diff --git a/apps/api/app/api/routes/generation_cover.py b/apps/api/app/api/routes/generation_cover.py index ef4400615..45c5d27fb 100644 --- a/apps/api/app/api/routes/generation_cover.py +++ b/apps/api/app/api/routes/generation_cover.py @@ -1,15 +1,18 @@ """封面生成路由 — Generation 模块. 端点: -- POST /generate-cover AI 生成封面(从预览视频中抽帧) +- POST /generate-cover AI 生成封面(从最终成片视频中抽帧,兼容预览片段回退) 挂载路径: /api/v1/generation/generate-cover """ from __future__ import annotations +import ipaddress import logging +import re from typing import Any, List, Optional +from urllib.parse import urlparse from app.auth import AuthenticatedUser, get_current_user from app.dependencies import get_db_session, get_generated_video_repository @@ -24,6 +27,7 @@ from packages.adapters.sqlalchemy_impl.generation_task_repository import ( ) from packages.application import ListGeneratedVideosByTaskUseCase from packages.domain.config_schemas import normalize_plan_config +from packages.shared.storage import get_shared_storage_service from .templates_editor.dependencies import get_draft_plan_id, get_editor_services @@ -51,6 +55,14 @@ class GenerateCoverRequest(BaseModel): default=None, description="上传的封面图片 URL,仅 cover_type=upload 时有效", ) + generated_video_id: Optional[str] = Field( + default=None, + description="确认生成产出的最终视频 ID。传入后封面从该视频文件抽帧,而非预览片段。", + ) + video_url: Optional[str] = Field( + default=None, + description="最终视频 URL(兜底)。当 generated_video_id 不可用时,直接从此 URL 对应的视频抽帧。", + ) class GenerateCoverResponse(BaseModel): @@ -121,8 +133,6 @@ def _persist_cover_frame( exc_info=True, ) - from packages.shared.storage import get_shared_storage_service - storage = get_shared_storage_service() cover_key = f"covers/{plan_id}/cover_{uuid.uuid4().hex[:8]}.jpg" storage.upload_file( @@ -140,6 +150,106 @@ def _persist_cover_frame( Path(tmp_path).unlink(missing_ok=True) +def _get_task_video_url(db: Session, task_id: str) -> Optional[str]: + """从 GenerationTask 关联的 GeneratedVideo 中获取视频 storage_key / URL.""" + try: + video_repo = get_generated_video_repository(db) + use_case = ListGeneratedVideosByTaskUseCase(video_repo) + videos = use_case.execute(task_id) + if videos: + return getattr(videos[0], "file_url", "") or "" + except Exception: + logger.warning("[封面生成] 获取任务视频失败: task_id=%s", task_id, exc_info=True) + return None + + +def _resolve_storage_key_to_url(storage_key: str) -> Optional[str]: + """将 storage_key 或完整 URL 转换为可访问的裸 URL。""" + if not storage_key: + return None + try: + if storage_key.startswith("http"): + url = storage_key + else: + storage_svc = get_shared_storage_service() + url = storage_svc.get_url(storage_key) + if url: + url = re.sub(r"(? str: + """从 endpoint / URL 字符串中安全提取主机名(兼容有无 scheme 两种配置)。""" + v = (value or "").strip().lower() + if not v: + return "" + if "://" in v: + return (urlparse(v).hostname or "").lower() + # 无 scheme:去掉可能的端口(host:port),urlparse 补 // 以正确解析 + return (urlparse("//" + v).hostname or "").lower() + + +def _is_private_or_reserved_host(host: str) -> bool: + """判断主机名是否为内网/回环/链路本地/保留地址(IPv4 与 IPv6 统一处理)。 + + 使用标准库 ipaddress 判定;非 IP 主机名(如 localhost)单独处理。 + """ + h = host.strip().lower() + if h in {"localhost", "0.0.0.0", "::", "::1"}: + return True + try: + addr = ipaddress.ip_address(h) + # is_private 覆盖 10/8、172.16/12、192.168/16、127/8、169.254/16、 + # ::1、fc00::/7、fe80::/10 等全部私有/保留段 + return bool(addr.is_private or addr.is_loopback or addr.is_link_local or addr.is_reserved) + except ValueError: + return False + + +def _is_trusted_media_url(url: str) -> bool: + """校验 URL 是否指向受信任的存储域名(OSS bucket / 本地存储),防止 SSRF。 + + 用户可通过 video_url 传入视频地址,但服务端(MediaKit)会主动请求该 URL, + 因此必须限制为自家存储域名,拒绝内网地址、元数据地址等任意主机。 + """ + if not url: + return False + try: + parsed = urlparse(url.strip()) + if parsed.scheme not in ("http", "https"): + return False + host = (parsed.hostname or "").lower() + if not host: + return False + # 拒绝一切内网/回环/链路本地/保留地址(IPv4 + IPv6,标准库判定) + if _is_private_or_reserved_host(host): + return False + # 允许:自家 OSS bucket 域名(.)或 endpoint 自身及其子域 + try: + storage_svc = get_shared_storage_service() + trusted_hosts = set() + public_base = getattr(storage_svc, "public_url", "") or "" + h1 = _endpoint_host(public_base) + if h1: + trusted_hosts.add(h1) + h2 = _endpoint_host(getattr(storage_svc, "endpoint", "") or "") + if h2: + trusted_hosts.add(h2) + for trusted in trusted_hosts: + if host == trusted or host.endswith("." + trusted): + return True + except Exception: + logger.warning("[封面生成] 存储域名白名单初始化失败,URL 校验从严拒绝", exc_info=True) + return False + return False + except Exception: + logger.warning("[封面生成] video_url 白名单校验异常,从严拒绝: url=%s", url[:80], exc_info=True) + return False + + @router.post("/generate-cover", response_model=GenerateCoverResponse) def generate_cover( body: GenerateCoverRequest, @@ -149,12 +259,16 @@ def generate_cover( db: Session = Depends(get_db_session), current_user: AuthenticatedUser = Depends(get_current_user), ) -> GenerateCoverResponse: - """AI 生成封面 — 从预览视频中抽帧. + """AI 生成封面 — 优先从最终成片视频中抽帧,回退到预览片段. 流程(串行): - 1. 预览视频已渲染完成(通过 3 步查找获取 URL) - 2. 用裸 URL 让 MediaKit 下载视频并抽帧 - 3. 帧图下载后上传到 OSS covers/ 路径 + 1. 优先使用前端传入的 generation_task_id 定位最终成片任务, + 或自动查找 plan 关联的已完成最终成片任务(is_preview=False) + 2. 回退:从预览片段获取视频 URL(兼容旧流程) + 3. 用裸 URL 让 MediaKit 下载视频并抽帧 + 4. 帧图下载后上传到 OSS covers/ 路径 + + MediaKit 的调用方式(strategy / max_frames / 轮询 / 重试 / 降级)不变。 """ _, plan_svc = services plan = plan_svc.get_plan_or_raise(plan_id) @@ -182,27 +296,111 @@ def generate_cover( ) return GenerateCoverResponse(plan_id=plan_id, cover=cover_data) - # ── 3 步查找预览视频 URL ────────────────────────────────────────── - # 第一步:从 plan.config 读取 + # ── 查找用于抽帧的视频 URL ──────────────────────────────────────── + # 优先级: + # 0. 请求体显式传入的 generation_task_id(最终成片任务) + # 1. plan.config.rendered_storage_key + # 2. plan.config.generation_task_id 对应的任务 + # 3. source_edit_plan_id 关联的已完成「最终成片」任务(is_preview=False) + # 4. source_edit_plan_id 关联的已完成预览任务(is_preview=True,兼容回退) + # 5. user + template 最近的已完成预览任务(兜底) logger.info("[封面生成] 步骤1: 从 plan.config 查找 rendered_storage_key: plan_id=%s", plan_id) rendered_storage_key = (plan.config or {}).get("rendered_storage_key", "") - # 第二步:如果还没有,通过 generation_task_id 查找预览任务的产物 + # 步骤 0:请求体传入最终视频标识(generated_video_id 或 video_url) + if not rendered_storage_key: + # 0a:通过 generated_video_id 查找最终成片视频 + if body.generated_video_id: + logger.info( + "[封面生成] 步骤0a: 使用 generated_video_id: plan_id=%s video_id=%s", + plan_id, + body.generated_video_id, + ) + try: + gv_repo = get_generated_video_repository(db) + gv = gv_repo.get(body.generated_video_id) + if gv: + file_url = getattr(gv, "file_url", "") or "" + if file_url: + # 权限校验(双重,任何一层确认归属不符即拒绝): + # 1) GeneratedVideo.user_id 直接归属(老数据可能为空,为空时不据此放行) + gv_owner = (getattr(gv, "user_id", "") or "").strip() + if gv_owner and gv_owner != current_user.user.id: + raise HTTPException(status_code=403, detail="无权访问该视频") + # 2) 关联 generation_task 归属校验;关联任务缺失时不可静默放行: + # 若 video 自身无 owner 信息且关联任务也查不到,拒绝访问 + gv_task_id = getattr(gv, "generation_task_id", "") or "" + task0 = None + if gv_task_id: + try: + task0 = SQLAlchemyGenerationTaskRepository(db).get(gv_task_id) + except Exception: + logger.warning( + "[封面生成] 步骤0a关联任务查询异常: plan_id=%s task_id=%s", + plan_id, + gv_task_id, + exc_info=True, + ) + if task0 is not None: + task_owner = (getattr(task0, "created_by_user_id", "") or "").strip() + if task_owner and task_owner != current_user.user.id: + raise HTTPException(status_code=403, detail="无权访问该视频") + elif not gv_owner: + # video 无 owner 且关联任务不存在/无法确认归属 → 拒绝,防止越权 + logger.warning( + "[封面生成] 步骤0a视频归属无法确认,拒绝访问: plan_id=%s video_id=%s", + plan_id, + body.generated_video_id, + ) + raise HTTPException(status_code=403, detail="无权访问该视频") + rendered_storage_key = file_url + logger.info( + "[封面生成] ✅ 步骤0a找到最终成片: plan_id=%s video_id=%s url=%s", + plan_id, + body.generated_video_id, + file_url[:80], + ) + except HTTPException: + raise + except Exception: + logger.warning( + "[封面生成] 步骤0a查找视频失败: plan_id=%s video_id=%s", + plan_id, + body.generated_video_id, + exc_info=True, + ) + + # 0b:直接使用 video_url(兜底)— 必须通过存储域名白名单校验,防止 SSRF + if not rendered_storage_key and body.video_url: + if _is_trusted_media_url(body.video_url): + logger.info( + "[封面生成] 步骤0b: 使用请求体传入的 video_url(白名单通过): plan_id=%s url=%s", + plan_id, + body.video_url[:80], + ) + rendered_storage_key = body.video_url + else: + logger.warning( + "[封面生成] 步骤0b: video_url 不在受信任存储域名白名单内,已忽略: plan_id=%s url=%s", + plan_id, + body.video_url[:80], + ) + + # 步骤 2:通过 plan.config.generation_task_id 查找 if not rendered_storage_key: generation_task_id = (plan.config or {}).get("generation_task_id", "") - logger.info( - "[封面生成] 步骤2: 通过 generation_task_id 查找: plan_id=%s task_id=%s", plan_id, generation_task_id - ) if generation_task_id: + logger.info( + "[封面生成] 步骤2: 通过 plan.config.generation_task_id 查找: plan_id=%s task_id=%s", + plan_id, + generation_task_id, + ) try: - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - task = gen_task_repo.get(generation_task_id) + _repo = SQLAlchemyGenerationTaskRepository(db) + task = _repo.get(generation_task_id) if task: - video_repo = get_generated_video_repository(db) - use_case = ListGeneratedVideosByTaskUseCase(video_repo) - videos = use_case.execute(task.id) - if videos: - rendered_storage_key = getattr(videos[0], "file_url", "") or "" + rendered_storage_key = _get_task_video_url(db, task.id) or "" + if rendered_storage_key: logger.info( "[封面生成] ✅ 步骤2找到视频: plan_id=%s task_id=%s url=%s", plan_id, @@ -211,26 +409,23 @@ def generate_cover( ) except Exception: logger.warning( - "封面生成: 通过 generation_task_id 查找视频失败: plan_id=%s", + "[封面生成] 步骤2查找失败: plan_id=%s", plan_id, exc_info=True, ) - # 第 2.5 步:通过 plan_id 作为 source_edit_plan_id 查找关联的已完成预览任务 + # 步骤 3:通过 source_edit_plan_id 查找已完成「最终成片」任务(is_preview=False) if not rendered_storage_key: try: - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - logger.info("[封面生成] 步骤2.5: 通过 source_edit_plan_id 查找: plan_id=%s", plan_id) - preview_tasks = gen_task_repo.list_by_source_edit_plan(plan_id) - for pt in preview_tasks: - if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False): - video_repo = get_generated_video_repository(db) - use_case = ListGeneratedVideosByTaskUseCase(video_repo) - videos = use_case.execute(pt.id) - if videos: - rendered_storage_key = getattr(videos[0], "file_url", "") or "" + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info("[封面生成] 步骤3: 查找最终成片任务(is_preview=False): plan_id=%s", plan_id) + all_tasks = _repo.list_by_source_edit_plan(plan_id) + for pt in all_tasks: + if getattr(pt, "status", "") == "completed" and not getattr(pt, "is_preview", False): + rendered_storage_key = _get_task_video_url(db, pt.id) or "" + if rendered_storage_key: logger.info( - "[封面生成] ✅ 步骤2.5找到视频: plan_id=%s task_id=%s url=%s", + "[封面生成] ✅ 步骤3找到最终成片: plan_id=%s task_id=%s url=%s", plan_id, pt.id, rendered_storage_key[:80], @@ -238,66 +433,74 @@ def generate_cover( break except Exception: logger.warning( - "封面生成: 通过 source_edit_plan_id 查找预览任务失败: plan_id=%s", + "[封面生成] 步骤3查找最终成片失败: plan_id=%s", plan_id, exc_info=True, ) - # 第三步:按 user + template 查找最近的已完成预览任务(兜底) + # 步骤 4:兼容回退 — 通过 source_edit_plan_id 查找已完成预览任务 if not rendered_storage_key: try: - gen_task_repo = SQLAlchemyGenerationTaskRepository(db) - logger.info("[封面生成] 步骤3: 通过 user+template 查找: plan_id=%s template_id=%s", plan_id, template_id) - preview_tasks = gen_task_repo.list_latest_completed_preview( + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info("[封面生成] 步骤4: 回退查找预览任务(is_preview=True): plan_id=%s", plan_id) + preview_tasks = _repo.list_by_source_edit_plan(plan_id) + for pt in preview_tasks: + if getattr(pt, "status", "") == "completed" and getattr(pt, "is_preview", False): + rendered_storage_key = _get_task_video_url(db, pt.id) or "" + if rendered_storage_key: + logger.info( + "[封面生成] ✅ 步骤4找到预览视频: plan_id=%s task_id=%s url=%s", + plan_id, + pt.id, + rendered_storage_key[:80], + ) + break + except Exception: + logger.warning( + "[封面生成] 步骤4查找预览任务失败: plan_id=%s", + plan_id, + exc_info=True, + ) + + # 步骤 5:按 user + template 查找最近的已完成预览任务(兜底) + if not rendered_storage_key: + try: + _repo = SQLAlchemyGenerationTaskRepository(db) + logger.info( + "[封面生成] 步骤5: 通过 user+template 查找预览任务: plan_id=%s template_id=%s", + plan_id, + template_id, + ) + preview_tasks = _repo.list_latest_completed_preview( user_id=str(current_user.user.id), template_id=template_id, ) if preview_tasks: - completed_preview = preview_tasks[0] - video_repo = get_generated_video_repository(db) - use_case = ListGeneratedVideosByTaskUseCase(video_repo) - videos = use_case.execute(completed_preview.id) - if videos: - rendered_storage_key = getattr(videos[0], "file_url", "") or "" + rendered_storage_key = _get_task_video_url(db, preview_tasks[0].id) or "" + if rendered_storage_key: logger.info( - "封面视频: 通过 user+template 找到预览任务: plan_id=%s template_id=%s task_id=%s", + "[封面生成] ✅ 步骤5找到预览视频: plan_id=%s task_id=%s", plan_id, - template_id, - completed_preview.id, + preview_tasks[0].id, ) except Exception: logger.warning( - "封面警告: user+template 查找预览任务失败: plan_id=%s template_id=%s", + "[封面生成] 步骤5 user+template 查找失败: plan_id=%s", plan_id, - template_id, exc_info=True, ) - # 使用裸 URL(rendered/* 已配置公开读);找不到渲染视频时不立即报错, - # 因为步骤 E 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底) + # 将 storage_key 转换为可访问 URL;找不到视频时不立即报错, + # 因为步骤 E2 可以直接从源素材抽帧(历史数据或 Worker 抽帧失败时的兜底) primary_video_url = None if rendered_storage_key: plan_svc.update_plan_config(plan_id, {"rendered_storage_key": rendered_storage_key}) - try: - if rendered_storage_key.startswith("http"): - primary_video_url = rendered_storage_key - else: - from packages.shared.storage import get_shared_storage_service - - storage_svc = get_shared_storage_service() - primary_video_url = storage_svc.get_url(rendered_storage_key) - if primary_video_url: - import re as _re - - primary_video_url = _re.sub(r"(?