feat: 实现站内通知系统
- 后端: 新增 notifications/user_notifications 表迁移及 ORM 模型
- 后端: 实现 schema/repository/service/router 全套通知 API
- GET /api/v1/notifications (列表+游标分页)
- GET /api/v1/notifications/unread-count
- PATCH /api/v1/notifications/{id}/read (幂等)
- PATCH /api/v1/notifications/mark-all-read (幂等)
- 后端: payload 使用 Pydantic discriminated union (none/open_route/open_url)
- 后端: 19 个单元测试全部通过
- Flutter: 通知 feature 完整实现 (models/apis/repositories/bloc/UI)
- Flutter: Home 页通知按钮接入真实页面,显示未读 badge
- Flutter: 14 个测试全部通过
- 协议文档: notification-inbox-protocol.md 及错误码注册
This commit is contained in:
@@ -0,0 +1,141 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
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,
|
||||
)
|
||||
|
||||
|
||||
@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,
|
||||
) -> 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=n.title,
|
||||
body=n.body,
|
||||
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
|
||||
) -> 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,
|
||||
)
|
||||
payload = _parse_payload(n.payload)
|
||||
return NotificationListItem(
|
||||
id=un.id,
|
||||
notification_id=n.id,
|
||||
type=n.type,
|
||||
title=n.title,
|
||||
body=n.body,
|
||||
payload=payload,
|
||||
is_read=True,
|
||||
read_at=un.read_at or datetime.now(),
|
||||
created_at=un.created_at,
|
||||
)
|
||||
|
||||
async def mark_all_read(self, *, user_id: UUID) -> int:
|
||||
return await self._repository.mark_all_read(user_id=user_id)
|
||||
|
||||
|
||||
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")
|
||||
Reference in New Issue
Block a user