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