"""消息通知中心 endpoint(PRD《消息通知中心》)。 路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。 GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消) GET /unread-count 未读总数(首页铃铛角标) POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零) ⚠️ 虚拟数据阶段:数据来自 repositories/notification_mock.py 内存 mock(重启复位, 每个用户一套覆盖全部 13 种类型的固定样例)。接真实数据时只换 repository 实现。 ⚠️ 字段命名:本组接口对外为 **camelCase**(sentAt / isRead / pageSize…,PRD 前端契约), 详见 schemas/notification.py 顶部说明。 """ from __future__ import annotations import logging from fastapi import APIRouter, HTTPException, Query from app.api.deps import CurrentUser from app.core import notification_catalog as catalog from app.repositories import notification_mock as mock_repo from app.schemas.notification import ( InfoRow, MarkReadOut, MarkReadRequest, NotificationItem, NotificationListOut, UnreadCountOut, ) logger = logging.getLogger("shagua.notifications") router = APIRouter(prefix="/api/v1/notifications", tags=["notifications"]) def _to_item(n: mock_repo.MockNotification) -> NotificationItem: """mock 记录 + 类型静态目录 → 接口出参。""" ntype = catalog.get_type(n.type_key) return NotificationItem( id=n.id, category=ntype.category, category_label=catalog.category_label(ntype.category), type=ntype.key, card_style=ntype.card_style, title=ntype.card_title, coins=n.coins, cash_cents=n.cash_cents, cash_yuan=mock_repo.cash_yuan(n.cash_cents), info_rows=[InfoRow(**row) for row in n.info_rows], action_text=ntype.action_text, extra=n.extra, sent_at=n.sent_at, is_read=n.is_read, ) @router.get("", response_model=NotificationListOut, summary="消息列表(分页)") def list_notifications( user: CurrentUser, page: int = Query(default=1, ge=1, description="页码,1 起"), page_size: int = Query( default=20, ge=1, le=100, alias="pageSize", description="每页条数,默认 20,最大 100" ), ) -> NotificationListOut: """通知中心消息列表。 - 排序服务端已做好:**全列表按时间倒序**(最新在前,不做分类分组;PRD §1 的 "按分类分组"为笔误,已与需求方确认取消),前端按返回顺序渲染即可。 - 每条的字段构成与各版式说明见 NotificationItem schema。 - 响应同时带 unreadCount,进页面时可顺手刷新角标。 - 虚拟数据阶段:首次请求生成一套固定样例(13 类型全覆盖 + 今天/昨天/当年/跨年 四种时间 + 已读未读混合),已读状态在服务端内存维护,重启服务复位。 """ items, total, unread = mock_repo.list_notifications(user.id, page=page, page_size=page_size) return NotificationListOut( items=[_to_item(n) for n in items], page=page, page_size=page_size, total=total, has_more=page * page_size < total, unread_count=unread, ) @router.get("/unread-count", response_model=UnreadCountOut, summary="未读总数(铃铛角标)") def get_unread_count(user: CurrentUser) -> UnreadCountOut: """首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。 - count:精确未读条数; - badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。 """ count = mock_repo.unread_count(user.id) badge = None if count == 0 else ("99+" if count > 99 else str(count)) return UnreadCountOut(count=count, badge_text=badge) @router.post("/read", response_model=MarkReadOut, summary="标记已读(单条/多条/全量)") def mark_read(req: MarkReadRequest, user: CurrentUser) -> MarkReadOut: """红点消除(PRD §4),两种调用模式: 1. `{"ids": [90001]}` —— 点击某张消息卡片(无论点击后是跳转/弹窗/无动作都算已读); 用户点击 push 直达落地页时,客户端也用它把对应站内消息同步置读(push extras 里带 notificationId); 2. `{"all": true}` —— 进入通知中心自动清零(只是浏览列表就消红点,无需逐条点击)。 幂等:不存在或已读的 id 忽略;重复调用 markedCount 为 0、不报错。 响应带 unreadCount(处理后剩余未读),可直接刷新铃铛角标。 """ if not req.all and not req.ids: raise HTTPException(status_code=400, detail="ids 与 all 至少传一个:{ids:[...]} 或 {all:true}") marked, unread = mock_repo.mark_read(user.id, ids=req.ids, mark_all=req.all) logger.info( "notifications read user_id=%d mode=%s marked=%d unread_left=%d", user.id, "all" if req.all else f"ids×{len(req.ids or [])}", marked, unread, ) return MarkReadOut(ok=True, marked_count=marked, unread_count=unread)