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,117 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Annotated
|
||||
|
||||
from fastapi import APIRouter, Depends, Query
|
||||
|
||||
from core.auth.models import CurrentUser
|
||||
from v1.notifications.dependencies import get_notification_service
|
||||
from v1.notifications.schemas import (
|
||||
MarkAllReadResponse,
|
||||
NotificationItemResponse,
|
||||
NotificationListResponse,
|
||||
UnreadCountResponse,
|
||||
)
|
||||
from v1.notifications.service import NotificationService
|
||||
from v1.users.dependencies import get_current_user
|
||||
|
||||
router = APIRouter(prefix="/notifications", tags=["notifications"])
|
||||
|
||||
|
||||
@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),
|
||||
) -> 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,
|
||||
)
|
||||
items = []
|
||||
for item in result.items:
|
||||
items.append(
|
||||
NotificationItemResponse(
|
||||
id=str(item.id),
|
||||
notificationId=str(item.notification_id),
|
||||
type=item.type,
|
||||
title=item.title,
|
||||
body=item.body,
|
||||
payload=item.payload,
|
||||
isRead=item.is_read,
|
||||
readAt=item.read_at,
|
||||
createdAt=item.created_at,
|
||||
)
|
||||
)
|
||||
return NotificationListResponse(
|
||||
items=items,
|
||||
nextCursor=result.next_cursor.isoformat() if result.next_cursor else None,
|
||||
hasMore=result.has_more,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/unread-count", response_model=UnreadCountResponse)
|
||||
async def get_unread_count(
|
||||
service: Annotated[NotificationService, Depends(get_notification_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> UnreadCountResponse:
|
||||
count = await service.get_unread_count(user_id=current_user.id)
|
||||
return UnreadCountResponse(count=count)
|
||||
|
||||
|
||||
@router.patch("/{notification_id}/read", response_model=NotificationItemResponse)
|
||||
async def mark_notification_read(
|
||||
notification_id: str,
|
||||
service: Annotated[NotificationService, Depends(get_notification_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> 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(
|
||||
code="NOTIFICATION_NOT_FOUND",
|
||||
detail="Notification not found or not owned by current user",
|
||||
),
|
||||
)
|
||||
|
||||
item = await service.mark_read(
|
||||
user_notification_id=uid,
|
||||
user_id=current_user.id,
|
||||
)
|
||||
return NotificationItemResponse(
|
||||
id=str(item.id),
|
||||
notificationId=str(item.notification_id),
|
||||
type=item.type,
|
||||
title=item.title,
|
||||
body=item.body,
|
||||
payload=item.payload,
|
||||
isRead=item.is_read,
|
||||
readAt=item.read_at,
|
||||
createdAt=item.created_at,
|
||||
)
|
||||
|
||||
|
||||
@router.patch("/mark-all-read", response_model=MarkAllReadResponse)
|
||||
async def mark_all_read(
|
||||
service: Annotated[NotificationService, Depends(get_notification_service)],
|
||||
current_user: Annotated[CurrentUser, Depends(get_current_user)],
|
||||
) -> MarkAllReadResponse:
|
||||
updated_count = await service.mark_all_read(user_id=current_user.id)
|
||||
return MarkAllReadResponse(updatedCount=updated_count)
|
||||
Reference in New Issue
Block a user