From 9ac54dde6e11ca5a01b62d9b5f562913fe8b1139 Mon Sep 17 00:00:00 2001 From: saas-backend-agent Date: Fri, 4 Sep 2026 00:54:38 +0800 Subject: [PATCH] =?UTF-8?q?feat(api):=20=E6=88=90=E5=93=81=E8=A7=86?= =?UTF-8?q?=E9=A2=91=E6=8E=A5=E5=8F=A3=E8=A1=A5=E5=85=A8=E6=9F=A5=E9=87=8D?= =?UTF-8?q?=E5=AD=97=E6=AE=B5=20duplicate=5Frate/visual=5Fsimilarity/match?= =?UTF-8?q?=5Fcount=20#1660?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GeneratedVideoResponse 新增三个可选字段(duplicate_rate/visual_similarity/match_count) - VideoItemResponse(/videos 列表) 补 visual_similarity/match_count,前端 #1662 已在消费 - generation_tasks/videos 路由透传三个字段 - domain GeneratedVideo 与 repository 读写映射改为 nullable 语义: 未计算的老数据保持 None(前端自动隐藏),计算后 0 是合法值不被吞掉 - 新增 7 个透传单测 --- apps/api/app/api/routes/generation_tasks.py | 12 +- apps/api/app/api/routes/videos.py | 2 + apps/api/app/schemas/generated_video.py | 4 + apps/api/app/schemas/video_center.py | 3 + .../generated_video_repository.py | 16 +-- packages/domain/generated_video.py | 4 +- tests/unit/test_video_response_dup_fields.py | 123 ++++++++++++++++++ 7 files changed, 150 insertions(+), 14 deletions(-) create mode 100644 tests/unit/test_video_response_dup_fields.py diff --git a/apps/api/app/api/routes/generation_tasks.py b/apps/api/app/api/routes/generation_tasks.py index d7651f918..d5fbad1f1 100755 --- a/apps/api/app/api/routes/generation_tasks.py +++ b/apps/api/app/api/routes/generation_tasks.py @@ -92,6 +92,9 @@ def _to_generated_video_response(item, download_url: str | None = None) -> Gener height=item.height, fps=item.fps, download_url=download_url, + duplicate_rate=getattr(item, "duplicate_rate", None), + visual_similarity=getattr(item, "visual_similarity", None), + match_count=getattr(item, "match_count", None), ) @@ -137,7 +140,6 @@ def _select_assets_from_library( return [a.id for a in ready_video_assets] - def _writeback_edit_plan_config( plan_id: str, task_id: str, @@ -162,7 +164,7 @@ def _writeback_edit_plan_config( current_config = plan_model.config if isinstance(plan_model.config, dict) else {} merged = dict(current_config) merged["generation_task_id"] = task_id - + # 检查标题是否发生变化,如果变化则清除 cover 字段强制重新生成封面 if title_config: old_title_config = merged.get("title_config", {}) or {} @@ -174,10 +176,12 @@ def _writeback_edit_plan_config( del merged["cover"] logger.info( "[生成任务] 标题变化,清除旧封面: plan_id=%s old_title=%s new_title=%s", - plan_id, old_title_text, new_title_text, + plan_id, + old_title_text, + new_title_text, ) merged["title_config"] = title_config - + plan_model.config = merged db.commit() logger.info( diff --git a/apps/api/app/api/routes/videos.py b/apps/api/app/api/routes/videos.py index cba5a2298..250b59e73 100644 --- a/apps/api/app/api/routes/videos.py +++ b/apps/api/app/api/routes/videos.py @@ -53,6 +53,8 @@ def _to_video_response(item, storage: OSSStorageService | None = None) -> VideoI download_url=download_url, generated_at=format_utc_datetime(item.generated_at) if hasattr(item, "generated_at") else "", duplicate_rate=getattr(item, "duplicate_rate", None), + visual_similarity=getattr(item, "visual_similarity", None), + match_count=getattr(item, "match_count", None), ) diff --git a/apps/api/app/schemas/generated_video.py b/apps/api/app/schemas/generated_video.py index 5f800bd87..185570c35 100644 --- a/apps/api/app/schemas/generated_video.py +++ b/apps/api/app/schemas/generated_video.py @@ -25,6 +25,10 @@ class GeneratedVideoResponse(BaseModel): review_status: str = "pending_review" generation_params: dict = Field(default_factory=dict) download_url: str | None = None + # #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数 + duplicate_rate: float | None = None + visual_similarity: float | None = None + match_count: int | None = None class GeneratedVideoDownloadUrlResponse(BaseModel): diff --git a/apps/api/app/schemas/video_center.py b/apps/api/app/schemas/video_center.py index d349cfd01..7b9b9432b 100755 --- a/apps/api/app/schemas/video_center.py +++ b/apps/api/app/schemas/video_center.py @@ -22,7 +22,10 @@ class VideoItemResponse(BaseModel): generation_params: dict = Field(default_factory=dict) download_url: str | None = None generated_at: str = "" + # #1660 查重率(百分比 0~100)/ 视觉相似度(0~1)/ 匹配帧数 duplicate_rate: float | None = None + visual_similarity: float | None = None + match_count: int | None = None class ListVideosResponse(BaseModel): diff --git a/packages/adapters/sqlalchemy_impl/generated_video_repository.py b/packages/adapters/sqlalchemy_impl/generated_video_repository.py index da7419ecc..c4db8eb33 100755 --- a/packages/adapters/sqlalchemy_impl/generated_video_repository.py +++ b/packages/adapters/sqlalchemy_impl/generated_video_repository.py @@ -31,8 +31,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=video.is_duplicate, duplicate_of=video.duplicate_of, duplicate_rate=video.duplicate_rate, - match_count=getattr(video, "match_count", 0), - visual_similarity=getattr(video, "visual_similarity", 0.0), + match_count=getattr(video, "match_count", None), + visual_similarity=getattr(video, "visual_similarity", None), generated_at=video.generated_at, created_at=video.created_at, ) @@ -64,8 +64,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), - match_count=getattr(model, "match_count", 0) or 0, - visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, + match_count=getattr(model, "match_count", None), + visual_similarity=getattr(model, "visual_similarity", None), generated_at=model.generated_at, created_at=model.created_at, ) @@ -81,8 +81,8 @@ class SQLAlchemyGeneratedVideoRepository: model.is_duplicate = video.is_duplicate model.duplicate_of = video.duplicate_of model.duplicate_rate = video.duplicate_rate - model.match_count = getattr(video, "match_count", 0) - model.visual_similarity = getattr(video, "visual_similarity", 0.0) + model.match_count = getattr(video, "match_count", None) + model.visual_similarity = getattr(video, "visual_similarity", None) self.session.add(model) self.session.commit() return video @@ -232,8 +232,8 @@ class SQLAlchemyGeneratedVideoRepository: is_duplicate=getattr(model, "is_duplicate", False), duplicate_of=getattr(model, "duplicate_of", None), duplicate_rate=getattr(model, "duplicate_rate", None), - match_count=getattr(model, "match_count", 0) or 0, - visual_similarity=getattr(model, "visual_similarity", 0.0) or 0.0, + match_count=getattr(model, "match_count", None), + visual_similarity=getattr(model, "visual_similarity", None), generated_at=model.generated_at, created_at=model.created_at, ) diff --git a/packages/domain/generated_video.py b/packages/domain/generated_video.py index 136a07546..57e6ddfbc 100755 --- a/packages/domain/generated_video.py +++ b/packages/domain/generated_video.py @@ -27,8 +27,8 @@ class GeneratedVideo: is_duplicate: bool = False duplicate_of: str | None = None duplicate_rate: float | None = None - match_count: int = 0 - visual_similarity: float = 0.0 + match_count: int | None = None + visual_similarity: float | None = None generated_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) created_at: datetime = field(default_factory=lambda: datetime.now(timezone.utc)) diff --git a/tests/unit/test_video_response_dup_fields.py b/tests/unit/test_video_response_dup_fields.py new file mode 100644 index 000000000..c28ea2c89 --- /dev/null +++ b/tests/unit/test_video_response_dup_fields.py @@ -0,0 +1,123 @@ +"""#1660 成品视频 API 查重字段透传测试。 + +覆盖两套响应构造路径: +- routes/videos.py::_to_video_response -> VideoItemResponse (/videos 列表) +- routes/generation_tasks.py::_to_generated_video_response -> GeneratedVideoResponse +""" + +from types import SimpleNamespace +from unittest.mock import MagicMock + +from app.api.routes.generation_tasks import _to_generated_video_response +from app.api.routes.videos import _to_video_response +from app.schemas.generated_video import GeneratedVideoResponse +from app.schemas.video_center import VideoItemResponse + + +def _make_item(**overrides): + base = dict( + id="v1", + project_id="p1", + generation_task_id="t1", + name="成片", + file_url="oss://bucket/v1.mp4", + file_size=1024, + duration=12.5, + thumbnail_url=None, + width=1080, + height=1920, + fps=30.0, + status="completed", + review_status="pending_review", + generation_params={}, + generated_at=None, + duplicate_rate=None, + match_count=None, + visual_similarity=None, + ) + base.update(overrides) + return SimpleNamespace(**base) + + +class TestVideoItemResponseDupFields: + def test_passes_through_all_three_fields(self): + item = _make_item(duplicate_rate=42.5, match_count=7, visual_similarity=0.83) + resp = _to_video_response(item, storage=None) + assert isinstance(resp, VideoItemResponse) + assert resp.duplicate_rate == 42.5 + assert resp.match_count == 7 + assert resp.visual_similarity == 0.83 + + def test_legacy_video_without_fields_returns_none(self): + """老数据/实体无查重字段时保持 None(前端自动隐藏),不报错。""" + item = SimpleNamespace( + id="v2", + project_id="p1", + generation_task_id="t2", + name="老视频", + file_url="oss://bucket/v2.mp4", + file_size=1, + duration=1.0, + thumbnail_url=None, + width=720, + height=1280, + fps=24.0, + status="completed", + review_status="pending_review", + generation_params={}, + ) + resp = _to_video_response(item, storage=None) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_explicit_none_values_kept(self): + item = _make_item() + resp = _to_video_response(item, storage=None) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_zero_match_count_is_valid_value(self): + """计算后确无匹配:match_count=0 / visual_similarity=0.0 是合法值,不能变 None。""" + item = _make_item(duplicate_rate=0.0, match_count=0, visual_similarity=0.0) + resp = _to_video_response(item, storage=None) + assert resp.match_count == 0 + assert resp.visual_similarity == 0.0 + + +class TestGeneratedVideoResponseDupFields: + def test_passes_through_all_three_fields(self): + item = _make_item(duplicate_rate=15.2, match_count=3, visual_similarity=0.61) + resp = _to_generated_video_response(item, download_url="https://dl/x") + assert isinstance(resp, GeneratedVideoResponse) + assert resp.duplicate_rate == 15.2 + assert resp.match_count == 3 + assert resp.visual_similarity == 0.61 + assert resp.download_url == "https://dl/x" + + def test_missing_fields_default_none(self): + item = SimpleNamespace( + id="v3", + project_id="p1", + generation_task_id="t3", + name="x", + file_url="oss://x", + file_size=1, + duration=1.0, + thumbnail_url=None, + width=720, + height=1280, + fps=24.0, + ) + resp = _to_generated_video_response(item) + assert resp.duplicate_rate is None + assert resp.match_count is None + assert resp.visual_similarity is None + + def test_storage_failure_falls_back_to_file_url(self): + storage = MagicMock() + storage.get_download_url.side_effect = RuntimeError("oss down") + item = _make_item() + resp = _to_video_response(item, storage=storage) + assert resp.download_url == item.file_url -- 2.54.0