diff --git a/apps/api/app/api/routes/assets.py b/apps/api/app/api/routes/assets.py index 3a407e81b..80a199aff 100755 --- a/apps/api/app/api/routes/assets.py +++ b/apps/api/app/api/routes/assets.py @@ -576,9 +576,7 @@ def smart_match_assets( request.library_id, request.kind, status=["ready"], limit=10000 ) else: - filtered_assets = asset_repository.find_by_library( - request.library_id, status=["ready"], limit=10000 - ) + filtered_assets = asset_repository.find_by_library(request.library_id, status=["ready"], limit=10000) total_candidates = len(filtered_assets) # 调用统一智能选素材算法(kind 已在 DB 层过滤,无需重复过滤) @@ -610,9 +608,11 @@ def smart_match_assets( continue filtered_results.append(r) + # 扁平结构:SmartMatchItem 继承 AssetResponse,素材字段直接在条目顶层, + # 前端无需解析 item.asset 包装层,item.id / item.usable / 余量字段直接可读 items = [ SmartMatchItem( - asset=_to_asset_response(r.asset), + **_to_asset_response(r.asset).model_dump(), score=r.score, breakdown=r.breakdown, ) diff --git a/apps/api/app/api/routes/templates_editor/clips.py b/apps/api/app/api/routes/templates_editor/clips.py index 8f25ddcb0..08d4bc307 100755 --- a/apps/api/app/api/routes/templates_editor/clips.py +++ b/apps/api/app/api/routes/templates_editor/clips.py @@ -367,7 +367,6 @@ def batch_delete_editor_clips( return ClipBatchDeleteResponse(deleted_count=deleted, plan_id=plan_id) - def _safe_segment_duration(value, default: float) -> float: """安全地将数据库中的时长值转换为正浮点数. @@ -403,9 +402,7 @@ def _get_template_segments( if clip_configs: result = [] for cc in clip_configs: - dur_min = _safe_segment_duration( - cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION - ) + dur_min = _safe_segment_duration(cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION) dur_max = _safe_segment_duration( cc.max_duration or cc.min_duration, _DEFAULT_EDITOR_CLIP_DURATION, @@ -492,7 +489,7 @@ def _get_mediakit_recommendations( prompt = ( "请分析每段视频,找出最精彩的5秒片段应该从哪个时间点开始。" "考虑因素:画面清晰度、主体是否明确、是否有明显的动作或场景变化。" - '请严格以JSON数组格式返回,不要包含其他文字:' + "请严格以JSON数组格式返回,不要包含其他文字:" '[{"asset_id": "素材ID", "recommended_start_time": 12.5, "reason": "原因"}]' ) @@ -545,9 +542,7 @@ def _get_mediakit_recommendations( # 尝试正则提取 if not parsed: - time_match = re.search( - r'recommended_start_time["\s:]+([\d.]+)', content_text - ) + time_match = re.search(r'recommended_start_time["\s:]+([\d.]+)', content_text) if time_match: try: recommendations[asset_id] = float(time_match.group(1)) @@ -598,14 +593,17 @@ def create_clips_from_assets_editor( detail="模板没有片段配置,无法创建片段", ) - if not body.asset_ids: + # 防御:schema validator 已过滤 null/空串,这里再归一化一次, + # 避免异常入参(undefined → null)导致后续 /assets/{id} 404 / 422 + asset_ids = [str(aid).strip() for aid in (body.asset_ids or []) if isinstance(aid, str) and aid.strip()] + if not asset_ids: raise HTTPException( status_code=status.HTTP_400_BAD_REQUEST, detail="素材列表为空,无法创建片段", ) # 2. 获取素材实际时长(去重查询) - unique_asset_ids = list(dict.fromkeys(body.asset_ids)) + unique_asset_ids = list(dict.fromkeys(asset_ids)) asset_durations: dict[str, float] = {} for asset_id in unique_asset_ids: asset = asset_repo.get(asset_id) @@ -615,9 +613,7 @@ def create_clips_from_assets_editor( # 3. 在内存中计算所有片段数据(使用随机起始时间,不调用MediaKit) # 读取素材 metadata 中持久化的历史已用区间(跨任务/跨调用去重), # 格式与 _calc_random_start_time 的 used_segments 参数一致 - used_segments: dict[str, list[tuple[float, float]]] = get_used_segments( - db, unique_asset_ids - ) + used_segments: dict[str, list[tuple[float, float]]] = get_used_segments(db, unique_asset_ids) # 受控复用回调:可用区间耗尽时复用最久未用且未达复用上限(3次)的历史区间, # 复用片段时长累加到 reused_durations 供 15% 占比控制 reused_durations: dict[str, float] = {} @@ -655,9 +651,9 @@ def create_clips_from_assets_editor( asset_id = "" clip_duration = 0.0 start_time: float | None = None - n_assets = len(body.asset_ids) + n_assets = len(asset_ids) for offset in range(n_assets): - candidate = body.asset_ids[(i + offset) % n_assets] + candidate = asset_ids[(i + offset) % n_assets] candidate_total = asset_durations.get(candidate, 0.0) if candidate_total <= 0: continue @@ -701,18 +697,12 @@ def create_clips_from_assets_editor( ) # 记录已使用时间段(内存,供本次后续片段避开) - used_segments.setdefault(asset_id, []).append( - (start_time, start_time + clip_duration) - ) - asset_assigned_durations[asset_id] = ( - asset_assigned_durations.get(asset_id, 0.0) + clip_duration - ) + used_segments.setdefault(asset_id, []).append((start_time, start_time + clip_duration)) + asset_assigned_durations[asset_id] = asset_assigned_durations.get(asset_id, 0.0) + clip_duration # 同步写入素材 metadata(不 commit,与下方 replace_all_clips_transactional # 处于同一事务,任一步失败整体回滚,不留脏数据); # 复用区间与历史记录高度重叠时 record 内部自动累加 use_count - record_used_segments( - db, asset_id, start_time, start_time + clip_duration, plan_id - ) + record_used_segments(db, asset_id, start_time, start_time + clip_duration, plan_id) clips_data.append( { @@ -803,18 +793,14 @@ def _update_mediakit_recommendations_async( # pragma: no cover # 批量预加载所有涉及的素材(消除 N+1 查询) unique_asset_ids = list({getattr(c, "asset_id", "") or "" for c in clips} - {""}) - assets_map: dict[str, object] = { - a.id: a for a in asset_repo.find_by_ids(unique_asset_ids) - } + assets_map: dict[str, object] = {a.id: a for a in asset_repo.find_by_ids(unique_asset_ids)} # 按 asset_id 预分组片段时间段(消除 O(N^2) 嵌套循环) clips_by_asset: dict[str, list[tuple[str, float, float]]] = defaultdict(list) for clip in clips: aid = getattr(clip, "asset_id", "") or "" if aid and clip.start_time is not None: - clips_by_asset[aid].append( - (clip.id, clip.start_time, clip.start_time + clip.duration) - ) + clips_by_asset[aid].append((clip.id, clip.start_time, clip.start_time + clip.duration)) # 读取素材全部历史已用区间(跨任务/跨 plan 持久化记录): # MediaKit 挪点必须与随机选片一样避让历史区间,否则会把片段挪回已用过的画面 @@ -861,6 +847,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover if cid != clip.id and cid not in updated_clip_ids ] other_segments.extend(updated_segments.get(asset_id, [])) + # 并入该素材全部历史已用区间(含其他 plan/其他任务),set 去重: # 本 plan 片段创建时已写入历史记录 # 并入该素材全部历史已用区间(含其他 plan/其他任务)。 @@ -869,9 +856,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover def _norm(segs): return {(round(float(a), 3), round(float(b), 3)) for a, b in segs} - other_segments = list( - _norm(other_segments) | _norm(historical_segments.get(asset_id, [])) - ) + other_segments = list(_norm(other_segments) | _norm(historical_segments.get(asset_id, []))) # 检查推荐时间是否与同 plan 片段或历史已用区间冲突(含 0.3s 边缘间隙): # 冲突时放弃该推荐、保留原随机起点(不硬挪到已用过的画面) @@ -893,9 +878,7 @@ def _update_mediakit_recommendations_async( # pragma: no cover # 保证 clip.start_time 与 metadata.used_time_ranges 不出现不一致。 plan_svc.update_clip(clip.id, start_time=recommended_start) try: - if remove_used_segment( - db, asset_id, old_start, old_end, plan_id=plan_id - ): + if remove_used_segment(db, asset_id, old_start, old_end, plan_id=plan_id): record_used_segments( db, asset_id, @@ -915,18 +898,14 @@ def _update_mediakit_recommendations_async( # pragma: no cover updated_count += 1 updated_clip_ids.add(clip.id) except Exception as ue: - logger.warning( - "后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue - ) + logger.warning("后台任务: 单个片段更新失败: clip_id=%s error=%s", clip.id, ue) try: db.rollback() except Exception: pass continue - updated_segments.setdefault(asset_id, []).append( - (recommended_start, recommended_start + clip_duration) - ) + updated_segments.setdefault(asset_id, []).append((recommended_start, recommended_start + clip_duration)) logger.info( "后台任务: 更新片段起始时间: clip_id=%s asset_id=%s start_time=%.2f", clip.id, @@ -950,4 +929,3 @@ def _update_mediakit_recommendations_async( # pragma: no cover db.close() except Exception: pass - diff --git a/apps/api/app/api/routes/templates_editor/schemas.py b/apps/api/app/api/routes/templates_editor/schemas.py index 2403d22b5..8d4f036b9 100755 --- a/apps/api/app/api/routes/templates_editor/schemas.py +++ b/apps/api/app/api/routes/templates_editor/schemas.py @@ -167,7 +167,18 @@ class ClipsFromAssetsRequest(BaseModel): asset_ids: List[str] = Field(..., min_length=1, max_length=200, description="素材 ID 列表,按顺序追加到时间线末尾") clip_type: str = Field(default="main", description="片段类型,默认 main") - required_clips_count: Optional[int] = Field(default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量") + required_clips_count: Optional[int] = Field( + default=None, ge=1, le=200, description="要求创建的片段数量;不传则等于素材数量" + ) + + @validator("asset_ids", pre=True) + def _drop_invalid_asset_ids(cls, v): # noqa: N805 + """容错过滤:前端异常情况下可能把 undefined 序列化成 null 或空串混入 + asset_ids(会直接 422 或导致后续 /assets/{id} 404),这里统一剔除。 + 过滤后为空时由 Field(min_length=1) / 路由层 400 兜底。""" + if not isinstance(v, list): + return v + return [x for x in v if isinstance(x, str) and x.strip()] class ClipsFromAssetsResponse(BaseModel): diff --git a/apps/api/app/schemas/asset.py b/apps/api/app/schemas/asset.py index 1f9d2927c..39480135a 100755 --- a/apps/api/app/schemas/asset.py +++ b/apps/api/app/schemas/asset.py @@ -129,10 +129,13 @@ class SmartMatchRequest(BaseModel): ) -class SmartMatchItem(BaseModel): - """智能选素材结果条目。""" +class SmartMatchItem(AssetResponse): + """智能选素材结果条目(扁平结构)。 + + 素材字段(id/usable/余量等)直接挂在条目顶层,前端拿到 item 即可读 item.id, + 与 AssetResponse 字段完全一致;score/breakdown 为智能匹配附加的评分字段。 + """ - asset: AssetResponse score: float = Field(..., ge=0, le=100, description="综合得分 0-100") breakdown: dict[str, float] = Field(default_factory=dict, description="各维度得分明细") diff --git a/tests/unit/test_asset_availability.py b/tests/unit/test_asset_availability.py index e12d6da18..8e9cda6e2 100755 --- a/tests/unit/test_asset_availability.py +++ b/tests/unit/test_asset_availability.py @@ -386,14 +386,14 @@ class TestSmartMatchFiltersExhausted: _fresh_asset("a-fresh-1"), ] resp = self._call(assets) - returned_ids = {item.asset.id for item in resp.items} + returned_ids = {item.id for item in resp.items} assert "a-fresh-1" in returned_ids assert "a-exhausted-1" not in returned_ids assert "a-exhausted-2" not in returned_ids # total_candidates 是过滤前的候选总数 assert resp.total_candidates == 3 # 返回的素材全部 usable=True - assert all(item.asset.usable for item in resp.items) + assert all(item.usable for item in resp.items) def test_all_exhausted_returns_empty(self): """全部素材已用尽时返回空列表(不报错,前端显示空结果)。""" @@ -406,4 +406,49 @@ class TestSmartMatchFiltersExhausted: assets = [_fresh_asset("a-1"), _fresh_asset("a-2")] resp = self._call(assets) assert len(resp.items) == 2 - assert all(item.asset.usable for item in resp.items) + assert all(item.usable for item in resp.items) + + +class TestSmartMatchFlatStructure: + """P0 回归:smart-match 响应必须扁平——item 顶层直接可读素材字段, + 前端 items.map(a => a.id) 不能再拿到 undefined(此前 item.asset 嵌套包装 + 导致 GET /assets/undefined 404 + from-assets 422,自动模式全链路断裂)。""" + + def test_item_id_at_top_level(self): + """item.id 直接在顶层可读,不存在 item.asset 包装层。""" + assets = [_fresh_asset("a-flat-1"), _fresh_asset("a-flat-2")] + resp = TestSmartMatchFiltersExhausted()._call(assets) + ids = [item.id for item in resp.items] + assert ids == ["a-flat-1", "a-flat-2"] + # 嵌套 asset 字段已移除 + assert all(not hasattr(item, "asset") for item in resp.items) + + def test_item_is_asset_response_superset(self): + """条目携带 AssetResponse 全部关键字段 + usable/余量,前端可直接渲染卡片。""" + assets = [_fresh_asset("a-fields-1")] + resp = TestSmartMatchFiltersExhausted()._call(assets) + item = resp.items[0] + assert item.id == "a-fields-1" + assert item.name == "fresh-a-fields-1" + assert item.storage_key == "key/test-asset.mp4" + assert item.mime_type == "video/mp4" + assert item.duration == 60.0 + assert item.status == "ready" + assert item.thumbnail_url is None or item.thumbnail_url.startswith("http") + # 余量/可用性字段顶层可读(isAssetUsable 依赖) + assert item.usable is True + assert item.used_duration == 0.0 + assert item.available_duration == 60.0 + assert item.used_ratio == 0.0 + # 评分字段保留 + assert 0.0 <= item.score <= 100.0 + assert isinstance(item.breakdown, dict) + + def test_score_and_breakdown_preserved(self): + """扁平化后评分字段不丢失。""" + assets = [_fresh_asset("a-score-1")] + resp = TestSmartMatchFiltersExhausted()._call(assets) + item = resp.items[0] + assert isinstance(item.score, float) + assert item.score > 0 + assert isinstance(item.breakdown, dict) and item.breakdown diff --git a/tests/unit/test_editor_clips_random_start.py b/tests/unit/test_editor_clips_random_start.py index 6501a6fa7..bba35e223 100644 --- a/tests/unit/test_editor_clips_random_start.py +++ b/tests/unit/test_editor_clips_random_start.py @@ -747,3 +747,83 @@ class TestReuseRatioGate: assert len(clips_data) == 2 # 耗尽素材被跳过,两个片段都分配给新鲜素材 assert all(c["asset_id"] == "fresh" for c in clips_data) + + +# ── P0 回归:from-assets 对 undefined/null/空串 asset_ids 容错 ────────────── +# 线上事故:前端 smart-match 拿到 {asset: {...}} 包装层后 items.map(a=>a.id) +# 全为 undefined,POST /clips/from-assets 携带 null → 422,自动模式预览断裂。 + + +class TestClipsFromAssetsInvalidIds: + def test_schema_filters_null_and_empty_ids(self): + """请求 schema 在 pre 阶段剔除 null/空串/空白 id,不触发 422。""" + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", " ", "a2"]) # type: ignore[list-item] + assert body.asset_ids == ["a1", "a2"] + + def test_schema_all_invalid_raises(self): + """全部为非法 id 时 min_length=1 兜底报校验错误(前端得到 422 而非脏数据)。""" + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + from pydantic import ValidationError + + with pytest.raises(ValidationError): + ClipsFromAssetsRequest(asset_ids=[None, "", " "]) # type: ignore[list-item] + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_route_filters_invalid_ids_and_uses_valid(self, mock_storage): + """路由层二次兜底:混有 null/空串时只用合法 id 正常创建片段。""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + from app.api.routes.templates_editor.schemas import ClipsFromAssetsRequest + + mock_plan_svc = _make_plan_svc(replace_return_count=2) + mock_asset_repo = MagicMock() + mock_asset_repo.get = MagicMock(side_effect=lambda aid: _make_mock_asset(aid, 30.0)) + + body = ClipsFromAssetsRequest(asset_ids=["a1", None, "", "a2"]) # type: ignore[list-item] + + with _patch_segments(_segments(2, dur_min=3.0, dur_max=5.0)): + result = create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + background_tasks=MagicMock(), + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + db=MagicMock(), + current_user=_make_auth_user(), + ) + + assert result.created_count == 2 + clips_data = _get_clips_data_from_call(mock_plan_svc) + assert {c["asset_id"] for c in clips_data} == {"a1", "a2"} + + @patch("app.api.routes.templates_editor.clips.get_storage_service") + def test_route_all_empty_ids_raises_400(self, mock_storage): + """schema 被绕过直接调路由、且 id 全非法时,路由 400 而非 500/422。""" + from app.api.routes.templates_editor.clips import ( + create_clips_from_assets_editor, + ) + + mock_plan_svc = _make_plan_svc() + mock_asset_repo = MagicMock() + + body = MagicMock() + body.asset_ids = [None, "", " "] + + with _patch_segments(DEFAULT_SEGMENTS): + with pytest.raises(HTTPException) as exc_info: + create_clips_from_assets_editor( + template_id="tpl-001", + body=body, + background_tasks=MagicMock(), + plan_id=TEST_PLAN_ID, + services=(MagicMock(), mock_plan_svc), + asset_repo=mock_asset_repo, + db=MagicMock(), + current_user=_make_auth_user(), + ) + assert exc_info.value.status_code == 400 + mock_plan_svc.replace_all_clips_transactional.assert_not_called() diff --git a/tests/unit/test_smart_match.py b/tests/unit/test_smart_match.py index 3a7b12266..0a31b4c34 100755 --- a/tests/unit/test_smart_match.py +++ b/tests/unit/test_smart_match.py @@ -425,6 +425,12 @@ class TestSmartMatchEndpoint: for item in data["items"]: assert "quality" in item["breakdown"] assert "duration" in item["breakdown"] + # P0 回归:扁平结构——素材字段在 item 顶层,无 asset 包装层 + for item in data["items"]: + assert item["id"] + assert "asset" not in item + assert item["mime_type"].startswith("video/") + assert "usable" in item def test_limit_parameter(self): project, library, assets = _make_test_data() @@ -463,7 +469,7 @@ class TestSmartMatchEndpoint: assert resp.status_code == 200 data = resp.json() assert len(data["items"]) == 1 - assert data["items"][0]["asset"]["mime_type"] == "image/png" + assert data["items"][0]["mime_type"] == "image/png" # total_candidates should only count filtered-by-kind assets (1 image, not 3 videos) assert data["total_candidates"] == 1