fix(assets): smart-match 响应扁平化修复自动选素材 404/422 全链路断裂 #1565

Merged
xiaoxia merged 1 commits from fix/smart-match-flatten into develop 2026-08-31 13:35:06 +08:00
7 changed files with 178 additions and 55 deletions
+4 -4
View File
@@ -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,
)
@@ -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
@@ -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):
+6 -3
View File
@@ -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="各维度得分明细")
+48 -3
View File
@@ -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
@@ -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)
# 全为 undefinedPOST /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()
+7 -1
View File
@@ -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