feat(notification): 通知标题和正文支持多语言

- 通知静态配置支持 title/body i18n
- 前端通知列表和详情页展示本地化内容
- 新增数据库迁移脚本
- 更新通知协议文档
This commit is contained in:
ZL-Q
2026-04-28 17:20:17 +08:00
parent b9617ae152
commit a940f2ea47
16 changed files with 601 additions and 213 deletions
@@ -0,0 +1,51 @@
"""Convert notification title/body from text to jsonb (i18n dict).
title and body become jsonb objects keyed by locale code:
{"zh": "欢迎来到觅爻", "zh_Hant": "...", "en": "..."}
Existing data is wrapped under the "zh" key (simplified Chinese default).
Revision ID: 20260428_0001
"""
from alembic import op
import sqlalchemy as sa
revision = "20260428_0001"
down_revision = "20260427_0002"
branch_labels = None
depends_on = None
def upgrade() -> None:
op.execute(
"""
ALTER TABLE notifications
ALTER COLUMN title TYPE jsonb USING jsonb_build_object('zh', title),
ALTER COLUMN body TYPE jsonb USING jsonb_build_object('zh', body);
"""
)
op.execute(
"""
ALTER TABLE notifications DROP CONSTRAINT IF EXISTS ck_notifications_payload_object;
"""
)
op.execute(
"""
ALTER TABLE notifications
ADD CONSTRAINT ck_notifications_payload_object
CHECK (jsonb_typeof(payload) = 'object');
"""
)
def downgrade() -> None:
op.execute(
"""
ALTER TABLE notifications
ALTER COLUMN title TYPE text USING COALESCE(title ->> 'zh', ''),
ALTER COLUMN body TYPE text USING COALESCE(body ->> 'zh', '');
"""
)
@@ -7,7 +7,14 @@ from typing import Literal
from uuid import UUID
import yaml
from pydantic import BaseModel, ConfigDict, Field, ValidationError, model_validator
from pydantic import (
BaseModel,
ConfigDict,
Field,
ValidationError,
field_validator,
model_validator,
)
from backend.src.schemas.shared.notification import (
NotificationPayload,
@@ -18,6 +25,7 @@ from schemas.enums import NotificationTargetMode
class StaticNotificationDefinition(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
supported_locale_keys: ClassVar[set[str]] = {"zh", "zh_Hant", "en"}
source_key: str = Field(min_length=1, max_length=128)
version: int = Field(ge=1)
@@ -25,10 +33,22 @@ class StaticNotificationDefinition(BaseModel):
status: Literal["draft", "published", "revoked"]
deleted: bool = False
published_at: datetime | None = None
title: str = Field(min_length=1)
body: str = Field(min_length=1)
title: dict[str, str] = Field(min_length=1)
body: dict[str, str] = Field(min_length=1)
payload: NotificationPayload = NotificationPayloadNone(action="none")
@field_validator("title", "body")
@classmethod
def validate_i18n_text(cls, value: dict[str, str]) -> dict[str, str]:
invalid_keys = set(value) - cls.supported_locale_keys
if invalid_keys:
raise ValueError("i18n keys must be one of zh, zh_Hant, en")
if "zh" not in value:
raise ValueError("i18n text must include zh")
if any(not text.strip() for text in value.values()):
raise ValueError("i18n text values must be non-empty")
return value
class StaticNotificationTargets(BaseModel):
model_config: ClassVar[ConfigDict] = ConfigDict(extra="forbid")
@@ -1,10 +1,16 @@
notification:
source_key: welcome_points
version: 1
version: 2
type: system
status: published
title: 欢迎来到觅爻
body: 你已获得新用户奖励,点击前往积分页查看当前余额。
title:
zh: 欢迎来到觅爻
zh_Hant: 歡迎來到覓爻
en: Welcome to MeiYao
body:
zh: 你已获得新用户奖励,点击前往积分页查看当前余额。
zh_Hant: 你已獲得新用戶獎勵,點擊前往積分頁查看當前餘額。
en: You have received a new user reward. Tap to check your points balance.
payload:
action: open_route
route: /points
+9 -5
View File
@@ -3,12 +3,12 @@ from __future__ import annotations
import uuid
from datetime import datetime
from sqlalchemy import CheckConstraint, DateTime, Index, String, Text, text
from sqlalchemy import CheckConstraint, DateTime, Index, String, text
from sqlalchemy.dialects.postgresql import UUID
from sqlalchemy.orm import Mapped, mapped_column
from core.db.base import Base, SoftDeleteMixin, TimestampMixin
from core.db.types import json_jsonb
from core.db.types import json_jsonb as jsonb
from schemas.enums import NotificationTargetMode
@@ -57,10 +57,14 @@ class Notification(TimestampMixin, SoftDeleteMixin, Base):
source_key: Mapped[str | None] = mapped_column(String(128), nullable=True)
source_version: Mapped[int | None] = mapped_column(nullable=True)
content_hash: Mapped[str | None] = mapped_column(String(64), nullable=True)
title: Mapped[str] = mapped_column(Text, nullable=False)
body: Mapped[str] = mapped_column(Text, nullable=False)
title: Mapped[dict[str, str]] = mapped_column(
jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict,
)
body: Mapped[dict[str, str]] = mapped_column(
jsonb, nullable=False, server_default=text("'{}'::jsonb"), default=dict,
)
payload: Mapped[dict[str, object]] = mapped_column(
json_jsonb,
jsonb,
nullable=False,
server_default=text("'{}'::jsonb"),
default=dict,
+24 -13
View File
@@ -1,11 +1,13 @@
from __future__ import annotations
from datetime import datetime
from typing import Annotated
from fastapi import APIRouter, Depends, Query
from core.logging import get_logger
from core.auth.models import CurrentUser
from core.http.errors import ApiProblemError, problem_payload
from v1.notifications.dependencies import get_notification_service
from v1.notifications.schemas import (
MarkAllReadResponse,
@@ -13,33 +15,42 @@ from v1.notifications.schemas import (
NotificationListResponse,
UnreadCountResponse,
)
from v1.notifications.service import NotificationService
from v1.notifications.service import NotificationService, normalize_locale
from v1.users.dependencies import get_current_user
router = APIRouter(prefix="/notifications", tags=["notifications"])
logger = get_logger("v1.notifications.router")
def _parse_cursor(cursor: str | None) -> datetime | None:
if cursor is None:
return None
try:
return datetime.fromisoformat(cursor.replace("Z", "+00:00"))
except ValueError as exc:
raise ApiProblemError(
status_code=422,
detail=problem_payload(
code="NOTIFICATION_INVALID_CURSOR",
detail="Notification cursor must be an ISO 8601 datetime",
params={"cursor": cursor},
),
) from exc
@router.get("", response_model=NotificationListResponse)
async def list_notifications(
service: Annotated[NotificationService, Depends(get_notification_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
limit: int = Query(default=20, ge=1, le=50),
cursor: str | None = Query(default=None),
locale: str | None = Query(default=None),
) -> NotificationListResponse:
from datetime import datetime
parsed_cursor = None
if cursor is not None:
try:
parsed_cursor = datetime.fromisoformat(cursor.replace("Z", "+00:00"))
except (ValueError, AttributeError):
parsed_cursor = None
result = await service.list_notifications(
user_id=current_user.id,
limit=limit,
cursor=parsed_cursor,
cursor=_parse_cursor(cursor),
locale=normalize_locale(locale),
)
logger.info(
"Notification list fetched",
@@ -89,14 +100,13 @@ async def mark_notification_read(
notification_id: str,
service: Annotated[NotificationService, Depends(get_notification_service)],
current_user: Annotated[CurrentUser, Depends(get_current_user)],
locale: str | None = Query(default=None),
) -> NotificationItemResponse:
from uuid import UUID
try:
uid = UUID(notification_id)
except ValueError:
from core.http.errors import ApiProblemError, problem_payload
raise ApiProblemError(
status_code=404,
detail=problem_payload(
@@ -108,6 +118,7 @@ async def mark_notification_read(
item = await service.mark_read(
user_notification_id=uid,
user_id=current_user.id,
locale=normalize_locale(locale),
)
logger.info(
"Notification marked as read",
+39 -5
View File
@@ -13,6 +13,35 @@ from v1.notifications.schemas import (
NotificationPayload,
)
DEFAULT_LOCALE = "zh"
SUPPORTED_LOCALES = frozenset({"zh", "zh_Hant", "en"})
def resolve_i18n_text(i18n_dict: dict[str, str], locale: str) -> str:
if not i18n_dict:
return ""
if locale in i18n_dict:
return i18n_dict[locale]
if DEFAULT_LOCALE in i18n_dict:
return i18n_dict[DEFAULT_LOCALE]
return ""
def normalize_locale(raw: str | None) -> str:
if raw is None:
return DEFAULT_LOCALE
locale = raw.strip()
if locale in SUPPORTED_LOCALES:
return locale
lower = locale.lower().replace("-", "_")
if lower in ("zh_cn", "zh_hans", "zh"):
return "zh"
if lower in ("zh_tw", "zh_hant", "zh_hk"):
return "zh_Hant"
if lower.startswith("en"):
return "en"
return DEFAULT_LOCALE
@dataclass(frozen=True)
class NotificationListItem:
@@ -44,6 +73,7 @@ class NotificationService:
user_id: UUID,
limit: int = 20,
cursor: datetime | None = None,
locale: str = DEFAULT_LOCALE,
) -> NotificationListResult:
actual_limit = min(limit, 50)
rows = await self._repository.list_notifications(
@@ -65,8 +95,8 @@ class NotificationService:
id=un.id,
notification_id=n.id,
type=n.type,
title=n.title,
body=n.body,
title=resolve_i18n_text(n.title, locale),
body=resolve_i18n_text(n.body, locale),
payload=payload,
is_read=un.is_read,
read_at=un.read_at,
@@ -83,7 +113,11 @@ class NotificationService:
return await self._repository.get_unread_count(user_id=user_id)
async def mark_read(
self, *, user_notification_id: UUID, user_id: UUID
self,
*,
user_notification_id: UUID,
user_id: UUID,
locale: str = DEFAULT_LOCALE,
) -> NotificationListItem:
result = await self._repository.get_user_notification(
user_notification_id=user_notification_id,
@@ -109,8 +143,8 @@ class NotificationService:
id=un.id,
notification_id=n.id,
type=n.type,
title=n.title,
body=n.body,
title=resolve_i18n_text(n.title, locale),
body=resolve_i18n_text(n.body, locale),
payload=payload,
is_read=True,
read_at=un.read_at or datetime.now(timezone.utc),
+93 -10
View File
@@ -5,7 +5,12 @@ from uuid import UUID, uuid4
import pytest
from v1.notifications.service import NotificationService, _parse_payload
from v1.notifications.service import (
NotificationService,
_parse_payload,
resolve_i18n_text,
normalize_locale,
)
from v1.notifications.schemas import (
NotificationPayloadNone,
NotificationPayloadRoute,
@@ -39,8 +44,8 @@ class _FakeNotification:
*,
id: UUID,
type: str = "system",
title: str = "Test",
body: str = "Test body",
title: dict[str, str] | None = None,
body: dict[str, str] | None = None,
payload: dict | None = None,
status: str = "published",
deleted_at: datetime | None = None,
@@ -48,8 +53,8 @@ class _FakeNotification:
):
self.id = id
self.type = type
self.title = title
self.body = body
self.title = title or {"zh": "Test"}
self.body = body or {"zh": "Test body"}
self.payload = payload or {"action": "none"}
self.status = status
self.deleted_at = deleted_at
@@ -154,8 +159,8 @@ def _make_notification(
notification_id: UUID | None = None,
is_read: bool = False,
read_at: datetime | None = None,
title: str = "Test",
body: str = "Test body",
title: dict[str, str] | None = None,
body: dict[str, str] | None = None,
payload: dict | None = None,
status: str = "published",
deleted_at: datetime | None = None,
@@ -185,8 +190,12 @@ class TestListNotifications:
async def test_returns_only_user_a_notifications(
self, service: NotificationService, fake_repo: _FakeNotificationRepository
):
un_a, n_a = _make_notification(user_id=USER_A, title="A1")
un_b, n_b = _make_notification(user_id=USER_B, title="B1")
un_a, n_a = _make_notification(
user_id=USER_A, title={"zh": "A1"}, body={"zh": "A1 body"},
)
un_b, n_b = _make_notification(
user_id=USER_B, title={"zh": "B1"}, body={"zh": "B1 body"},
)
fake_repo.add_item(un_a, n_a)
fake_repo.add_item(un_b, n_b)
@@ -219,7 +228,9 @@ class TestListNotifications:
self, service: NotificationService, fake_repo: _FakeNotificationRepository
):
for i in range(3):
un, n = _make_notification(user_id=USER_A, title=f"N{i}")
un, n = _make_notification(
user_id=USER_A, title={"zh": f"N{i}"}, body={"zh": f"N{i} body"},
)
fake_repo.add_item(un, n)
result = await service.list_notifications(user_id=USER_A, limit=2)
@@ -383,3 +394,75 @@ class TestParsePayload:
assert payload.route == "/settings"
assert payload.entity_id is None
assert payload.tab is None
class TestResolveI18nText:
def test_exact_locale_match(self):
text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "en")
assert text == "Hello"
def test_falls_back_to_default(self):
text = resolve_i18n_text({"zh": "你好", "en": "Hello"}, "zh_Hant")
assert text == "你好"
def test_returns_empty_when_default_missing(self):
text = resolve_i18n_text({"en": "Hello"}, "zh_Hant")
assert text == ""
def test_empty_dict(self):
text = resolve_i18n_text({}, "en")
assert text == ""
class TestNormalizeLocale:
def test_known_locale_passthrough(self):
assert normalize_locale("zh") == "zh"
assert normalize_locale("zh_Hant") == "zh_Hant"
assert normalize_locale("en") == "en"
def test_none_returns_default(self):
assert normalize_locale(None) == "zh"
def test_zh_cn_maps_to_zh(self):
assert normalize_locale("zh_CN") == "zh"
assert normalize_locale("zh_Hans") == "zh"
def test_zh_tw_maps_to_hant(self):
assert normalize_locale("zh_TW") == "zh_Hant"
assert normalize_locale("zh-Hant") == "zh_Hant"
def test_unknown_returns_default(self):
assert normalize_locale("fr") == "zh"
assert normalize_locale("ja") == "zh"
class TestListNotificationsI18n:
@pytest.mark.asyncio
async def test_locale_en_returns_english(
self, service: NotificationService, fake_repo: _FakeNotificationRepository
):
un, n = _make_notification(
user_id=USER_A,
title={"zh": "你好", "en": "Hello"},
body={"zh": "正文", "en": "Body"},
)
fake_repo.add_item(un, n)
result = await service.list_notifications(user_id=USER_A, locale="en")
assert result.items[0].title == "Hello"
assert result.items[0].body == "Body"
@pytest.mark.asyncio
async def test_locale_zh_hant_falls_back_to_zh(
self, service: NotificationService, fake_repo: _FakeNotificationRepository
):
un, n = _make_notification(
user_id=USER_A,
title={"zh": "你好", "en": "Hello"},
body={"zh": "正文", "en": "Body"},
)
fake_repo.add_item(un, n)
result = await service.list_notifications(user_id=USER_A, locale="zh_Hant")
assert result.items[0].title == "你好"
assert result.items[0].body == "正文"
@@ -27,8 +27,12 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None
version: 1
type: system
status: published
title: Welcome
body: Welcome to the app.
title:
zh: 欢迎
en: Welcome
body:
zh: 欢迎使用
en: Welcome to the app.
payload:
action: open_route
route: /points
@@ -43,6 +47,8 @@ def test_load_static_notification_file_parses_valid_yaml(tmp_path: Path) -> None
loaded = load_static_notification_file(file_path)
assert loaded.notification.source_key == "welcome_bonus"
assert loaded.notification.title == {"zh": "欢迎", "en": "Welcome"}
assert loaded.notification.body == {"zh": "欢迎使用", "en": "Welcome to the app."}
assert loaded.notification.payload.action == "open_route"
assert loaded.targets.mode == NotificationTargetMode.USER_IDS
assert len(loaded.targets.user_ids or []) == 1
@@ -58,8 +64,10 @@ def test_load_static_notification_file_parses_new_users(tmp_path: Path) -> None:
version: 1
type: system
status: published
title: Welcome
body: You got points.
title:
zh: 欢迎
body:
zh: 你好
payload:
action: open_route
route: /points
@@ -85,8 +93,10 @@ def test_load_static_notification_file_parses_exist_users(tmp_path: Path) -> Non
version: 1
type: system
status: published
title: Come back
body: We miss you.
title:
zh: 回来吧
body:
zh: 想你
payload:
action: none
targets:
@@ -110,8 +120,10 @@ def test_load_static_notification_file_parses_all_users(tmp_path: Path) -> None:
version: 1
type: system
status: published
title: Announcement
body: Maintenance at midnight.
title:
zh: 公告
body:
zh: 午夜维护
payload:
action: none
targets:
@@ -134,8 +146,10 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -
version: 1
type: system
status: published
title: Invalid
body: Invalid targets.
title:
zh: 无效
body:
zh: 无效
payload:
action: none
targets:
@@ -149,6 +163,88 @@ def test_load_static_notification_file_rejects_invalid_targets(tmp_path: Path) -
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_i18n_without_zh(
tmp_path: Path,
) -> None:
file_path = tmp_path / "missing_zh.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: missing_zh
version: 1
type: system
status: published
title:
en: Welcome
body:
zh: 正文
payload:
action: none
targets:
mode: all_users
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_empty_i18n_text(
tmp_path: Path,
) -> None:
file_path = tmp_path / "empty_i18n.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: empty_i18n
version: 1
type: system
status: published
title:
zh: ""
body:
zh: 正文
payload:
action: none
targets:
mode: all_users
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_unknown_i18n_locale(
tmp_path: Path,
) -> None:
file_path = tmp_path / "unknown_locale.yaml"
_write_yaml(
file_path,
"""
notification:
source_key: unknown_locale
version: 1
type: system
status: published
title:
zh: 标题
ja: タイトル
body:
zh: 正文
payload:
action: none
targets:
mode: all_users
""",
)
with pytest.raises(ValueError, match="Invalid static notification data"):
load_static_notification_file(file_path)
def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> None:
file_path = tmp_path / "bad_mode.yaml"
_write_yaml(
@@ -159,8 +255,10 @@ def test_load_static_notification_file_rejects_unknown_mode(tmp_path: Path) -> N
version: 1
type: system
status: published
title: Bad
body: Bad mode.
title:
zh: 坏
body:
zh: 坏
payload:
action: none
targets:
@@ -184,8 +282,10 @@ def test_load_static_notification_file_rejects_new_users_with_user_ids(
version: 1
type: system
status: published
title: Bad
body: Bad.
title:
zh: 坏
body:
zh: 坏
payload:
action: none
targets:
@@ -211,8 +311,10 @@ def test_load_static_notification_file_rejects_user_ids_without_list(
version: 1
type: system
status: published
title: Bad
body: Bad.
title:
zh: 坏
body:
zh: 坏
payload:
action: none
targets:
@@ -235,8 +337,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key(
version: 1
type: system
status: published
title: First
body: First body.
title:
zh: 第一
body:
zh: 第一
payload:
action: none
targets:
@@ -251,8 +355,10 @@ def test_load_static_notification_documents_rejects_duplicate_source_key(
version: 1
type: system
status: published
title: Second
body: Second body.
title:
zh: 第二
body:
zh: 第二
payload:
action: none
targets:
@@ -275,8 +381,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None:
version: 1
type: system
status: published
title: Title A
body: Body A.
title:
zh: 标题A
body:
zh: 正文A
payload:
action: none
targets:
@@ -291,8 +399,10 @@ def test_content_hash_changes_when_notification_changes(tmp_path: Path) -> None:
version: 1
type: system
status: published
title: Title B
body: Body A.
title:
zh: 标题B
body:
zh: 正文A
payload:
action: none
targets:
@@ -319,8 +429,10 @@ def test_load_static_notification_file_supports_deleted_flag(tmp_path: Path) ->
type: system
status: revoked
deleted: true
title: Deleted
body: Deleted body.
title:
zh: 已删
body:
zh: 已删
payload:
action: none
targets: