mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-08 00:46:57 +08:00
fix(subscribe): protect runtime facts in public writes (#6157)
This commit is contained in:
@@ -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)
|
||||
)
|
||||
# 是否手动修改过总集数
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -80,7 +80,7 @@ MoviePilot 当前以媒体类型和洗版方式组合出常用订阅模式。普
|
||||
|
||||
## 维护边界
|
||||
|
||||
- 用户/API/Agent 的普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要。
|
||||
- 公共创建与普通订阅编辑只负责目标和配置变更,不直接维护下载事实和进度摘要;Agent 或维护工具需要修正运行事实时,应使用受控的内部工具或领域入口。
|
||||
- 主程序下载链路产生的电视剧下载必须同时维护 `note` 和 `episode_priority`。
|
||||
- backfill 入口只接收调用方确认的外部存在事实,不主动扫描媒体库。
|
||||
- progress 刷新入口负责把当前事实转换为 `lack_episode`,并仅为分集洗版派生 `current_priority`;全集准入基线由模式转换、确认完整覆盖的整包下载、目标范围变化,以及插件回滚或显式完成入口维护。
|
||||
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
Reference in New Issue
Block a user