Files
eryao/docs/plans/notification-system-plan.md
T
qzl 3f3d613d99 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 及错误码注册
2026-04-10 18:50:08 +08:00

16 KiB
Raw Blame History

通知系统计划

更新时间:2026-04-10 状态:最终执行版

1. 目标

本阶段实现最小可用的站内通知系统,满足以下能力:

  • 系统向用户投递站内通知
  • 用户在 App 内查看通知列表
  • 用户查看通知内容并标记已读
  • 首页复用现有通知按钮作为入口
  • 首页显示未读 badge,并随数据变化自动更新
  • App 前台打开时,新通知自动出现
  • 支持通知主记录的撤销和统一删除

本阶段不实现系统级离线推送。


2. 范围

2.1 In Scope

  • 站内通知 inbox
  • notifications 主表管理通知内容和生命周期
  • user_notifications 记录用户接收关系和已读状态
  • 通知列表
  • 未读数
  • 单条已读
  • 全部已读
  • 前台 Realtime 增量同步
  • 撤销和统一删除在用户侧生效

2.2 Out of Scope

  • APNs / FCM 离线推送
  • 设备 token 注册与管理
  • 推送送达率、失败重试、DLQ
  • seen/opened/provider_ack/push_state
  • 通知模板后台
  • 复杂批量 fanout 系统
  • 用户侧单条删除、归档、撤回
  • 本地通知调度

3. 现有代码基线

实现必须基于当前仓库结构:

后端:

  • 用户资料与设置接口已存在
  • 通知偏好存于 profiles.settings.notification
  • ORM 基类位于 backend/src/core/db/base.py
    • Base
    • TimestampMixin
    • SoftDeleteMixin

Flutter

  • 首页通知入口位于 apps/lib/features/home/presentation/screens/home_screen.dart
  • 当前点击行为是 featurePending
  • App 顶层状态由 apps/lib/app/app.dart 持有并下传
  • 现有数据层模式是 data/apis + data/repositories
  • 现有状态管理明确证据是 ChangeNotifier 与页面级 setState
  • 现有导航模式是 Navigator.of(context).push(MaterialPageRoute(...))
  • 现有事件流解析参考在 features/divination/data/apis/divination_api.dart::streamEvents

实现时优先复用这些模式,不引入新的全局前端架构。


4. 数据模型

4.1 表设计

本阶段使用两张表:

  • notifications
  • user_notifications

4.2 notifications

职责:

  • 管理系统通知主记录
  • 管理通知内容
  • 管理发布时间、撤销、统一删除

建议字段:

CREATE TABLE notifications (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    type VARCHAR(32) NOT NULL DEFAULT 'system',
    title TEXT NOT NULL,
    body TEXT NOT NULL,
    payload JSONB NOT NULL DEFAULT '{}'::jsonb,
    status VARCHAR(16) NOT NULL DEFAULT 'published',
    published_at TIMESTAMPTZ,
    revoked_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    deleted_at TIMESTAMPTZ
);

CREATE INDEX ix_notifications_status_created_at
    ON notifications(status, created_at DESC);

CREATE INDEX ix_notifications_published_at
    ON notifications(published_at DESC);

字段语义:

  • status='draft':草稿,未对用户生效
  • status='published':已发布
  • status='revoked':已撤销,不再对用户展示
  • deleted_at:平台侧软删除

4.3 user_notifications

职责:

  • 表示某个用户收到某条通知
  • 记录用户已读状态
  • 支撑未读数统计

建议字段:

CREATE TABLE user_notifications (
    id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
    user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE,
    notification_id UUID NOT NULL REFERENCES notifications(id) ON DELETE CASCADE,
    is_read BOOLEAN NOT NULL DEFAULT FALSE,
    read_at TIMESTAMPTZ,
    created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
    updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);

CREATE INDEX ix_user_notifications_user_created_at
    ON user_notifications(user_id, created_at DESC);

CREATE INDEX ix_user_notifications_user_unread
    ON user_notifications(user_id, is_read);

CREATE UNIQUE INDEX uq_user_notifications_user_notification
    ON user_notifications(user_id, notification_id);

4.4 ORM 约定

新模型必须继承现有 ORM 基类约定:

  • Notification(TimestampMixin, SoftDeleteMixin, Base)
  • UserNotification(TimestampMixin, Base)

说明:

  • notifications 需要平台侧软删除能力
  • user_notifications 当前不需要 deleted_at

5. JSONB 与 Schema 约束

凡是数据库字段使用 JSONB,必须先定义明确的 Pydantic schema,再允许落库。

强约束:

  • 禁止无约束 JSON 直接入库
  • 禁止先放 dict[str, object] 再补协议
  • schema 变更必须先更新协议文档,再更新后端与前端解析

当前通知方案中,这条约束直接作用于 notifications.payload

5.1 payload 职责

payload 只负责:

  • 用户点击通知后,客户端应该做什么

payload 不负责:

  • 展示文案
  • 用户状态
  • 服务端内部状态
  • 统计、权限、跟踪信息

5.2 payload 字段设计

字段:

  • action
  • route
  • entity_id
  • tab
  • url

字段职责:

  • action
    • 点击动作类型
    • 只允许:noneopen_routeopen_url
  • route
    • action='open_route' 时使用
    • App 内目标路由
  • entity_id
    • 可选业务对象 ID
  • tab
    • 可选子页面定位参数
  • url
    • action='open_url' 时使用
    • 外链地址

使用规则:

  • action='none'
    • route/entity_id/tab/url 都为空
  • action='open_route'
    • route 必填
    • entity_id/tab 可选
    • url 为空
  • action='open_url'
    • url 必填
    • route/entity_id/tab 为空

不加入以下字段:

  • params
  • metadata
  • tracking
  • buttons
  • image
  • badge_delta

5.3 Pydantic Schema

class NotificationPayloadNone(BaseModel):
    model_config = ConfigDict(extra="forbid")

    action: Literal["none"]


class NotificationPayloadRoute(BaseModel):
    model_config = ConfigDict(extra="forbid")

    action: Literal["open_route"]
    route: str = Field(max_length=200)
    entity_id: str | None = Field(default=None, max_length=64)
    tab: str | None = Field(default=None, max_length=32)


class NotificationPayloadUrl(BaseModel):
    model_config = ConfigDict(extra="forbid")

    action: Literal["open_url"]
    url: str = Field(max_length=500)


NotificationPayload = (
    NotificationPayloadNone
    | NotificationPayloadRoute
    | NotificationPayloadUrl
)

6. 生命周期语义

6.1 撤销

  • 更新 notifications.status = 'revoked'
  • 写入 revoked_at
  • 查询列表和未读数时默认不返回已撤销通知
  • 前台收到撤销事件后移除或失效本地项

6.2 统一删除

  • 更新 notifications.deleted_at
  • 查询列表和未读数时默认过滤 deleted_at IS NULL
  • 如未来需要物理清理,单独实现后台清理任务

7. API 方案

正式实现前,先补协议文档:

  • docs/protocols/notification/notification-inbox-protocol.md

本阶段接口:

方法 路径 说明
GET /api/v1/notifications 获取当前用户通知列表
GET /api/v1/notifications/unread-count 获取当前用户未读数
PATCH /api/v1/notifications/{id}/read 标记单条通知已读
PATCH /api/v1/notifications/mark-all-read 全部标记已读

约束:

  • 所有接口只作用于当前登录用户
  • user_id 必须来自 JWT sub
  • readmark-all-read 必须幂等
  • 列表查询必须联表过滤 notifications.statusnotifications.deleted_at
  • 错误返回遵循 RFC 7807 + code

建议列表项响应字段:

  • id
  • notification_id
  • type
  • title
  • body
  • payload
  • is_read
  • read_at
  • created_at

本阶段不提供:

  • PATCH /seen
  • POST /opened
  • DELETE /notifications/{id}
  • /push/devices/*

8. 后端方案

8.1 新增内容

  • Alembic 迁移:新增 notificationsuser_notifications
  • backend/src/models/notification.py
  • backend/src/models/user_notification.py
  • backend/src/v1/notifications/
    • schemas.py
    • repository.py
    • service.py
    • router.py
  • 更新 backend/src/models/__init__.py

8.2 设计约束

  • 遵循 schema -> repository -> service 分层
  • 越权访问必须返回标准问题详情错误
  • 默认按 created_at DESC 返回列表
  • 已读更新只允许作用于当前用户自己的通知
  • 任何 JSONB 字段都必须先有 Pydantic schema 和协议定义

8.3 通知写入方式

本阶段不做完整运营后台和复杂 fanout。

最小写入入口:

  1. 业务服务内部创建 notifications 主记录
  2. 为目标用户写入 user_notifications
  3. 如需调试,可使用开发环境脚本或种子数据

本阶段不引入:

  • Redis outbox
  • Taskiq worker
  • 推送 provider SDK
  • 重试链路

9. Realtime 方案

Realtime 只负责前台同步,不负责离线触达。

目标:

  • App 前台打开时,新通知自动出现
  • 首页 badge 自动更新
  • 撤销通知自动从前台生效

事件范围:

  • notification_created
  • notification_read_updated
  • notification_revoked

原则:

  • Realtime 是 HTTP 的增量补充,不替代首次全量拉取
  • 客户端首次进入页面仍先拉 HTTP 列表和未读数
  • 收到事件后只做本地增量更新
  • 只同步当前用户自己的通知事件

10. Flutter 方案

10.1 入口

复用 HomeScreen 现有通知按钮:

  • 位置不变
  • 点击后从 featurePending 改为进入通知中心
  • 右上角显示未读 badge
  • 未读数为 0 时不显示 badge 或只显示红点
  • 数量较大时显示 99+

10.2 状态承载

第一阶段优先沿用当前代码模式:

  • apps/lib/app/app.dart 中创建通知 API 和状态
  • app/app.dart 中持有通知列表与未读数
  • 通过构造参数和回调传给 HomeScreen 与通知页面

不在本计划中预设新的 Bloc/Cubit/Provider 架构。

10.3 模块结构

通知 feature 复用现有 data/apisdata/modelsdata/repositories 组织方式。

建议目录:

apps/lib/features/notifications/
├── data/
│   ├── apis/notification_api.dart
│   ├── models/notification_item.dart
│   ├── models/notification_payload.dart
│   └── repositories/notification_repository.dart
└── presentation/
    ├── screens/notification_center_screen.dart
    └── widgets/notification_list_item.dart

10.4 数据对接

前端必须先做强类型解析,再交给页面层使用。

复用现有模式:

  • API 层拿原始 JSON
  • 在 API/模型层解析为强类型对象
  • 页面层只消费模型

建议前端模型:

class NotificationItem {
  const NotificationItem({
    required this.id,
    required this.notificationId,
    required this.type,
    required this.title,
    required this.body,
    required this.payload,
    required this.isRead,
    required this.createdAt,
    this.readAt,
  });

  final String id;
  final String notificationId;
  final String type;
  final String title;
  final String body;
  final NotificationPayload payload;
  final bool isRead;
  final DateTime createdAt;
  final DateTime? readAt;
}

payload 也必须单独解析,不能在 widget 中直接读 map。

10.5 payload 的 Dart 模型

sealed class NotificationPayload {
  const NotificationPayload();
}

final class NotificationPayloadNone extends NotificationPayload {
  const NotificationPayloadNone();
}

final class NotificationPayloadRoute extends NotificationPayload {
  const NotificationPayloadRoute({
    required this.route,
    this.entityId,
    this.tab,
  });

  final String route;
  final String? entityId;
  final String? tab;
}

final class NotificationPayloadUrl extends NotificationPayload {
  const NotificationPayloadUrl({required this.url});

  final String url;
}

解析原则:

  • 后端响应 JSON 在 API 层一次性解析成强类型模型
  • 解析失败必须抛错并记录
  • 未知 action 视为协议错误

10.6 通知中心页面

页面形态:标准列表式 inbox。

页面包含:

  • 标题栏:通知
  • 右上角操作:全部已读
  • 主体:通知列表
  • 空状态
  • 下拉刷新

列表排序:

  • created_at DESC

列表项最小展示字段:

  • title
  • body
  • created_at
  • is_read

交互:

  • 点击通知项
    • 若未读,先标记已读
    • 再执行 payload.action 对应跳转
  • 已撤销通知
    • Realtime 收到撤销事件后移除

10.7 前端状态流转

最小状态:

  • 通知列表
  • 未读数

最小流转:

  1. App 进入首页或相关模块初始化时拉未读数
  2. 进入通知中心时拉列表并同步未读数
  3. 点击单条通知时更新已读并减少未读数,再执行跳转
  4. 点击“全部已读”时将列表设为已读并将 badge 归零
  5. 收到 Realtime 事件时:
    • 新增:插入列表顶部并递增未读数
    • 已读:更新对应项并调整未读数
    • 撤销:移除对应项并重新校正未读数

10.8 前端 Realtime 处理

通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。

建议事件模型:

  • NotificationCreatedEvent
  • NotificationReadUpdatedEvent
  • NotificationRevokedEvent

处理原则:

  • 事件到达后先校验结构,再更新本地状态
  • 本地不存在对应通知时,不崩溃;必要时触发轻量刷新
  • Realtime 不替代首次 HTTP 全量拉取

10.9 页面跳转执行规则

通知点击逻辑集中处理,不散落在列表 widget 中。

建议统一入口:

  • handleNotificationTap(NotificationItem item)

执行顺序:

  1. 判断是否未读
  2. 若未读,调用 repository 标记已读
  3. 根据 payload.action 执行行为
  4. 跳转失败时记录错误,但不回滚已读状态

行为映射:

  • none
    • 不跳转,或停留通知中心
  • open_route
    • 使用现有 Navigator.of(context).push(MaterialPageRoute(...)) 组织 App 内导航
  • open_url
    • 使用统一外链打开能力

10.10 本阶段不新增的依赖

  • firebase_messaging
  • flutter_local_notifications

是否引入 supabase_flutter 或其他 Realtime 客户端,取决于最终接入方案;在协议确认前不写死。

10.11 与现有设置项关系

profiles.settings.notification.allow_notificationsallow_vibration 保持现状:

  • 不删除
  • 不扩字段
  • 不承担站内通知已读状态

11. 实施清单

  1. 编写协议文档 docs/protocols/notification/notification-inbox-protocol.md
  2. 新增 notificationsuser_notifications 表迁移
  3. 实现后端通知模型、schema、repository、service、router
  4. 实现通知列表、未读数、单条已读、全部已读接口
  5. 定义并实现通知 Realtime 事件协议
  6. 新增 Flutter 通知 feature、通知中心页面和列表项组件
  7. app/app.dart 中接入通知 API、状态和 Realtime 订阅
  8. 将 Home 页通知按钮接入真实页面并展示 badge
  9. 完成最小测试

12. 验收标准

  • 能为指定用户写入一条站内通知
  • 用户能看到自己的通知列表
  • 用户点击通知后可标记为已读
  • “全部已读”后未读数归零
  • 用户 A 不能读取或修改用户 B 的通知
  • 已读接口重复调用不会报错,也不会产生脏状态
  • App 前台打开时,服务端新写入的通知可自动出现在列表中
  • 首页 badge 会随新增通知和已读操作自动更新
  • 撤销或统一删除主通知后,用户侧列表不再展示对应通知

13. 测试要求

后端至少覆盖:

  • 列表只返回当前用户数据
  • 未读数统计正确
  • 单条已读幂等
  • 全部已读幂等
  • 越权访问被拒绝
  • 已撤销或已删除主通知不会出现在列表和未读统计中

Flutter 至少覆盖:

  • 通知模型解析
  • 未读数展示逻辑
  • 列表点击后状态刷新
  • Realtime 事件驱动下的列表或 badge 更新逻辑

本阶段不要求测试:

  • 推送送达率
  • 设备注册
  • 系统级离线推送

14. 后续扩展条件

只有在真实需求出现时,才继续扩展:

14.1 扩到更多表

出现以下需求之一时,再评估扩展到三张或四张表:

  • 同一通知内容批量投递给大量用户
  • 需要模板复用
  • 需要设备级投递状态追踪
  • 需要运营后台批量发送

届时再评估是否新增:

  • user_push_devices
  • notification_push_attempts

14.2 接入系统级离线推送

只有在确认以下需求时才接入:

  • App 在后台或离线时也要触达用户
  • iOS / Android 需要真正弹出系统通知

届时再补:

  • 设备 token 注册
  • APNs / FCM 配置
  • 推送发送服务
  • 失败重试和审计链路