"""消息通知中心 endpoint(PRD《消息通知中心》)。 路由前缀 `/api/v1/notifications`,需 Bearer 鉴权(消息按用户隔离)。 GET / 消息列表(分页;全列表时间倒序,不分组——PRD 原文的分组已取消) GET /unread-count 未读总数(首页铃铛角标) POST /read 标记已读({ids:[...]} 单条/多条 或 {all:true} 全量清零) 数据落库 `notification` 表(repositories/notification.py,按用户隔离)。业务事件(奖励过期、 提现回执、反馈回复……)调 `create_notification` 下发;未接入业务前列表为空,可用 `/api/v1/push/test` 的 createNotification 造联调数据。 ⚠️ 字段命名:本组接口对外为 **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, DbSession from app.core import notification_catalog as catalog from app.models.notification import Notification from app.repositories import notification as notif_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: Notification) -> NotificationItem: """通知行 + 类型静态目录 → 接口出参。""" ntype = catalog.get_type(n.type) 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=notif_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=notif_repo.as_cst(n.sent_at), is_read=n.is_read, ) @router.get("", response_model=NotificationListOut, summary="消息列表(分页)") def list_notifications( user: CurrentUser, db: DbSession, 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,进页面时可顺手刷新角标。 - 无消息时返回空列表(total=0);数据由业务事件下发,联调可用 /push/test 造。 """ items, total, unread = notif_repo.list_notifications( db, 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, db: DbSession) -> UnreadCountOut: """首页铃铛角标数据源。刷新时机(PRD §4):进入首页时、从通知中心/其他页面返回首页时。 - count:精确未读条数; - badgeText:直接可展示的角标文案——超过 99 返回 "99+",等于 0 返回 null(隐藏整个角标)。 """ count = notif_repo.unread_count(db, 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, db: DbSession) -> 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 = notif_repo.mark_read(db, 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)