From 5c2b503a74eb85a3055b09e1e8d2c15deb08e0a1 Mon Sep 17 00:00:00 2001 From: InfinityPacer <160988576+InfinityPacer@users.noreply.github.com> Date: Mon, 20 Jul 2026 16:44:49 +0800 Subject: [PATCH] fix(subscribe): protect runtime facts in public writes (#6157) --- app/api/endpoints/subscribe.py | 31 ++---- app/schemas/subscribe.py | 12 ++- docs/subscribe-lifecycle.md | 2 +- tests/test_subscribe_endpoint.py | 164 ++++++++++++++++++++++++++++++- 4 files changed, 179 insertions(+), 30 deletions(-) diff --git a/app/api/endpoints/subscribe.py b/app/api/endpoints/subscribe.py index 5b4d60655..1c27dc315 100644 --- a/app/api/endpoints/subscribe.py +++ b/app/api/endpoints/subscribe.py @@ -152,14 +152,8 @@ async def create_subscribe( title = subscribe_in.name else: title = None - # 订阅用户 - subscribe_in.username = current_user.name - # 转化为字典 - subscribe_dict = subscribe_in.model_dump() - if subscribe_in.id: - subscribe_dict.pop("id", None) - # completed_episode 是响应派生字段,禁止写入持久层 - subscribe_dict.pop("completed_episode", None) + subscribe_dict = subscribe_in.to_public_write_payload() + subscribe_dict["username"] = current_user.name sid, message = await SubscribeChain().async_add( mtype=mtype, title=title, @@ -183,23 +177,14 @@ async def update_subscribe( subscribe = await get_accessible_subscribe(db, subscribe_in.id, current_user) if not subscribe: return schemas.Response(success=False, message="订阅不存在") - # 避免更新缺失集数 old_subscribe_dict = subscribe.to_dict() - subscribe_dict = subscribe_in.model_dump() + subscribe_dict = subscribe_in.to_public_write_payload() subscribe_dict["username"] = subscribe.username - if subscribe_in.episode_priority is None: - subscribe_dict.pop("episode_priority", None) - # completed_episode 是响应派生字段,禁止写入持久层 - subscribe_dict.pop("completed_episode", None) - if not subscribe_in.lack_episode: - # 没有缺失集数时,缺失集数清空,避免更新为0 - subscribe_dict.pop("lack_episode") - elif subscribe_in.total_episode: - # 总集数增加时,缺失集数也要增加 - if subscribe_in.total_episode > (subscribe.total_episode or 0): - subscribe_dict["lack_episode"] = subscribe.lack_episode + ( - subscribe_in.total_episode - (subscribe.total_episode or 0) - ) + if subscribe_in.total_episode and subscribe_in.total_episode > (subscribe.total_episode or 0): + # 扩大目标范围时,新增加的集数尚无下载事实,应同步计入缺失集数。 + subscribe_dict["lack_episode"] = (subscribe.lack_episode or 0) + ( + subscribe_in.total_episode - (subscribe.total_episode or 0) + ) # 是否手动修改过总集数 if subscribe_in.total_episode != subscribe.total_episode: subscribe_dict["manual_total_episode"] = 1 diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index 8fdc6ab8b..9cfc6fd26 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -1,4 +1,4 @@ -from typing import Optional, List, Dict, Any +from typing import Optional, List, Dict, Any, ClassVar from pydantic import BaseModel, Field, ConfigDict, model_validator @@ -44,6 +44,12 @@ def compute_subscribe_completed_episode(subscribe: "Subscribe") -> Optional[int] class Subscribe(BaseModel): + # 公共创建和更新接口不得接收系统字段和运行事实;其余字段默认作为订阅输入透传。 + PUBLIC_WRITE_EXCLUDED_FIELDS: ClassVar[frozenset[str]] = frozenset({ + "id", "poster", "backdrop", "vote", "description", "lack_episode", "completed_episode", + "note", "state", "last_update", "username", "current_priority", "episode_priority", "date", + }) + id: Optional[int] = None # 订阅名称 name: Optional[str] = None @@ -136,6 +142,10 @@ class Subscribe(BaseModel): self.completed_episode = compute_subscribe_completed_episode(self) return self + def to_public_write_payload(self) -> Dict[str, Any]: + """裁剪公共订阅写入字段,避免请求体覆盖下载事实和运行状态。""" + return self.model_dump(exclude=self.PUBLIC_WRITE_EXCLUDED_FIELDS) + class SubscribeShare(BaseModel): # 分享ID diff --git a/docs/subscribe-lifecycle.md b/docs/subscribe-lifecycle.md index ec69c9019..96f158c80 100644 --- a/docs/subscribe-lifecycle.md +++ b/docs/subscribe-lifecycle.md @@ -80,7 +80,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普 ## 维护边界 -- 用户/API/Agent 的普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要。 +- 公共创建与普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要;Agent 或维护工具需要修正运行事实时,应使用受控的内部工具或领域入口。 - 主程序下载链路产生的电视剧下载必须同时维护 `note` 和 `episode_priority`。 - backfill 入口只接收调用方确认的外部存在事实,不主动扫描媒体库。 - progress 刷新入口负责把当前事实转换为 `lack_episode`,并仅为分集洗版派生 `current_priority`;全集准入基线由模式转换、确认完整覆盖的整包下载、目标范围变化,以及插件回滚或显式完成入口维护。 diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 97f25bddf..13b9750e4 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -593,15 +593,20 @@ class SubscribeEndpointTest(TestCase): self.assertTrue(response.success) scheduler.return_value.start.assert_called_once_with(job_id) - def test_create_subscribe_excludes_completed_episode_from_write_payload(self): + def test_create_subscribe_excludes_system_fields_from_write_payload(self): """ - 新增订阅时不应把 completed_episode 派生字段传入持久化链路。 + 新增订阅时不应把历史 ID、媒体元数据和响应派生字段传入持久化链路。 """ subscribe_in = Subscribe( + id=99, name="测试剧集", year="2026", type=MediaType.TV.value, season=1, + poster="old-poster.jpg", + backdrop="old-backdrop.jpg", + vote=8.0, + description="旧历史简介", total_episode=10, lack_episode=3, ) @@ -620,9 +625,57 @@ class SubscribeEndpointTest(TestCase): ) self.assertTrue(response.success) - self.assertNotIn("completed_episode", async_add.await_args.kwargs) - self.assertEqual(async_add.await_args.kwargs["username"], "moviepilot-user") - self.assertTrue(async_add.await_args.kwargs["owner_scope"]) + payload = async_add.await_args.kwargs + for field in ("id", "poster", "backdrop", "vote", "description", "completed_episode"): + self.assertNotIn(field, payload) + self.assertEqual(payload["username"], "moviepilot-user") + self.assertTrue(payload["owner_scope"]) + + def test_create_subscribe_ignores_runtime_fact_fields(self): + """ + 公共新增接口只能写目标和配置,调用方携带的运行事实不得进入新增链路。 + """ + subscribe_in = Subscribe( + name="测试剧集", + year="2026", + type=MediaType.TV.value, + season=1, + total_episode=10, + lack_episode=3, + note=[1, 2, 3], + state="S", + last_update="2026-07-20 12:00:00", + username="forged-user", + current_priority=90, + episode_priority={"1": 90}, + date="2026-07-19 12:00:00", + ) + + with patch( + "app.api.endpoints.subscribe.SubscribeChain.async_add", + new=AsyncMock(return_value=(1, "新增订阅成功")), + ) as async_add: + response = asyncio.run( + create_subscribe( + subscribe_in=subscribe_in, + current_user=_EndpointUser(name="moviepilot-user", is_superuser=False), + ) + ) + + self.assertTrue(response.success) + payload = async_add.await_args.kwargs + self.assertEqual(payload["username"], "moviepilot-user") + for field in ( + "lack_episode", + "note", + "state", + "last_update", + "current_priority", + "episode_priority", + "date", + "completed_episode", + ): + self.assertNotIn(field, payload) def test_create_subscribe_preserves_special_season_zero_with_doubanid(self): """ @@ -812,6 +865,107 @@ class SubscribeEndpointTest(TestCase): self.assertEqual(payload["old_subscribe_info"]["name"], "旧标题") self.assertEqual(payload["subscribe_info"]["name"], "新标题") + def test_update_subscribe_ignores_runtime_fact_fields(self): + """ + 公共普通更新不得覆盖运行事实,状态调整继续由专用接口负责。 + """ + from app.api.endpoints.subscribe import update_subscribe + + subscribe = _EndpointSubscribe( + id=8, + username="alice", + name="旧标题", + total_episode=10, + lack_episode=5, + state="R", + note=[1, 2, 3, 4, 5], + current_priority=60, + episode_priority={"1": 60}, + last_update="2026-07-19 12:00:00", + date="2026-07-18 12:00:00", + sites=[], + search_imdbid=0, + filter_groups=[], + start_episode=0, + ) + subscribe_in = Subscribe( + id=8, + name="新标题", + total_episode=10, + lack_episode=0, + state="S", + note=[], + current_priority=100, + episode_priority={"1": 100}, + last_update="2026-07-20 12:00:00", + date="2026-07-20 12:00:00", + ) + + with patch( + "app.api.endpoints.subscribe.Subscribe.async_get", + new=AsyncMock(side_effect=[subscribe, subscribe]), + ), patch( + "app.api.endpoints.subscribe.eventmanager.async_send_event", + new=AsyncMock(), + ): + response = asyncio.run( + update_subscribe( + subscribe_in=subscribe_in, + db=object(), + current_user=_EndpointUser(name="admin", is_superuser=True), + ) + ) + + self.assertTrue(response.success) + self.assertEqual(subscribe.name, "新标题") + self.assertEqual(subscribe.lack_episode, 5) + self.assertEqual(subscribe.state, "R") + self.assertEqual(subscribe.note, [1, 2, 3, 4, 5]) + self.assertEqual(subscribe.current_priority, 60) + self.assertEqual(subscribe.episode_priority, {"1": 60}) + self.assertEqual(subscribe.last_update, "2026-07-19 12:00:00") + self.assertEqual(subscribe.date, "2026-07-18 12:00:00") + + def test_update_subscribe_derives_lack_when_total_episode_increases(self): + """ + 公共更新扩大目标范围时,缺失集数与人工总集数标记仍由服务端派生。 + """ + from app.api.endpoints.subscribe import update_subscribe + + subscribe = _EndpointSubscribe( + id=9, + username="alice", + name="测试剧集", + total_episode=10, + lack_episode=2, + manual_total_episode=0, + sites=[], + search_imdbid=0, + filter_groups=[], + start_episode=0, + ) + subscribe_in = Subscribe(id=9, name="测试剧集", total_episode=12, lack_episode=0) + + with patch( + "app.api.endpoints.subscribe.Subscribe.async_get", + new=AsyncMock(side_effect=[subscribe, subscribe]), + ), patch( + "app.api.endpoints.subscribe.eventmanager.async_send_event", + new=AsyncMock(), + ): + response = asyncio.run( + update_subscribe( + subscribe_in=subscribe_in, + db=object(), + current_user=_EndpointUser(name="admin", is_superuser=True), + ) + ) + + self.assertTrue(response.success) + self.assertEqual(subscribe.total_episode, 12) + self.assertEqual(subscribe.lack_episode, 4) + self.assertEqual(subscribe.manual_total_episode, 1) + class _EndpointUser(SimpleNamespace): """