import json import logging import os from datetime import timezone, timedelta from types import SimpleNamespace CST = timezone(timedelta(hours=8)) from fastapi import APIRouter, Depends, HTTPException, Query, UploadFile, File, status from fastapi.responses import RedirectResponse from sqlalchemy import select, func from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import get_db, get_current_user from app.models.user import User from app.models.project import Project from app.models.generation_record import GenerationRecord from app.models.system_config import SystemConfig from app.schemas.generation import ( OptimizeParams, GenerationRecordOut, GenerationRecordPageListOut, OptimizeResult, UpdatePromptRequest, ) from app.services.generation.pipeline.db_lock_service import ( DatabaseRowLockBusy, execute_with_lock_timeout, ) from app.services.credits import deduct_credits, add_credits, calc_text_credits from app.services.llm import optimize_prompt from app.services.video_url import validate_and_get_record_id, get_video_stream_url from app.services.private_portrait.reference_resolver import batch_resolve_private_portrait_reference_display_urls, resolve_private_portrait_reference_display_urls from app.services.resource_signed_url_service import build_resource_signed_url from app.services.resource_capacity_service import assert_user_resource_capacity_available from app.services.upload_resource import delete_unbound_upload_resource, upload_reference_file, cleanup_upload_resource_files_after_commit from app.services.upload_resource.log_service import log_upload_resource_exception, safe_rollback_with_log from app.enums.upload_resource import UploadResourceEventEnum, UploadResourceModuleEnum, UploadResourceTypeEnum from app.enums.generation_status import ( ASPECT_RATIOS, DURATIONS, IMAGE_SIZES, RESOLUTIONS, GenerationRecordPipelineStage, GenerationType, ) from app.enums.common import LogEventStatusEnum from app.enums.generation_record import ( GenerationRecordConfigSourceEnum, GenerationRecordEventTypeEnum, ) from app.services.generation.billing_service import ( CHARGE_TEXT_PROMPT, OWNER_GENERATION_RECORD, build_credit_biz_key, charge_generation_media_for_record, get_next_credit_attempt_no, ) from app.services.generation.ai.engine_service import ( get_image_engine, get_video_engine, image_supported_sizes, parse_json_list, ) from app.services.generation.pipeline.generation_record_config_service import ( ensure_generation_record_config_frozen, freeze_generation_record_config_with_log, frozen_generation_record_engine_view, generation_record_config_fallback_hint, generation_record_engine_snapshot, is_generation_record_config_complete, is_generation_record_config_recoverable, log_generation_record_config_event, ) from app.services.generation.media_reference_service import ( calculate_media_reference_usage, validate_media_reference_usage_for_engine, ) from app.services.credit_record_meta_service import build_generation_record_prompt_meta from app.enums.audio_reference import ( AUDIO_ALLOWED_EXTENSIONS, AUDIO_ALLOWED_MIME_TYPES, AUDIO_MAX_FILE_SIZE_MB, ) from app.utils.id_gen import generate_id from app.utils.exceptions import RecordNotFoundError, InvalidStatusError router = APIRouter(prefix="/generation-records", tags=["generation"]) logger = logging.getLogger("videogen") def _engine_snapshot(record: GenerationRecord) -> dict | None: return generation_record_engine_snapshot(record) def _record_config_complete(record: GenerationRecord) -> bool: return is_generation_record_config_complete(record) def _canonical_references(value: object) -> str: return json.dumps(value or [], ensure_ascii=False, sort_keys=True, separators=(",", ":")) def _idempotency_config_matches(record: GenerationRecord, req: OptimizeParams) -> bool: try: existing_references = json.loads(record.media_references) if record.media_references else [] except (TypeError, json.JSONDecodeError): return False if ( str(record.project_id) != str(req.project_id) or record.original_prompt != req.prompt or record.gen_type != req.gen_type.value or str(record.engine_id or "") != str(req.engine_id) or bool(record.include_media_references) != bool(req.include_media_references) or _canonical_references(existing_references) != _canonical_references(req.references) ): return False if req.gen_type == GenerationType.video: return ( record.duration == req.duration and record.aspect_ratio == req.aspect_ratio and record.resolution == req.resolution ) return ( record.image_size == req.image_size and record.image_proportion == req.image_proportion and record.image_px == req.image_px ) def _record_status_view(record: GenerationRecord) -> dict[str, object]: config_complete = _record_config_complete(record) config_recoverable = is_generation_record_config_recoverable(record) prompt_failure = record.status == "failed" and record.resource_generation_started_at is None resource_failure = record.status == "failed" and record.resource_generation_started_at is not None if record.status in {"pending", "optimizing"}: client_status = "prompt_processing" operation_phase = "prompt" elif record.status == "prompt_optimized": client_status = "ready" operation_phase = "prompt" elif record.status == "generating": client_status = "generating" operation_phase = "resource" elif record.status == "completed": client_status = "success" operation_phase = "resource" else: client_status = "failure" operation_phase = "prompt" if prompt_failure else "resource" return { "config_complete": config_complete, "config_recoverable": config_recoverable, "config_fallback_hint": generation_record_config_fallback_hint(record), "can_generate": record.status == "prompt_optimized" and (config_complete or config_recoverable), "can_retry": resource_failure and config_complete and record.pipeline_stage != GenerationRecordPipelineStage.UPSCALE_FAILED.value, "should_poll": record.status in {"optimizing", "generating"}, "client_status": client_status, "operation_phase": operation_phase, } def _frozen_engine_view(record: GenerationRecord) -> SimpleNamespace: return frozen_generation_record_engine_view(record) 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: try: refs = json.loads(record.media_references) except (json.JSONDecodeError, TypeError): refs = None error_message = record.error_message if error_message: from app.services.error_codes import ARK_ERRORS import re match = re.search(r"code='([^']+)'", error_message) if match: code = match.group(1) if code in ARK_ERRORS: error_message = ARK_ERRORS[code] else: parts = error_message.split(":") if len(parts) >= 2 and parts[1].strip() in ARK_ERRORS: error_message = ARK_ERRORS[parts[1].strip()] return GenerationRecordOut( id=record.id, project_id=record.project_id, project_name=project_name, original_prompt=record.original_prompt, optimized_prompt=record.optimized_prompt, gen_type=record.gen_type, duration=record.duration, aspect_ratio=record.aspect_ratio, resolution=record.resolution, image_size=record.image_size, image_proportion=record.image_proportion, image_px=record.image_px, status=record.status, pipeline_stage=record.pipeline_stage, video_upscale_enabled=bool(record.video_upscale_enabled_snapshot), 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 '', 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), **_record_status_view(record), 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), video_tokens_used=record.video_tokens_used or 0, image_tokens_used=record.image_tokens_used or 0, error_message=error_message, created_at=record.created_at, generated_at=record.generated_at, ) @router.get("", response_model=GenerationRecordPageListOut) async def list_records( project_id: str | None = Query( None, alias="project_id", description="查询单个项目的生成记录", ), status: str | None = Query( None, description="查询状态,可以不传。prompt_optimized:待生成 | generating:生成中 | failed:失败 | completed:成功", examples=["completed"], ), page: int = Query( 1, ge=1, description="分页页码,从1开始", examples=[1], ), page_size: int = Query( 10, ge=1, le=100, description="每页返回的生成记录数量,范围 1~100", examples=[10], ), record_ids: list[str] | None = Query( None, description="对应记录ID数组", examples=[["0019e8c55ddd1429b86", "0019e8c54dfc1e13262"]], ), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): allowed_statuses = { "prompt_optimized", "generating", "failed", "completed", } if status and status not in allowed_statuses: raise HTTPException( status_code=400, detail="状态参数错误,仅支持:prompt_optimized、generating、failed、completed", ) offset = (page - 1) * page_size conditions = [ GenerationRecord.user_id == current_user.id, GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None), ] if project_id: conditions.append(GenerationRecord.project_id == project_id) if status: conditions.append(GenerationRecord.status == status) if record_ids: conditions.append(GenerationRecord.id.in_(record_ids)) total_result = await db.execute( select(func.count(GenerationRecord.id)) .join(Project, GenerationRecord.project_id == Project.id) .where(*conditions) ) total = total_result.scalar_one() or 0 query = ( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where(*conditions) .order_by(GenerationRecord.created_at.desc()) .offset(offset) .limit(page_size) ) result = await db.execute(query) rows = result.all() refs_map = await batch_resolve_private_portrait_reference_display_urls( db, {record.id: json.loads(record.media_references) if record.media_references else None for record, _project_name in rows}, user_id=current_user.id, ) return { "total": int(total), "page": page, "page_size": page_size, "items": [ _record_to_out(record, project_name, refs_override=refs_map.get(record.id)) for record, project_name in rows ], } @router.post("/optimize", response_model=OptimizeResult) async def optimize( req: OptimizeParams, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_id_snapshot = str(current_user.id) # Validate project, engine and the complete generation configuration before # charging prompt credits or invoking the LLM. proj_result = await db.execute( select(Project).where( Project.id == req.project_id, Project.user_id == user_id_snapshot, Project.deleted_at.is_(None), ).limit(1) ) project = proj_result.scalar_one_or_none() if not project: raise HTTPException(status_code=404, detail="项目不存在") log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_START, event_status=LogEventStatusEnum.STARTED, source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE, record=GenerationRecord( id=req.idempotency_key or "pending", user_id=user_id_snapshot, project_id=req.project_id, original_prompt=req.prompt, gen_type=req.gen_type.value, duration=req.duration if req.gen_type == GenerationType.video else None, aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None, resolution=req.resolution if req.gen_type == GenerationType.video else None, image_size=req.image_size if req.gen_type == GenerationType.image else None, image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, image_px=req.image_px if req.gen_type == GenerationType.image else None, include_media_references=bool(req.include_media_references), media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None, engine_id=req.engine_id, ), detail={ "project_id": req.project_id, "gen_type": req.gen_type.value, "engine_id": req.engine_id, "include_media_references": bool(req.include_media_references), "reference_count": len(req.references or []), "source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value, }, ) # Idempotency must be checked before the temporary prompt-credit hold. The # key is bound to one immutable generation configuration; reusing it with a # different engine, parameter set or attachment selection is rejected. if req.idempotency_key: existing = await db.execute( select(GenerationRecord, Project.name) .join(Project, GenerationRecord.project_id == Project.id) .where( GenerationRecord.user_id == user_id_snapshot, GenerationRecord.deleted_at.is_(None), Project.deleted_at.is_(None), GenerationRecord.idempotency_key == req.idempotency_key, ) .order_by(GenerationRecord.created_at.desc()) .limit(1) ) row = existing.first() if row: existing_record, project_name = row if not _idempotency_config_matches(existing_record, req): raise HTTPException( status_code=409, detail="幂等键已绑定其他生成配置,请重新提交", ) if not existing_record.optimized_prompt or not _record_config_complete(existing_record): raise HTTPException( status_code=409, detail="幂等记录配置不完整,请使用新的幂等键重新提交", ) refs = await resolve_private_portrait_reference_display_urls( db, json.loads(existing_record.media_references) if existing_record.media_references else None, user_id=user_id_snapshot, ) return OptimizeResult( optimized_prompt=existing_record.optimized_prompt, text_credits_cost=existing_record.text_credits_cost or 0.0, text_tokens_used=existing_record.text_tokens_used or 0, record=_record_to_out(existing_record, project_name, refs_override=refs), ) if req.gen_type == GenerationType.video: if req.duration not in DURATIONS: raise HTTPException(status_code=400, detail=f"视频时长必须为{DURATIONS}秒之一") if req.aspect_ratio not in ASPECT_RATIOS: raise HTTPException(status_code=400, detail="不支持的画面比例") if req.resolution not in RESOLUTIONS: raise HTTPException(status_code=400, detail="不支持的分辨率") engine = await get_video_engine(db, req.engine_id) _validate_video_engine_selection( engine, aspect_ratio=req.aspect_ratio, resolution=req.resolution, duration=int(req.duration), ) else: if req.image_size not in IMAGE_SIZES: raise HTTPException(status_code=400, detail=f"图片分辨率必须为{IMAGE_SIZES}之一") if not req.image_proportion or not req.image_px: raise HTTPException(status_code=400, detail="图片生成需要指定比例和像素尺寸") engine = await get_image_engine(db, req.engine_id) _validate_image_engine_selection(engine, image_size=req.image_size) project_name_snapshot = str(project.name) project_industry_snapshot = str(project.industry or "") engine_snapshot_source = SimpleNamespace( **{ key: value for key, value in vars(engine).items() if key != "_sa_instance_state" } ) reference_usage = calculate_media_reference_usage( json.dumps(req.references, ensure_ascii=False) if req.references else None, include=bool(req.include_media_references), ) validate_media_reference_usage_for_engine( reference_usage, gen_type=req.gen_type.value, engine=engine, ) log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.PROMPT_CONFIG_VALIDATE_SUCCESS, event_status=LogEventStatusEnum.SUCCESS, source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE, record=GenerationRecord( id=req.idempotency_key or "pending", user_id=user_id_snapshot, project_id=req.project_id, original_prompt=req.prompt, gen_type=req.gen_type.value, duration=req.duration if req.gen_type == GenerationType.video else None, aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None, resolution=req.resolution if req.gen_type == GenerationType.video else None, image_size=req.image_size if req.gen_type == GenerationType.image else None, image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, image_px=req.image_px if req.gen_type == GenerationType.image else None, include_media_references=bool(req.include_media_references), media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None, engine_id=req.engine_id, ), detail={ "project_id": req.project_id, "gen_type": req.gen_type.value, "engine_id": req.engine_id, "include_media_references": bool(req.include_media_references), "reference_count": len(req.references or []), "media_reference_usage": getattr(reference_usage, "__dict__", None) or str(reference_usage), "source": GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE.value, }, ) hold_credits = 5 hold_result = await db.execute( select(SystemConfig).where(SystemConfig.key == "optimize_hold_credits").limit(1) ) hold_row = hold_result.scalar_one_or_none() if hold_row and hold_row.value: try: hold_credits = max(0, int(hold_row.value)) except (ValueError, TypeError): hold_credits = 5 hold_scope = req.idempotency_key or generate_id() hold_biz_key = f"optimize_hold:{hold_scope}" hold_refund_biz_key = f"optimize_hold_refund:{hold_scope}" await deduct_credits( db, user_id_snapshot, hold_credits, "AI创作预扣积分", biz_key=hold_biz_key, ) await db.commit() try: optimized, token_usage = await optimize_prompt( db, req.prompt, user_id=user_id_snapshot, industry_key=project_industry_snapshot, duration=req.duration if req.gen_type == GenerationType.video else None, image_size=req.image_size if req.gen_type == GenerationType.image else None, image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, image_px=req.image_px if req.gen_type == GenerationType.image else None, references=req.references, gen_type=req.gen_type.value, log_module="generation_record", log_step="prompt_optimize", log_project_id=req.project_id, ) except Exception as exc: from app.services.error_codes import extract_error_message await db.rollback() await add_credits( db, user_id_snapshot, hold_credits, "AI创作预扣积分退还", record_type="refund", biz_key=hold_refund_biz_key, refund_for_biz_key=hold_biz_key, ) await db.commit() raise HTTPException( status_code=502, detail=f"AI模型调用失败: {extract_error_message(exc, '提示词')}", ) from exc try: text_credits = await calc_text_credits( db, int(token_usage.get("input_tokens", 0) or 0), int(token_usage.get("output_tokens", 0) or 0), ) record = GenerationRecord( id=generate_id(), user_id=user_id_snapshot, project_id=req.project_id, original_prompt=req.prompt, optimized_prompt=optimized, gen_type=req.gen_type.value, duration=req.duration if req.gen_type == GenerationType.video else None, aspect_ratio=req.aspect_ratio if req.gen_type == GenerationType.video else None, resolution=req.resolution if req.gen_type == GenerationType.video else None, image_size=req.image_size if req.gen_type == GenerationType.image else None, image_proportion=req.image_proportion if req.gen_type == GenerationType.image else None, image_px=req.image_px if req.gen_type == GenerationType.image else None, status="prompt_optimized", pipeline_stage=None, credits_cost=0, text_credits_cost=round(text_credits, 2), text_tokens_used=int(token_usage.get("total_tokens", 0) or 0), media_references=json.dumps(req.references, ensure_ascii=False) if req.references else None, include_media_references=bool(req.include_media_references), idempotency_key=req.idempotency_key, ) if req.gen_type == GenerationType.video: from app.services.video_upscale.snapshot_service import build_video_upscale_snapshot provider_resolution, upscale_enabled, upscale_snapshot_json = await build_video_upscale_snapshot( db, target_resolution=req.resolution, aspect_ratio=req.aspect_ratio, supported_provider_resolutions=parse_json_list( engine_snapshot_source.supported_resolutions, [], ), ) record.provider_generation_resolution = provider_resolution record.video_upscale_enabled_snapshot = upscale_enabled record.video_upscale_snapshot_json = upscale_snapshot_json else: record.provider_generation_resolution = None record.video_upscale_enabled_snapshot = False record.video_upscale_snapshot_json = None freeze_generation_record_config_with_log( record, engine=engine_snapshot_source, source=GenerationRecordConfigSourceEnum.PROMPT_OPTIMIZE, ) db.add(record) await db.flush() prompt_attempt_no = 1 prompt_biz_key = build_credit_biz_key( owner_type=OWNER_GENERATION_RECORD, owner_id=record.id, attempt_no=prompt_attempt_no, charge_kind=CHARGE_TEXT_PROMPT, action="charge", ) prompt_meta = await build_generation_record_prompt_meta( db, record_id=record.id, attempt_no=prompt_attempt_no, charge_kind=CHARGE_TEXT_PROMPT, usage=token_usage, ) # Release the hold and charge the exact prompt usage in one transaction. await add_credits( db, user_id_snapshot, hold_credits, f"AI创作预扣积分退还 - {project_name_snapshot}", related_id=record.id, record_type="refund", biz_key=hold_refund_biz_key, refund_for_biz_key=hold_biz_key, ) await deduct_credits( db, user_id_snapshot, text_credits, f"提示词优化 - {project_name_snapshot}", related_id=record.id, biz_key=prompt_biz_key, record_meta=prompt_meta, ) record_id_snapshot = str(record.id) await db.commit() except Exception: await db.rollback() # Any local pricing/snapshot/persistence failure after the provider call # must release the committed hold. The refund key is idempotent. await add_credits( db, user_id_snapshot, hold_credits, "AI创作预扣积分退还", record_type="refund", biz_key=hold_refund_biz_key, refund_for_biz_key=hold_biz_key, ) await db.commit() raise 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=user_id_snapshot, ) return OptimizeResult( optimized_prompt=record.optimized_prompt or "", text_credits_cost=round(record.text_credits_cost or 0, 2), text_tokens_used=record.text_tokens_used or 0, record=_record_to_out(record, project_name, refs_override=refs), ) @router.post("/{record_id}/generate") async def generate_record_resource( record_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_id_snapshot = str(current_user.id) 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.user_id == user_id_snapshot, 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 RecordNotFoundError() record, project_name = row if record.status != "prompt_optimized": raise InvalidStatusError("只有提词已完成的记录可以发起资源生成") log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_START, event_status=LogEventStatusEnum.STARTED, source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK, record=record, detail={ "record_id": record.id, "project_id": record.project_id, "gen_type": record.gen_type, "config_complete_before": _record_config_complete(record), "config_recoverable": is_generation_record_config_recoverable(record), }, ) await ensure_generation_record_config_frozen( db, record, source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK, ) if not _record_config_complete(record): raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词") log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_CONFIG_READY, event_status=LogEventStatusEnum.SUCCESS, source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK, record=record, detail={ "record_id": record.id, "project_id": record.project_id, "gen_type": record.gen_type, "engine_id": record.engine_id, "duration": record.duration, "aspect_ratio": record.aspect_ratio, "resolution": record.resolution, "provider_generation_resolution": record.provider_generation_resolution, "image_size": record.image_size, "image_proportion": record.image_proportion, "image_px": record.image_px, "include_media_references": bool(record.include_media_references), }, ) await assert_user_resource_capacity_available(db, user_id_snapshot) attempt_no = await get_next_credit_attempt_no( db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id, ) # Confirm that the bound engine still exists and is active, but never rebuild # the snapshot or replace the user's frozen parameters with current defaults. if record.gen_type == GenerationType.video.value: await get_video_engine(db, record.engine_id) else: await get_image_engine(db, record.engine_id) frozen_engine = _frozen_engine_view(record) 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=frozen_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=record.engine_id, ) record.credits_cost = round( float(record.credits_cost or 0) + float(billing.total_charged or 0), 2, ) log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_BILLING_SUCCESS, event_status=LogEventStatusEnum.SUCCESS, source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK, record=record, detail={ "record_id": record.id, "attempt_no": attempt_no, "engine_id": record.engine_id, "charged": float(billing.total_charged or 0), "credits_cost_total": record.credits_cost, }, ) from app.services.generation.pipeline.generation_record_service import ( commit_and_enqueue_generation_record, prepare_generation_record_execution, ) prepare_generation_record_execution(record, attempt_no=attempt_no) await db.flush() record_id_snapshot = str(record.id) enqueue_log_record = GenerationRecord( id=record_id_snapshot, user_id=user_id_snapshot, project_id=str(record.project_id), original_prompt=record.original_prompt or "", gen_type=record.gen_type, duration=record.duration, aspect_ratio=record.aspect_ratio, resolution=record.resolution, image_size=record.image_size, image_proportion=record.image_proportion, image_px=record.image_px, engine_id=record.engine_id, include_media_references=bool(record.include_media_references), media_references=record.media_references, ) enqueue_log_detail = { "record_id": record_id_snapshot, "attempt_no": attempt_no, "reason": "generation_record_api_generate", } await commit_and_enqueue_generation_record( db, record, reason="generation_record_api_generate", ) log_generation_record_config_event( event_type=GenerationRecordEventTypeEnum.GENERATION_SUBMIT_ENQUEUE_SUCCESS, event_status=LogEventStatusEnum.SUCCESS, source=GenerationRecordConfigSourceEnum.LEGACY_GENERATE_FALLBACK, record=enqueue_log_record, detail=enqueue_log_detail, ) 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=user_id_snapshot, ) return _record_to_out(record, project_name, refs_override=refs) @router.post("/{record_id}/retry") async def retry_generation( record_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): user_id_snapshot = str(current_user.id) 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.user_id == user_id_snapshot, 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 RecordNotFoundError() record, project_name = row if record.status != "failed": raise InvalidStatusError("只有失败的记录可以重试") if record.resource_generation_started_at is None: raise InvalidStatusError("该记录失败于提词阶段,请重新生成提词") if record.pipeline_stage == GenerationRecordPipelineStage.UPSCALE_FAILED.value: raise InvalidStatusError("该任务生成失败,请联系客服进行修复") if not _record_config_complete(record): raise InvalidStatusError("该记录缺少冻结的生成配置,请重新生成提词") await assert_user_resource_capacity_available(db, user_id_snapshot) attempt_no = await get_next_credit_attempt_no( db, owner_type=OWNER_GENERATION_RECORD, owner_id=record.id, ) if record.gen_type == GenerationType.video.value: await get_video_engine(db, record.engine_id) else: await get_image_engine(db, record.engine_id) frozen_engine = _frozen_engine_view(record) 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=frozen_engine, ) billing = await charge_generation_media_for_record( db, record=record, project_name=project_name, description_prefix="资源生成重试-", attempt_no=attempt_no, engine_id=record.engine_id, ) 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) from app.services.generation.pipeline.generation_record_service import ( commit_and_enqueue_generation_record, prepare_generation_record_execution, ) prepare_generation_record_execution(record, attempt_no=attempt_no) await db.flush() 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=user_id_snapshot, ) return _record_to_out(record, project_name, refs_override=refs) @router.put("/{record_id}/prompt") async def update_prompt( record_id: str, req: UpdatePromptRequest, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): result = await db.execute( select(GenerationRecord).where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, GenerationRecord.deleted_at.is_(None), ) .limit(1) ) record = result.scalar_one_or_none() if not record: raise RecordNotFoundError() if record.status != "prompt_optimized": raise InvalidStatusError("只有待生成状态可以修改提示词") record.optimized_prompt = req.optimized_prompt await db.flush() return {"message": "ok"} @router.get("/{record_id}/video") async def get_video( record_id: str, token: str = Query(...), db: AsyncSession = Depends(get_db), ): """Validate temp token and redirect to video URL.""" validated_id = await validate_and_get_record_id(token) if validated_id != record_id: raise HTTPException(status_code=403, detail="无效的视频链接") video_url = await get_video_stream_url(db, record_id) if not video_url: raise HTTPException(status_code=404, detail="视频不存在") return RedirectResponse(url=video_url) @router.get("/{record_id}/queue-status") async def get_queue_status( record_id: str, current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """Get queue position, estimated wait time, and current status for a generation record.""" from sqlalchemy import func result = await db.execute( select(GenerationRecord).where( GenerationRecord.id == record_id, GenerationRecord.user_id == current_user.id, GenerationRecord.deleted_at.is_(None), ) .limit(1) ) record = result.scalar_one_or_none() if not record: raise RecordNotFoundError() queue_position = None estimated_wait_seconds = None if record.status == "generating": resource_started_at = record.resource_generation_started_at or record.created_at ahead_result = await db.execute( select(func.count(GenerationRecord.id)).where( GenerationRecord.status == "generating", GenerationRecord.deleted_at.is_(None), func.coalesce( GenerationRecord.resource_generation_started_at, GenerationRecord.created_at, ) < resource_started_at, ) ) ahead = ahead_result.scalar() or 0 queue_position = ahead + 1 estimated_wait_seconds = ahead * 60 return { "record_id": record.id, "status": record.status, "pipeline_stage": record.pipeline_stage, "video_upscale_enabled": bool(record.video_upscale_enabled_snapshot), "queue_position": queue_position, "estimated_wait_seconds": estimated_wait_seconds, } @router.post( "/upload-image", summary="上传 AI 创作普通参考图片", description=( "上传普通 AI 创作参考图片,写入 UploadResource 资源账本并纳入用户上传容量统计。" "返回 resource_id 和 url。该文件在未绑定业务记录前可通过 /generation-records/delete-file 单独删除," "也会出现在 /upload-resources/history 历史素材中供 AI 创作复用。" ), responses={400: {"description": "文件类型、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}}, ) async def upload_image( file: UploadFile = File(...), current_user: User = Depends(get_current_user), gen_type: str = Query("video", description="生成类型:video-视频,image-图片"), db: AsyncSession = Depends(get_db), ): """上传普通参考图片,记录 UploadResource 并纳入用户容量统计。""" result = await upload_reference_file( db, file=file, current_user=current_user, module=UploadResourceModuleEnum.COMMON.value, resource_type=UploadResourceTypeEnum.IMAGE.value, gen_type=gen_type, ) await db.commit() return { "url": result.url, "filename": result.filename, "type": "image", "gen_type": gen_type, "resource_id": result.resource_id, "file_size_bytes": result.file_size_bytes, } @router.post( "/upload-video", summary="上传 AI 创作普通参考视频", description=( "上传普通 AI 创作参考视频,写入 UploadResource 资源账本并纳入用户上传容量统计。" "duration_seconds 为前端识别的视频秒数,用于历史复用和 AI 创作视频总时长校验。" "未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。" ), responses={400: {"description": "文件类型、大小、容量或视频参数校验失败"}, 401: {"description": "未登录或 Token 无效"}}, ) async def upload_video( file: UploadFile = File(...), current_user: User = Depends(get_current_user), duration_seconds: float | None = Query(None, description="前端识别的视频时长秒数,可选"), db: AsyncSession = Depends(get_db), ): """上传普通参考视频,记录 UploadResource 并纳入用户容量统计。""" result = await upload_reference_file( db, file=file, current_user=current_user, module=UploadResourceModuleEnum.COMMON.value, resource_type=UploadResourceTypeEnum.VIDEO.value, duration_seconds=duration_seconds, ) await db.commit() return { "url": result.url, "filename": result.filename, "type": "video", "resource_id": result.resource_id, "file_size_bytes": result.file_size_bytes, "duration_seconds": result.duration_seconds, } @router.post( "/upload-audio", summary="上传 AI 创作普通参考音频", description=( "上传普通 AI 创作参考音频,写入 UploadResource 资源账本并纳入用户上传容量统计。" "当前仅支持 mp3、wav;单文件大小受 AUDIO_MAX_FILE_SIZE_MB 限制。" "duration_seconds 为前端识别的音频秒数,用于 AI 创作音频总时长校验。" "未绑定业务记录前可单独删除,并会出现在 /upload-resources/history 历史素材中。" ), responses={400: {"description": "音频格式、MIME、大小或容量校验失败"}, 401: {"description": "未登录或 Token 无效"}}, ) async def upload_audio( file: UploadFile = File(...), current_user: User = Depends(get_current_user), duration_seconds: float | None = Query(None, description="前端识别的音频时长秒数,可选"), db: AsyncSession = Depends(get_db), ): """上传普通参考音频,记录 UploadResource 并纳入用户容量统计。""" import os ext = os.path.splitext(file.filename or "")[1].lower().lstrip(".") if ext not in AUDIO_ALLOWED_EXTENSIONS: raise HTTPException(status_code=400, detail="仅支持 mp3、wav 音频文件") expected_mime = AUDIO_ALLOWED_MIME_TYPES.get(ext) if expected_mime and file.content_type and file.content_type != expected_mime: raise HTTPException(status_code=400, detail=f"音频 MIME 类型错误,{ext} 必须为 {expected_mime}") result = await upload_reference_file( db, file=file, current_user=current_user, module=UploadResourceModuleEnum.COMMON.value, resource_type=UploadResourceTypeEnum.AUDIO.value, duration_seconds=duration_seconds, max_bytes=AUDIO_MAX_FILE_SIZE_MB * 1024 * 1024, ) await db.commit() return { "url": result.url, "filename": result.filename, "type": "audio", "resource_id": result.resource_id, "file_size_bytes": result.file_size_bytes, "duration_seconds": result.duration_seconds, } @router.post( "/delete-file", summary="删除未绑定上传文件", description=( "删除当前用户自己的未绑定上传文件,并释放 UploadResource 上传容量。" "仅允许删除 bind_status=pending、delete_policy=user_deletable、未绑定 source_model/source_id 的资源。" "删除顺序为主事务先 soft delete 并 commit,commit 成功后再清理真实文件。" "该接口保留给单文件删除;批量删除请使用 DELETE /upload-resources/history/batch。" ), responses={400: {"description": "文件路径无效、文件已被模块任务使用或不可单独删除"}, 401: {"description": "未登录或 Token 无效"}, 403: {"description": "无权删除此文件"}}, ) async def delete_upload( url: str = Query(..., description="文件URL,如 /uploads/images/2024/01/01/video_img_xxx.png"), current_user: User = Depends(get_current_user), db: AsyncSession = Depends(get_db), ): """删除未绑定业务记录的上传文件,并释放 UploadResource 容量。""" user_id = str(current_user.id) pending_ids: list[str] = [] legacy_paths: list[str] = [] try: result = await delete_unbound_upload_resource(db, user=current_user, url=url) pending_ids = list(result.pop("_pending_physical_delete_resource_ids", []) or []) legacy_paths = list(result.pop("_legacy_pending_delete_paths", []) or []) await db.commit() except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value, message="删除上传文件主事务回滚失败", user_id=user_id, detail={"url": url}, original_exc=exc, ) log_upload_resource_exception( event_type=UploadResourceEventEnum.DELETE_UPLOAD_FAILED.value, message=f"删除上传文件失败: {exc}", user_id=user_id, detail={"url": url}, exc=exc, ) raise if pending_ids or legacy_paths: try: await cleanup_upload_resource_files_after_commit(db, resource_ids=pending_ids, legacy_paths=legacy_paths) await db.commit() except Exception as exc: # noqa: BLE001 await safe_rollback_with_log( db, event_type=UploadResourceEventEnum.DELETE_UPLOAD_ROLLBACK_FAILED.value, message="删除上传文件 cleanup 事务回滚失败", user_id=user_id, detail={"url": url, "pending_ids": pending_ids, "legacy_paths": legacy_paths}, original_exc=exc, ) log_upload_resource_exception( event_type=UploadResourceEventEnum.DELETE_UPLOAD_CLEANUP_FAILED.value, message=f"删除上传文件后清理真实文件失败: {exc}", user_id=user_id, resource_ids=pending_ids, detail={"url": url, "legacy_paths": legacy_paths}, exc=exc, ) return result