mirror of
https://github.com/jxxghp/MoviePilot.git
synced 2026-09-05 23:47:41 +08:00
fix(subscribe): 修复历史 note 双重 JSON 编码导致的订阅列表 500
2.0 时代旧代码对 JSON 列显式 json.dumps,导致 note 字段在库里是 字符串而非数组,响应模型按 List[int] 校验失败返回 500。 - 新增 3.0.7 数据迁移,解析并回写字符串型 note 为真正的 JSON 数组 - Subscribe 响应模型增加 note 字符串兼容解析,脏数据按空值处理
This commit is contained in:
@@ -1,6 +1,7 @@
|
|||||||
|
import json
|
||||||
from typing import Optional, List, Dict, Any, ClassVar
|
from typing import Optional, List, Dict, Any, ClassVar
|
||||||
|
|
||||||
from pydantic import BaseModel, Field, ConfigDict, model_validator
|
from pydantic import BaseModel, Field, ConfigDict, model_validator, field_validator
|
||||||
|
|
||||||
from app.schemas.media import OptionalMediaIdentityMixin
|
from app.schemas.media import OptionalMediaIdentityMixin
|
||||||
from app.schemas.types import MediaSource, MediaType
|
from app.schemas.types import MediaSource, MediaType
|
||||||
@@ -154,6 +155,28 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel):
|
|||||||
|
|
||||||
model_config = ConfigDict(from_attributes=True)
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
@field_validator("note", mode="before")
|
||||||
|
@classmethod
|
||||||
|
def _normalize_legacy_note(cls, value: Any) -> Any:
|
||||||
|
"""
|
||||||
|
兼容历史字符串型 note。
|
||||||
|
|
||||||
|
2.0 时代旧代码对 JSON 列显式做了 ``json.dumps``,历史数据可能是一层
|
||||||
|
或两层 JSON 编码的字符串(如 ``'[1, 2, 3]'``),不解析会触发响应
|
||||||
|
校验 500;解析失败按空值处理,避免脏数据阻塞整个订阅列表接口。
|
||||||
|
"""
|
||||||
|
if not isinstance(value, str):
|
||||||
|
return value
|
||||||
|
parsed = value
|
||||||
|
while isinstance(parsed, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(parsed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if isinstance(parsed, list):
|
||||||
|
return [item for item in parsed if isinstance(item, int)]
|
||||||
|
return None
|
||||||
|
|
||||||
@model_validator(mode="before")
|
@model_validator(mode="before")
|
||||||
@classmethod
|
@classmethod
|
||||||
def _normalize_empty_strings(cls, data: Any) -> Any:
|
def _normalize_empty_strings(cls, data: Any) -> Any:
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
"""3.0.7
|
||||||
|
修复历史订阅 note 字段的双重 JSON 编码
|
||||||
|
|
||||||
|
Revision ID: 73370ce9bab7
|
||||||
|
Revises: f4c8d2a7b1e6
|
||||||
|
Create Date: 2026-08-17
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
import sqlalchemy as sa
|
||||||
|
|
||||||
|
|
||||||
|
revision = "73370ce9bab7"
|
||||||
|
down_revision = "f4c8d2a7b1e6"
|
||||||
|
branch_labels = None
|
||||||
|
depends_on = None
|
||||||
|
|
||||||
|
|
||||||
|
def _parse_legacy_note(value: object):
|
||||||
|
"""解析历史写入的字符串型 note,兼容一层或两层 JSON 编码。
|
||||||
|
|
||||||
|
2.0 时代旧代码对 JSON 列显式做了 ``json.dumps(note)``,SQLite 下 2.0.3
|
||||||
|
的列类型迁移又是空操作,导致读回的值是 ``'[1, 2, 3]'`` 这类字符串而非列表;
|
||||||
|
字符串会被响应模型按 ``List[int]`` 校验并触发 500。解析失败按空值处理,
|
||||||
|
不丢弃整型数组以外的历史内容。
|
||||||
|
"""
|
||||||
|
parsed = value
|
||||||
|
while isinstance(parsed, str):
|
||||||
|
try:
|
||||||
|
parsed = json.loads(parsed)
|
||||||
|
except (TypeError, ValueError):
|
||||||
|
return None
|
||||||
|
if isinstance(parsed, list) and all(isinstance(item, int) for item in parsed):
|
||||||
|
return parsed
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
def _repair_subscribe_note() -> None:
|
||||||
|
"""把 subscribe 表中字符串型 note 回写为真正的 JSON 数组。
|
||||||
|
|
||||||
|
通过 sa.JSON 类型绑定参数写回,SQLite 与 PostgreSQL 都会按各自方言
|
||||||
|
序列化,保证后续按模型读回时得到整数列表。
|
||||||
|
"""
|
||||||
|
subscribe = sa.table(
|
||||||
|
"subscribe",
|
||||||
|
sa.column("id", sa.Integer()),
|
||||||
|
sa.column("note", sa.JSON()),
|
||||||
|
)
|
||||||
|
connection = op.get_bind()
|
||||||
|
rows = connection.execute(
|
||||||
|
sa.select(subscribe.c.id, subscribe.c.note)
|
||||||
|
).mappings().all()
|
||||||
|
for row in rows:
|
||||||
|
note = row["note"]
|
||||||
|
if not isinstance(note, str):
|
||||||
|
continue
|
||||||
|
repaired = _parse_legacy_note(note)
|
||||||
|
connection.execute(
|
||||||
|
subscribe.update()
|
||||||
|
.where(subscribe.c.id == row["id"])
|
||||||
|
.values(note=repaired)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
"""修复历史订阅 note 字段的双重 JSON 编码数据。"""
|
||||||
|
if "subscribe" not in sa.inspect(op.get_bind()).get_table_names():
|
||||||
|
return
|
||||||
|
_repair_subscribe_note()
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
"""数据修复不可逆,降级不做任何操作。"""
|
||||||
|
pass
|
||||||
@@ -1424,3 +1424,34 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings():
|
|||||||
assert payload["type"] == MediaType.MUSIC.value
|
assert payload["type"] == MediaType.MUSIC.value
|
||||||
assert payload["music_type"] == "album"
|
assert payload["music_type"] == "album"
|
||||||
assert payload["total_tracks"] == 13
|
assert payload["total_tracks"] == 13
|
||||||
|
|
||||||
|
|
||||||
|
class _LegacyNoteRow:
|
||||||
|
"""携带历史字符串 note 的最小 ORM 替身。"""
|
||||||
|
|
||||||
|
def __init__(self, note):
|
||||||
|
self.note = note
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.parametrize(
|
||||||
|
("raw", "expected"),
|
||||||
|
[
|
||||||
|
("[1, 2, 3, 4]", [1, 2, 3, 4]), # 双重 JSON 编码的整型数组
|
||||||
|
("[1, 2]", [1, 2]),
|
||||||
|
("[]", []), # 双重编码的空数组
|
||||||
|
("null", None), # 双重编码的 null
|
||||||
|
("not json", None), # 无法解析的历史脏数据
|
||||||
|
([1, 2, 3], [1, 2, 3]), # 正常列表原样保留
|
||||||
|
(None, None), # 空值
|
||||||
|
],
|
||||||
|
)
|
||||||
|
def test_subscribe_note_normalizes_legacy_json_string(raw, expected):
|
||||||
|
"""历史字符串型 note 应被解析为整数列表,避免响应校验 500。"""
|
||||||
|
subscribe = Subscribe.model_validate(_LegacyNoteRow(note=raw))
|
||||||
|
assert subscribe.note == expected
|
||||||
|
|
||||||
|
|
||||||
|
def test_subscribe_note_strips_non_int_items_from_legacy_string():
|
||||||
|
"""历史脏数据中混入非整数元素时只保留整数,不阻塞整个订阅列表接口。"""
|
||||||
|
subscribe = Subscribe.model_validate(_LegacyNoteRow(note='[1, 2, "x", 3]'))
|
||||||
|
assert subscribe.note == [1, 2, 3]
|
||||||
|
|||||||
Reference in New Issue
Block a user