- 后端: 新增 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 及错误码注册
16 KiB
通知系统计划
更新时间: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.pyBaseTimestampMixinSoftDeleteMixin
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 表设计
本阶段使用两张表:
notificationsuser_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 字段设计
字段:
actionrouteentity_idtaburl
字段职责:
action- 点击动作类型
- 只允许:
none、open_route、open_url
routeaction='open_route'时使用- App 内目标路由
entity_id- 可选业务对象 ID
tab- 可选子页面定位参数
urlaction='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为空
不加入以下字段:
paramsmetadatatrackingbuttonsimagebadge_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必须来自 JWTsubread和mark-all-read必须幂等- 列表查询必须联表过滤
notifications.status和notifications.deleted_at - 错误返回遵循 RFC 7807 +
code
建议列表项响应字段:
idnotification_idtypetitlebodypayloadis_readread_atcreated_at
本阶段不提供:
PATCH /seenPOST /openedDELETE /notifications/{id}/push/devices/*
8. 后端方案
8.1 新增内容
- Alembic 迁移:新增
notifications、user_notifications backend/src/models/notification.pybackend/src/models/user_notification.pybackend/src/v1/notifications/schemas.pyrepository.pyservice.pyrouter.py
- 更新
backend/src/models/__init__.py
8.2 设计约束
- 遵循
schema -> repository -> service分层 - 越权访问必须返回标准问题详情错误
- 默认按
created_at DESC返回列表 - 已读更新只允许作用于当前用户自己的通知
- 任何
JSONB字段都必须先有 Pydantic schema 和协议定义
8.3 通知写入方式
本阶段不做完整运营后台和复杂 fanout。
最小写入入口:
- 业务服务内部创建
notifications主记录 - 为目标用户写入
user_notifications - 如需调试,可使用开发环境脚本或种子数据
本阶段不引入:
- Redis outbox
- Taskiq worker
- 推送 provider SDK
- 重试链路
9. Realtime 方案
Realtime 只负责前台同步,不负责离线触达。
目标:
- App 前台打开时,新通知自动出现
- 首页 badge 自动更新
- 撤销通知自动从前台生效
事件范围:
notification_creatednotification_read_updatednotification_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/apis、data/models、data/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
列表项最小展示字段:
titlebodycreated_atis_read
交互:
- 点击通知项
- 若未读,先标记已读
- 再执行
payload.action对应跳转
- 已撤销通知
- Realtime 收到撤销事件后移除
10.7 前端状态流转
最小状态:
- 通知列表
- 未读数
最小流转:
- App 进入首页或相关模块初始化时拉未读数
- 进入通知中心时拉列表并同步未读数
- 点击单条通知时更新已读并减少未读数,再执行跳转
- 点击“全部已读”时将列表设为已读并将 badge 归零
- 收到 Realtime 事件时:
- 新增:插入列表顶部并递增未读数
- 已读:更新对应项并调整未读数
- 撤销:移除对应项并重新校正未读数
10.8 前端 Realtime 处理
通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。
建议事件模型:
NotificationCreatedEventNotificationReadUpdatedEventNotificationRevokedEvent
处理原则:
- 事件到达后先校验结构,再更新本地状态
- 本地不存在对应通知时,不崩溃;必要时触发轻量刷新
- Realtime 不替代首次 HTTP 全量拉取
10.9 页面跳转执行规则
通知点击逻辑集中处理,不散落在列表 widget 中。
建议统一入口:
handleNotificationTap(NotificationItem item)
执行顺序:
- 判断是否未读
- 若未读,调用 repository 标记已读
- 根据
payload.action执行行为 - 跳转失败时记录错误,但不回滚已读状态
行为映射:
none- 不跳转,或停留通知中心
open_route- 使用现有
Navigator.of(context).push(MaterialPageRoute(...))组织 App 内导航
- 使用现有
open_url- 使用统一外链打开能力
10.10 本阶段不新增的依赖
firebase_messagingflutter_local_notifications
是否引入 supabase_flutter 或其他 Realtime 客户端,取决于最终接入方案;在协议确认前不写死。
10.11 与现有设置项关系
profiles.settings.notification.allow_notifications 和 allow_vibration 保持现状:
- 不删除
- 不扩字段
- 不承担站内通知已读状态
11. 实施清单
- 编写协议文档
docs/protocols/notification/notification-inbox-protocol.md - 新增
notifications、user_notifications表迁移 - 实现后端通知模型、schema、repository、service、router
- 实现通知列表、未读数、单条已读、全部已读接口
- 定义并实现通知 Realtime 事件协议
- 新增 Flutter 通知 feature、通知中心页面和列表项组件
- 在
app/app.dart中接入通知 API、状态和 Realtime 订阅 - 将 Home 页通知按钮接入真实页面并展示 badge
- 完成最小测试
12. 验收标准
- 能为指定用户写入一条站内通知
- 用户能看到自己的通知列表
- 用户点击通知后可标记为已读
- “全部已读”后未读数归零
- 用户 A 不能读取或修改用户 B 的通知
- 已读接口重复调用不会报错,也不会产生脏状态
- App 前台打开时,服务端新写入的通知可自动出现在列表中
- 首页 badge 会随新增通知和已读操作自动更新
- 撤销或统一删除主通知后,用户侧列表不再展示对应通知
13. 测试要求
后端至少覆盖:
- 列表只返回当前用户数据
- 未读数统计正确
- 单条已读幂等
- 全部已读幂等
- 越权访问被拒绝
- 已撤销或已删除主通知不会出现在列表和未读统计中
Flutter 至少覆盖:
- 通知模型解析
- 未读数展示逻辑
- 列表点击后状态刷新
- Realtime 事件驱动下的列表或 badge 更新逻辑
本阶段不要求测试:
- 推送送达率
- 设备注册
- 系统级离线推送
14. 后续扩展条件
只有在真实需求出现时,才继续扩展:
14.1 扩到更多表
出现以下需求之一时,再评估扩展到三张或四张表:
- 同一通知内容批量投递给大量用户
- 需要模板复用
- 需要设备级投递状态追踪
- 需要运营后台批量发送
届时再评估是否新增:
user_push_devicesnotification_push_attempts
14.2 接入系统级离线推送
只有在确认以下需求时才接入:
- App 在后台或离线时也要触达用户
- iOS / Android 需要真正弹出系统通知
届时再补:
- 设备 token 注册
- APNs / FCM 配置
- 推送发送服务
- 失败重试和审计链路