fix(subscribe): protect runtime facts in public writes (#6157)

This commit is contained in:
InfinityPacer
2026-07-20 16:44:49 +08:00
committed by GitHub
parent 44fa202778
commit 5c2b503a74
4 changed files with 179 additions and 30 deletions
+6 -21
View File
@@ -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,21 +177,12 @@ 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 + (
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)
)
# 是否手动修改过总集数
+11 -1
View File
@@ -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
+1 -1
View File
@@ -80,7 +80,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普
## 维护边界
- 用户/API/Agent 的普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要。
- 公共创建与普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要;Agent 或维护工具需要修正运行事实时,应使用受控的内部工具或领域入口
- 主程序下载链路产生的电视剧下载必须同时维护 `note``episode_priority`
- backfill 入口只接收调用方确认的外部存在事实,不主动扫描媒体库。
- progress 刷新入口负责把当前事实转换为 `lack_episode`,并仅为分集洗版派生 `current_priority`;全集准入基线由模式转换、确认完整覆盖的整包下载、目标范围变化,以及插件回滚或显式完成入口维护。
+159 -5
View File
@@ -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):
"""