a940f2ea47
- 通知静态配置支持 title/body i18n - 前端通知列表和详情页展示本地化内容 - 新增数据库迁移脚本 - 更新通知协议文档
187 lines
5.7 KiB
Python
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")
|