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

709 lines
16 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 通知系统计划
> 更新时间: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`
职责:
- 管理系统通知主记录
- 管理通知内容
- 管理发布时间、撤销、统一删除
建议字段:
```sql
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`
职责:
- 表示某个用户收到某条通知
- 记录用户已读状态
- 支撑未读数统计
建议字段:
```sql
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`
- 点击动作类型
- 只允许:`none``open_route``open_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
```python
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`
- `read``mark-all-read` 必须幂等
- 列表查询必须联表过滤 `notifications.status``notifications.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 迁移:新增 `notifications``user_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/apis``data/models``data/repositories` 组织方式。
建议目录:
```text
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/模型层解析为强类型对象
- 页面层只消费模型
建议前端模型:
```dart
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 模型
```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_notifications``allow_vibration` 保持现状:
- 不删除
- 不扩字段
- 不承担站内通知已读状态
---
## 11. 实施清单
1. 编写协议文档 `docs/protocols/notification/notification-inbox-protocol.md`
2. 新增 `notifications``user_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 配置
- 推送发送服务
- 失败重试和审计链路