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:
qzl
2026-04-10 18:50:08 +08:00
parent 17ef460391
commit 3f3d613d99
28 changed files with 3481 additions and 651 deletions
+113
View File
@@ -0,0 +1,113 @@
from __future__ import annotations
from datetime import datetime
from uuid import UUID
from sqlalchemy import func, select, update
from sqlalchemy.ext.asyncio import AsyncSession
from models.notification import Notification
from models.user_notification import UserNotification
class NotificationRepository:
def __init__(self, session: AsyncSession) -> None:
self._session = session
async def list_notifications(
self,
*,
user_id: UUID,
limit: int = 20,
cursor: datetime | None = None,
) -> list[tuple[UserNotification, Notification]]:
stmt = (
select(UserNotification, Notification)
.join(Notification, UserNotification.notification_id == Notification.id)
.where(
UserNotification.user_id == user_id,
Notification.status == "published",
Notification.deleted_at.is_(None),
)
.order_by(UserNotification.created_at.desc())
.limit(limit + 1)
)
if cursor is not None:
stmt = stmt.where(UserNotification.created_at < cursor)
rows = (await self._session.execute(stmt)).all()
return [(row[0], row[1]) for row in rows]
async def get_unread_count(self, *, user_id: UUID) -> int:
stmt = (
select(func.count())
.select_from(UserNotification)
.join(Notification, UserNotification.notification_id == Notification.id)
.where(
UserNotification.user_id == user_id,
UserNotification.is_read.is_(False),
Notification.status == "published",
Notification.deleted_at.is_(None),
)
)
result = (await self._session.execute(stmt)).scalar_one()
return result
async def get_user_notification(
self,
*,
user_notification_id: UUID,
user_id: UUID,
) -> tuple[UserNotification, Notification] | None:
stmt = (
select(UserNotification, Notification)
.join(Notification, UserNotification.notification_id == Notification.id)
.where(
UserNotification.id == user_notification_id,
UserNotification.user_id == user_id,
Notification.status == "published",
Notification.deleted_at.is_(None),
)
)
row = (await self._session.execute(stmt)).first()
if row is None:
return None
return (row[0], row[1])
async def mark_read(self, *, user_notification_id: UUID, user_id: UUID) -> bool:
stmt = select(UserNotification).where(
UserNotification.id == user_notification_id,
UserNotification.user_id == user_id,
UserNotification.is_read.is_(False),
)
un = (await self._session.execute(stmt)).scalar_one_or_none()
if un is None:
return False
un.is_read = True
un.read_at = datetime.now()
await self._session.flush()
return True
async def mark_all_read(self, *, user_id: UUID) -> int:
un_ids_stmt = (
select(UserNotification.id)
.join(Notification, UserNotification.notification_id == Notification.id)
.where(
UserNotification.user_id == user_id,
UserNotification.is_read.is_(False),
Notification.status == "published",
Notification.deleted_at.is_(None),
)
)
un_ids = list((await self._session.execute(un_ids_stmt)).scalars().all())
if not un_ids:
return 0
count = len(un_ids)
stmt = (
update(UserNotification)
.where(UserNotification.id.in_(un_ids))
.values(is_read=True, read_at=func.now())
)
await self._session.execute(stmt)
await self._session.flush()
return count