fix(assets): smart-match 响应扁平化修复自动选素材 404/422 全链路断裂 #1565
@@ -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):
|
||||
|
||||
@@ -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="各维度得分明细")
|
||||
|
||||
|
||||
@@ -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)
|
||||
# 全为 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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user