from __future__ import annotations import json from datetime import datetime, timedelta, timezone from typing import Any from fastapi import HTTPException from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings from app.models.chat_generation_task import ChatGenerationTask from app.models.user import User from app.schemas.generation_ai import GenerationAIReference, GenerationAITaskCreate from app.services.generation_ai_service import ( IMAGE_DEFAULT_PROPORTION, IMAGE_DEFAULT_PX, IMAGE_DEFAULT_SIZE, VIDEO_DEFAULT_RATIO, VIDEO_DEFAULT_RESOLUTION, _build_image_snapshot, _build_video_snapshot, _get_image_engine, _get_video_engine, _image_supported_sizes, _parse_list, normalize_px, ) from app.services.generation_billing_service import OWNER_CHAT_GENERATION_TASK, charge_generation_media_by_params from app.utils.id_gen import generate_id def _json(data: Any) -> str | None: if data is None: return None return json.dumps(data, ensure_ascii=False, default=str) def _build_backend_idempotency_key(*, generation_mode: str, gen_type: str, task_id: str) -> str: """模块生成关联 ChatGenerationTask 的幂等键由后端生成。 不再接收前端透传,避免 user_id + generation_mode + idempotency_key 唯一索引被前端固定 key 或重复 key 拦截。 """ return f"{generation_mode}:{gen_type}:{task_id}"[:64] async def create_chat_generation_task_for_module( db: AsyncSession, *, current_user: User, generation_mode: str, gen_type: str, original_prompt: str, optimized_prompt: str | None = None, engine_id: str | None = None, media_references: list[dict[str, Any]] | None = None, idempotency_key: str | None = None, image_size: str | None = None, image_proportion: str | None = None, image_px: str | None = None, duration: int | None = None, aspect_ratio: str | None = None, resolution: str | None = None, billing_project_name: str = "模块生成任务", billing_description_prefix: str = "模块生成-", billing_source_module: str | None = None, billing_source_project_id: str | None = None, billing_source_step_id: str | None = None, billing_source_step_code: str | None = None, billing_scene: str | None = None, ) -> ChatGenerationTask: """创建可复用的 ChatGenerationTask 子任务。 和 /generation-ai 普通任务不同,generation_mode 由业务模块传入, 但仍复用同一套引擎校验、扣费、Celery 创建/轮询/下载逻辑。 """ gen_type = gen_type.lower().strip() if gen_type not in ("image", "video"): raise HTTPException(status_code=400, detail="gen_type 仅支持 image 或 video") task_id = generate_id() now = datetime.now(timezone.utc) refs = media_references or [] backend_idempotency_key = _build_backend_idempotency_key( generation_mode=generation_mode, gen_type=gen_type, task_id=task_id, ) if gen_type == "image": engine = await _get_image_engine(db, engine_id) sizes = _image_supported_sizes(engine) size = image_size or engine.default_size or IMAGE_DEFAULT_SIZE proportion = image_proportion or IMAGE_DEFAULT_PROPORTION px = normalize_px(image_px) if sizes: if size not in sizes: raise HTTPException(status_code=400, detail=f"图片分辨率档位不支持: {size}") if proportion not in sizes.get(size, {}): raise HTTPException(status_code=400, detail=f"图片比例不支持: {proportion}") px = px or normalize_px((sizes.get(size) or {}).get(proportion)) px = px or IMAGE_DEFAULT_PX media_billing = await charge_generation_media_by_params( db, user_id=current_user.id, record_id=task_id, gen_type="image", image_size=size, engine_id=engine.id, project_name=billing_project_name, description_prefix=billing_description_prefix, owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, source_module=billing_source_module, source_project_id=billing_source_project_id, source_step_id=billing_source_step_id, source_step_code=billing_source_step_code, billing_scene=billing_scene, ) snapshot = _build_image_snapshot(engine, size, proportion, px) task = ChatGenerationTask( id=task_id, user_id=current_user.id, original_prompt=original_prompt, optimized_prompt=optimized_prompt, gen_type="image", image_size=size, image_proportion=proportion, image_px=px, status="generating", generation_mode=generation_mode, pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=backend_idempotency_key, deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_IMAGE_DEADLINE_MINUTES), ) else: engine = await _get_video_engine(db, engine_id) ratio = aspect_ratio or VIDEO_DEFAULT_RATIO selected_resolution = resolution or VIDEO_DEFAULT_RESOLUTION selected_duration = duration or 4 ratios = _parse_list(engine.supported_ratios, []) resolutions = _parse_list(engine.supported_resolutions, []) durations = _parse_list(engine.supported_durations, []) if ratios and ratio not in ratios: raise HTTPException(status_code=400, detail=f"视频比例不支持: {ratio}") if resolutions and selected_resolution not in resolutions: raise HTTPException(status_code=400, detail=f"视频分辨率不支持: {selected_resolution}") if durations and selected_duration not in durations: raise HTTPException(status_code=400, detail=f"视频时长不支持: {selected_duration}") if engine.max_duration and selected_duration > engine.max_duration: raise HTTPException(status_code=400, detail=f"视频时长不能超过 {engine.max_duration} 秒") media_billing = await charge_generation_media_by_params( db, user_id=current_user.id, record_id=task_id, gen_type="video", duration=selected_duration, resolution=selected_resolution, engine_id=engine.id, project_name=billing_project_name, description_prefix=billing_description_prefix, owner_type=OWNER_CHAT_GENERATION_TASK, attempt_no=1, source_module=billing_source_module, source_project_id=billing_source_project_id, source_step_id=billing_source_step_id, source_step_code=billing_source_step_code, billing_scene=billing_scene, ) snapshot = _build_video_snapshot(engine, ratio, selected_resolution, selected_duration) task = ChatGenerationTask( id=task_id, user_id=current_user.id, original_prompt=original_prompt, optimized_prompt=optimized_prompt, gen_type="video", duration=selected_duration, aspect_ratio=ratio, resolution=selected_resolution, image_size=image_size or IMAGE_DEFAULT_SIZE, image_proportion=image_proportion or IMAGE_DEFAULT_PROPORTION, image_px=normalize_px(image_px) or IMAGE_DEFAULT_PX, status="generating", generation_mode=generation_mode, pipeline_stage="queued", engine_id=engine.id, engine_snapshot_json=_json(snapshot), media_references=_json(refs) if refs else None, credits_cost=round(media_billing.total_charged, 2), idempotency_key=backend_idempotency_key, deadline_at=now + timedelta(minutes=settings.CHATAPI_ASYNC_VIDEO_DEADLINE_MINUTES), ) db.add(task) await db.flush() return task