Files
eryao/backend/src/v1/notifications/service.py
T
ZL-Q a940f2ea47 feat(notification): 通知标题和正文支持多语言
- 通知静态配置支持 title/body i18n
- 前端通知列表和详情页展示本地化内容
- 新增数据库迁移脚本
- 更新通知协议文档
2026-04-28 17:20:17 +08:00

187 lines
5.7 KiB
Python

from __future__ import annotations
from dataclasses import dataclass
from datetime import datetime, timezone
from uuid import UUID
from core.http.errors import ApiProblemError, problem_payload
from v1.notifications.repository import NotificationRepository
from v1.notifications.schemas import (
NotificationPayloadNone,
NotificationPayloadRoute,
NotificationPayloadUrl,
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:
id: UUID
notification_id: UUID
type: str
title: str
body: str
payload: NotificationPayload
is_read: bool
read_at: datetime | None
created_at: datetime
@dataclass(frozen=True)
class NotificationListResult:
items: list[NotificationListItem]
next_cursor: datetime | None
has_more: bool
class NotificationService:
def __init__(self, repository: NotificationRepository) -> None:
self._repository = repository
async def list_notifications(
self,
*,
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(
user_id=user_id,
limit=actual_limit + 1,
cursor=cursor,
)
has_more = len(rows) > actual_limit
items = rows[:actual_limit]
next_cursor = None
if has_more and items:
next_cursor = items[-1][0].created_at
list_items = []
for un, n in items:
payload = _parse_payload(n.payload)
list_items.append(
NotificationListItem(
id=un.id,
notification_id=n.id,
type=n.type,
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,
created_at=un.created_at,
)
)
return NotificationListResult(
items=list_items,
next_cursor=next_cursor,
has_more=has_more,
)
async def get_unread_count(self, *, user_id: UUID) -> int:
return await self._repository.get_unread_count(user_id=user_id)
async def mark_read(
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,
user_id=user_id,
)
if result is None:
raise ApiProblemError(
status_code=404,
detail=problem_payload(
code="NOTIFICATION_NOT_FOUND",
detail="Notification not found or not owned by current user",
),
)
un, n = result
if not un.is_read:
await self._repository.mark_read(
user_notification_id=user_notification_id,
user_id=user_id,
)
await self._repository.commit()
payload = _parse_payload(n.payload)
return NotificationListItem(
id=un.id,
notification_id=n.id,
type=n.type,
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),
created_at=un.created_at,
)
async def mark_all_read(self, *, user_id: UUID) -> int:
updated_count = await self._repository.mark_all_read(user_id=user_id)
if updated_count > 0:
await self._repository.commit()
return updated_count
async def link_notifications_for_registered_user(
self, *, user_id: UUID, is_first_registration: bool
) -> int:
return await self._repository.link_notifications_for_registered_user(
user_id=user_id, is_first_registration=is_first_registration
)
def _parse_payload(raw: dict[str, object]) -> NotificationPayload:
action = raw.get("action")
if action == "none":
return NotificationPayloadNone(action="none")
if action == "open_route":
return NotificationPayloadRoute(
action="open_route",
route=str(raw.get("route", "")),
entity_id=str(raw["entity_id"])
if "entity_id" in raw and raw["entity_id"] is not None
else None,
tab=str(raw["tab"]) if "tab" in raw and raw["tab"] is not None else None,
)
if action == "open_url":
return NotificationPayloadUrl(
action="open_url",
url=str(raw.get("url", "")),
)
return NotificationPayloadNone(action="none")