diff --git a/app/schemas/subscribe.py b/app/schemas/subscribe.py index f0b511c8f..f1be43ca6 100644 --- a/app/schemas/subscribe.py +++ b/app/schemas/subscribe.py @@ -1,6 +1,7 @@ +import json 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.types import MediaSource, MediaType @@ -154,6 +155,28 @@ class Subscribe(OptionalMediaIdentityMixin, BaseModel): 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") @classmethod def _normalize_empty_strings(cls, data: Any) -> Any: diff --git a/database/versions/73370ce9bab7_3_0_7.py b/database/versions/73370ce9bab7_3_0_7.py new file mode 100644 index 000000000..9004005fd --- /dev/null +++ b/database/versions/73370ce9bab7_3_0_7.py @@ -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 diff --git a/tests/test_subscribe_endpoint.py b/tests/test_subscribe_endpoint.py index 53cff0141..93bac6072 100644 --- a/tests/test_subscribe_endpoint.py +++ b/tests/test_subscribe_endpoint.py @@ -1424,3 +1424,34 @@ def test_create_subscribe_accepts_music_payload_with_empty_strings(): assert payload["type"] == MediaType.MUSIC.value assert payload["music_type"] == "album" 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]