fix(notification): persist independent media posters

This commit is contained in:
jxxghp
2026-10-24 19:11:27 +08:00
parent 6680e090e6
commit 81260dc452
10 changed files with 181 additions and 3 deletions

View File

@@ -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("将使用模板渲染消息内容")

View File

@@ -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,
)
)

View File

@@ -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,
)

View File

@@ -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

View File

@@ -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="发送时展示的文件名")

View File

@@ -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")

View File

@@ -28,6 +28,10 @@
### 通知配置与交互身份
`GET /api/v1/message/notification` 的可空 `poster` 字段提供独立媒体海报。
新通知从已有媒体上下文提取海报;`image` 继续保留原有渠道图片语义。
旧通知及没有海报的通知返回 `poster: null`,客户端不应将背景图裁切成海报。
`POST /api/v1/notification/config` 保存通知渠道时要求名称和稳定 `id` 各自唯一。
改名应保留原 `id`;复制为新的渠道实例应使用新的 `id`,避免共享登录凭据。
通过消息渠道发起普通重启时,完成回执保留发起时的 `source` 配置。

View File

@@ -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.

View File

@@ -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()

View File

@@ -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")