a8ac5dc0c7
- GET /api/v1/notifications(分页,时间倒序不分组)/unread-count /read(ids|all),
对外 camelCase(PRD 前端契约);数据为内存 mock(13 类型全覆盖,重启复位),
接真实数据只换 repositories/notification_mock.py
- vendor_push 补华为 Push Kit(OAuth+messages:send),抽通用 send_notification
(任意文案+extras+mock 模式),send_accessibility_disabled 改薄封装行为不变
- 新增 /api/v1/push/{vendors,templates,test}:凭据状态/13 类 PRD 文案模板/测试发送
(默认 mock,mock=false 真发,可联动插站内 mock 消息闭环验证已读)
- .env.example 补 HUAWEI_* 键;docs/api 新增 notifications.md + push-vendor-test.md
- alembic merge 1a924c274fce 收拢 direct_vendor_push_fields/feedback_type_reply
双 head,恢复 upgrade head 可用
- tests: test_notifications + test_push_center 共 35 例
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
121 lines
5.1 KiB
Python
121 lines
5.1 KiB
Python
"""消息通知中心 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)
|