diff --git a/app/application/messaging/message.py b/app/application/messaging/message.py index 1846c5f03..88b06365f 100644 --- a/app/application/messaging/message.py +++ b/app/application/messaging/message.py @@ -817,8 +817,11 @@ class MessageTemplateHelper: @staticmethod def render(message: Message, *args: Any, **kwargs: Any) -> Optional[Message]: """ - 渲染消息模板 + 从媒体上下文补充独立海报并渲染模板,保留渠道原有图片和显式海报。 """ + mediainfo = kwargs.get("mediainfo") + if isinstance(message, Message) and not message.poster and isinstance(mediainfo, (MediaInfo, MusicInfo)): + message.poster = mediainfo.get_poster_image() or None if not MessageTemplateHelper.is_instance_valid(message): if MessageTemplateHelper.meets_update_conditions(message, *args, **kwargs): logger.info("将使用模板渲染消息内容") diff --git a/app/chain/download/submission.py b/app/chain/download/submission.py index 7ca43c0da..d90c101af 100644 --- a/app/chain/download/submission.py +++ b/app/chain/download/submission.py @@ -941,6 +941,7 @@ class DownloadSubmissionOwner(_DownloadResourceOwner): f"错误信息:{error_msg}" ), image=prepared.media.get_message_image(), + poster=prepared.media.get_poster_image() or None, userid=userid, ) ) diff --git a/app/chain/subscribe/notify.py b/app/chain/subscribe/notify.py index 3bc3a6797..02cbb7866 100644 --- a/app/chain/subscribe/notify.py +++ b/app/chain/subscribe/notify.py @@ -316,6 +316,7 @@ class SubscribeNotificationOwner(_SubscribeOwnerBase): title=(f"{context.mediainfo.title_year} {context.metainfo.season} 添加订阅失败!"), text=err_msg, image=context.mediainfo.get_message_image(), + poster=context.mediainfo.get_poster_image() or None, userid=context.userid, ) diff --git a/app/db/models/message.py b/app/db/models/message.py index eaafa90cd..f014255ff 100644 --- a/app/db/models/message.py +++ b/app/db/models/message.py @@ -30,6 +30,8 @@ class Message(Base): text: Mapped[Optional[str]] = mapped_column(String) # 图片 image: Mapped[Optional[str]] = mapped_column(String) + # 独立媒体海报,历史记录不从背景图猜测 + poster: Mapped[Optional[str]] = mapped_column(String) # 链接 link: Mapped[Optional[str]] = mapped_column(String) # 用户ID diff --git a/app/schemas/message.py b/app/schemas/message.py index 712f2ae38..a640e2778 100644 --- a/app/schemas/message.py +++ b/app/schemas/message.py @@ -68,6 +68,7 @@ class MessageHistoryItem(BaseModel): title: Optional[str] = Field(default=None, description="标题") text: Optional[str] = Field(default=None, description="文本内容") image: Optional[str] = Field(default=None, description="图片") + poster: Optional[str] = Field(default=None, description="独立媒体海报,不使用背景图回退") link: Optional[str] = Field(default=None, description="链接") userid: Optional[str] = Field(default=None, description="用户ID") reg_time: Optional[str] = Field(default=None, description="登记时间") @@ -274,6 +275,7 @@ class Message(BaseModel): title: Optional[str] = Field(default=None, description="标题") text: Optional[str] = Field(default=None, description="文本内容") image: Optional[str] = Field(default=None, description="图片") + poster: Optional[str] = Field(default=None, description="独立媒体海报,不使用背景图回退") voice_path: Optional[str] = Field(default=None, description="语音文件路径") file_path: Optional[str] = Field(default=None, description="本地文件路径") file_name: Optional[str] = Field(default=None, description="发送时展示的文件名") diff --git a/database/versions/e4a8c7b29016_3_1_9.py b/database/versions/e4a8c7b29016_3_1_9.py new file mode 100644 index 000000000..fc4bc49cf --- /dev/null +++ b/database/versions/e4a8c7b29016_3_1_9.py @@ -0,0 +1,42 @@ +""" +3.1.9 为通知历史增加独立媒体海报。 + +版本标识:e4a8c7b29016 +前置版本:da73b6f20418 +创建时间:2026-10-24 +""" + +from alembic import op +import sqlalchemy as sa + + +revision = "e4a8c7b29016" +down_revision = "da73b6f20418" +branch_labels = None +depends_on = None + + +def _has_poster() -> bool: + """ + 检查通知表是否已经由新 ORM 或迁移创建海报字段。 + """ + return any( + column["name"] == "poster" + for column in sa.inspect(op.get_bind()).get_columns("message") + ) + + +def upgrade() -> None: + """ + 增加可空海报,不将历史渠道图片误认为媒体海报。 + """ + if not _has_poster(): + op.add_column("message", sa.Column("poster", sa.String(), nullable=True)) + + +def downgrade() -> None: + """ + 移除独立海报字段,保留原有图片与通知内容。 + """ + if _has_poster(): + op.drop_column("message", "poster") diff --git a/docs/mcp-api.md b/docs/mcp-api.md index a48974a04..b3a5ea1ef 100644 --- a/docs/mcp-api.md +++ b/docs/mcp-api.md @@ -28,6 +28,10 @@ ### 通知配置与交互身份 +`GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。 +新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。 +旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。 + `POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。 改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。 通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。 diff --git a/skills/database-operation/SKILL.md b/skills/database-operation/SKILL.md index 201fae41e..99fa69a37 100644 --- a/skills/database-operation/SKILL.md +++ b/skills/database-operation/SKILL.md @@ -193,7 +193,7 @@ python scripts/mp-db.py write "UPDATE subscribe SET state = 'S' WHERE id = 123" - Purpose: Stores inbound and outbound messages, channels, content, attachments, users, and timestamps. - Useful queries: Paging notification history, distinguishing direction, or tracing duplicates by source. - Write boundary: Written by messaging and notification services; clean it through the message API or retention job. -- Columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `link`, `userid`, `reg_time`, `action`, `note` +- Columns: `id`, `channel`, `source`, `mtype`, `title`, `text`, `image`, `poster`, `link`, `userid`, `reg_time`, `action`, `note` ### `outboxmessage` - Purpose: Stores externally visible side-effect intents committed atomically with business transactions. diff --git a/tests/test_message_posters.py b/tests/test_message_posters.py new file mode 100644 index 000000000..72702c51d --- /dev/null +++ b/tests/test_message_posters.py @@ -0,0 +1,122 @@ +""" +验证通知独立海报经过渲染、持久化、查询和事件投影,且不改变渠道图片。 +""" + +import asyncio +import importlib +from uuid import uuid4 + +import pytest +import sqlalchemy as sa +from alembic.migration import MigrationContext +from alembic.operations import Operations + +from app.api.endpoints.message import get_notification_message +from app.application.messaging.message import MessageQueryService, MessageTemplateHelper +from app.chain.base import ChainBase +from app.db.oper.message import MessageOper +from app.db.session import AsyncSessionFactory +from app.domain.context import MediaInfo, MusicInfo +from app.schemas.message import Message + + +@pytest.mark.parametrize("has_poster", [True, False]) +def test_render_preserves_backdrop_and_projects_only_real_poster(has_poster: bool) -> None: + """ + 即使消息已经有正文也应提取独立海报,缺失时不得从背景图回退。 + """ + media = MediaInfo( + title="测试影片", + backdrop_path="https://images.example.com/backdrop.jpg", + poster_path="https://images.example.com/poster.jpg" if has_poster else None, + ) + message = Message(title="已完成整理", image=media.get_message_image()) + + rendered = MessageTemplateHelper.render(message, mediainfo=media) + + assert rendered is message + assert message.image == "https://images.example.com/backdrop.jpg" + assert message.poster == ("https://images.example.com/poster.jpg" if has_poster else None) + event = ChainBase._build_notice_message_data(message) + assert event["poster"] == message.poster + assert event["image"] == message.image + + +def test_render_keeps_explicit_poster_and_supports_music_cover() -> None: + """ + 显式海报优先,音乐媒体使用已有封面,无媒体上下文不猜测图片类型。 + """ + music = MusicInfo(title="测试专辑", cover_url="https://images.example.com/album.jpg") + message = Message(title="入库完成", poster="https://images.example.com/custom.jpg") + assert MessageTemplateHelper.render(message, mediainfo=music).poster == message.poster + assert MessageTemplateHelper.render(Message(title="专辑通知"), mediainfo=music).poster == music.cover_url + assert MessageTemplateHelper.render(Message(title="普通通知", image=music.cover_url)).poster is None + + +@pytest.mark.parametrize("async_write", [False, True]) +def test_notification_history_preserves_poster_and_legacy_null(async_write: bool) -> None: + """ + 同步和异步写入均应通过真实通知查询返回独立海报,同时兼容旧消息。 + """ + title = f"poster-contract-{uuid4().hex}" + payload = { + "title": title, + "image": "https://images.example.com/backdrop.jpg", + "poster": "https://images.example.com/poster.jpg", + } + + async def write_and_query() -> None: + """ + 仅使用测试临时数据库保存消息,并经 API schema 读取结果。 + """ + async with AsyncSessionFactory() as db: + if async_write: + await MessageOper(db).async_add(**payload) + await MessageOper(db).async_add(title=f"{title}-legacy", image=payload["image"]) + await db.commit() + else: + MessageOper().add(**payload) + MessageOper().add(title=f"{title}-legacy", image=payload["image"]) + messages = await get_notification_message(service=MessageQueryService(MessageOper(db))) + current = next(item for item in messages if item.title == title) + legacy = next(item for item in messages if item.title == f"{title}-legacy") + assert current.image == payload["image"] + assert current.poster == payload["poster"] + assert legacy.image == payload["image"] + assert legacy.poster is None + + asyncio.run(write_and_query()) + + +@pytest.mark.parametrize("already_created", [False, True]) +def test_message_poster_migration_preserves_existing_images(monkeypatch, already_created: bool) -> None: + """ + 迁移支持旧表和新 ORM 已建表,重复执行及回滚不会改写历史背景图。 + """ + migration = importlib.import_module("database.versions.e4a8c7b29016_3_1_9") + engine = sa.create_engine("sqlite://") + metadata = sa.MetaData() + columns = [sa.Column("id", sa.Integer(), primary_key=True), sa.Column("image", sa.String())] + if already_created: + columns.append(sa.Column("poster", sa.String(), nullable=True)) + messages = sa.Table("message", metadata, *columns) + with engine.begin() as connection: + metadata.create_all(connection) + connection.execute(messages.insert(), {"id": 1, "image": "legacy-backdrop.jpg"}) + monkeypatch.setattr(migration, "op", Operations(MigrationContext.configure(connection))) + migration.upgrade() + migration.upgrade() + migrated = sa.Table("message", sa.MetaData(), autoload_with=connection) + row = connection.execute(sa.select(migrated)).mappings().one() + assert row["image"] == "legacy-backdrop.jpg" + assert row["poster"] is None + connection.execute(migrated.insert(), {"id": 2, "image": "backdrop.jpg", "poster": "poster.jpg"}) + assert connection.execute(sa.select(migrated.c.poster).where(migrated.c.id == 2)).scalar_one() == "poster.jpg" + migration.downgrade() + migration.downgrade() + restored = sa.Table("message", sa.MetaData(), autoload_with=connection) + assert "poster" not in restored.c + assert connection.execute(sa.select(restored.c.image).order_by(restored.c.id)).scalars().all() == [ + "legacy-backdrop.jpg", "backdrop.jpg", + ] + engine.dispose() diff --git a/tests/test_subscription_identity_review_migration.py b/tests/test_subscription_identity_review_migration.py index e3f8ba3d5..f541f47d9 100644 --- a/tests/test_subscription_identity_review_migration.py +++ b/tests/test_subscription_identity_review_migration.py @@ -87,7 +87,8 @@ def test_identity_upgrade_chain_preserves_plugin_installation_snapshot(monkeypat config = Config() config.set_main_option("script_location", "database") script = ScriptDirectory.from_config(config) - assert script.get_heads() == ["da73b6f20418"] + assert script.get_heads() == ["e4a8c7b29016"] + assert script.get_revision("e4a8c7b29016").down_revision == "da73b6f20418" assert script.get_revision("da73b6f20418").down_revision == "c9e2f6a04173" assert script.get_revision("c9e2f6a04173").down_revision == "b8d2f5a01962" predecessor = importlib.import_module("database.versions.b8d2f5a01962_plugin_data_identity")