Files
eryao/docs/plans/notification-system-plan.md
T

709 lines
16 KiB
Markdown
Raw Normal View History

2026-04-10 18:50:08 +08:00
# 通知系统计划
2026-04-10 18:50:08 +08:00
> 更新时间:2026-04-10
> 状态:最终执行版
2026-04-10 18:50:08 +08:00
## 1. 目标
2026-04-10 18:50:08 +08:00
本阶段实现最小可用的站内通知系统,满足以下能力:
2026-04-10 18:50:08 +08:00
- 系统向用户投递站内通知
- 用户在 App 内查看通知列表
- 用户查看通知内容并标记已读
- 首页复用现有通知按钮作为入口
- 首页显示未读 badge,并随数据变化自动更新
- App 前台打开时,新通知自动出现
- 支持通知主记录的撤销和统一删除
2026-04-10 18:50:08 +08:00
本阶段不实现系统级离线推送。
---
2026-04-10 18:50:08 +08:00
## 2. 范围
2026-04-10 18:50:08 +08:00
### 2.1 In Scope
2026-04-10 18:50:08 +08:00
- 站内通知 inbox
- `notifications` 主表管理通知内容和生命周期
- `user_notifications` 记录用户接收关系和已读状态
- 通知列表
- 未读数
- 单条已读
- 全部已读
- 前台 Realtime 增量同步
- 撤销和统一删除在用户侧生效
2026-04-10 18:50:08 +08:00
### 2.2 Out of Scope
2026-04-10 18:50:08 +08:00
- APNs / FCM 离线推送
- 设备 token 注册与管理
- 推送送达率、失败重试、DLQ
- `seen/opened/provider_ack/push_state`
- 通知模板后台
- 复杂批量 fanout 系统
- 用户侧单条删除、归档、撤回
- 本地通知调度
2026-04-10 18:50:08 +08:00
---
2026-04-10 18:50:08 +08:00
## 3. 现有代码基线
2026-04-10 18:50:08 +08:00
实现必须基于当前仓库结构:
2026-04-10 18:50:08 +08:00
后端:
2026-04-10 18:50:08 +08:00
- 用户资料与设置接口已存在
- 通知偏好存于 `profiles.settings.notification`
- ORM 基类位于 `backend/src/core/db/base.py`
- `Base`
- `TimestampMixin`
- `SoftDeleteMixin`
2026-04-10 18:50:08 +08:00
Flutter
2026-04-10 18:50:08 +08:00
- 首页通知入口位于 `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`
2026-04-10 18:50:08 +08:00
实现时优先复用这些模式,不引入新的全局前端架构。
---
2026-04-10 18:50:08 +08:00
## 4. 数据模型
2026-04-10 18:50:08 +08:00
### 4.1 表设计
2026-04-10 18:50:08 +08:00
本阶段使用两张表:
2026-04-10 18:50:08 +08:00
- `notifications`
- `user_notifications`
2026-04-10 18:50:08 +08:00
### 4.2 `notifications`
2026-04-10 18:50:08 +08:00
职责:
2026-04-10 18:50:08 +08:00
- 管理系统通知主记录
- 管理通知内容
- 管理发布时间、撤销、统一删除
2026-04-10 18:50:08 +08:00
建议字段:
```sql
CREATE TABLE notifications (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
2026-04-10 18:50:08 +08:00
type VARCHAR(32) NOT NULL DEFAULT 'system',
title TEXT NOT NULL,
body TEXT NOT NULL,
2026-04-10 18:50:08 +08:00
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
);
2026-04-10 18:50:08 +08:00
CREATE INDEX ix_notifications_status_created_at
ON notifications(status, created_at DESC);
CREATE INDEX ix_notifications_published_at
ON notifications(published_at DESC);
```
2026-04-10 18:50:08 +08:00
字段语义:
- `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,
2026-04-10 18:50:08 +08:00
is_read BOOLEAN NOT NULL DEFAULT FALSE,
read_at TIMESTAMPTZ,
2026-04-10 18:50:08 +08:00
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
2026-04-10 18:50:08 +08:00
CREATE INDEX ix_user_notifications_user_created_at
ON user_notifications(user_id, created_at DESC);
2026-04-10 18:50:08 +08:00
CREATE INDEX ix_user_notifications_user_unread
ON user_notifications(user_id, is_read);
2026-04-10 18:50:08 +08:00
CREATE UNIQUE INDEX uq_user_notifications_user_notification
ON user_notifications(user_id, notification_id);
```
2026-04-10 18:50:08 +08:00
### 4.4 ORM 约定
2026-04-10 18:50:08 +08:00
新模型必须继承现有 ORM 基类约定:
2026-04-10 18:50:08 +08:00
- `Notification(TimestampMixin, SoftDeleteMixin, Base)`
- `UserNotification(TimestampMixin, Base)`
2026-04-10 18:50:08 +08:00
说明:
2026-04-10 18:50:08 +08:00
- `notifications` 需要平台侧软删除能力
- `user_notifications` 当前不需要 `deleted_at`
2026-04-10 18:50:08 +08:00
---
2026-04-10 18:50:08 +08:00
## 5. JSONB 与 Schema 约束
2026-04-10 18:50:08 +08:00
凡是数据库字段使用 `JSONB`,必须先定义明确的 Pydantic schema,再允许落库。
2026-04-10 18:50:08 +08:00
强约束:
2026-04-10 18:50:08 +08:00
- 禁止无约束 JSON 直接入库
- 禁止先放 `dict[str, object]` 再补协议
- schema 变更必须先更新协议文档,再更新后端与前端解析
2026-04-10 18:50:08 +08:00
当前通知方案中,这条约束直接作用于 `notifications.payload`
2026-04-10 18:50:08 +08:00
### 5.1 `payload` 职责
2026-04-10 18:50:08 +08:00
`payload` 只负责:
2026-04-10 18:50:08 +08:00
- 用户点击通知后,客户端应该做什么
2026-04-10 18:50:08 +08:00
`payload` 不负责:
2026-04-10 18:50:08 +08:00
- 展示文案
- 用户状态
- 服务端内部状态
- 统计、权限、跟踪信息
2026-04-10 18:50:08 +08:00
### 5.2 `payload` 字段设计
2026-04-10 18:50:08 +08:00
字段:
2026-04-10 18:50:08 +08:00
- `action`
- `route`
- `entity_id`
- `tab`
- `url`
2026-04-10 18:50:08 +08:00
字段职责:
2026-04-10 18:50:08 +08:00
- `action`
- 点击动作类型
- 只允许:`none``open_route``open_url`
- `route`
- `action='open_route'` 时使用
- App 内目标路由
- `entity_id`
- 可选业务对象 ID
- `tab`
- 可选子页面定位参数
- `url`
- `action='open_url'` 时使用
- 外链地址
2026-04-10 18:50:08 +08:00
使用规则:
2026-04-10 18:50:08 +08:00
- `action='none'`
- `route/entity_id/tab/url` 都为空
- `action='open_route'`
- `route` 必填
- `entity_id/tab` 可选
- `url` 为空
- `action='open_url'`
- `url` 必填
- `route/entity_id/tab` 为空
2026-04-10 18:50:08 +08:00
不加入以下字段:
2026-04-10 18:50:08 +08:00
- `params`
- `metadata`
- `tracking`
- `buttons`
- `image`
- `badge_delta`
2026-04-10 18:50:08 +08:00
### 5.3 Pydantic Schema
2026-04-10 18:50:08 +08:00
```python
class NotificationPayloadNone(BaseModel):
model_config = ConfigDict(extra="forbid")
2026-04-10 18:50:08 +08:00
action: Literal["none"]
2026-04-10 18:50:08 +08:00
class NotificationPayloadRoute(BaseModel):
model_config = ConfigDict(extra="forbid")
2026-04-10 18:50:08 +08:00
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)
2026-04-10 18:50:08 +08:00
class NotificationPayloadUrl(BaseModel):
model_config = ConfigDict(extra="forbid")
2026-04-10 18:50:08 +08:00
action: Literal["open_url"]
url: str = Field(max_length=500)
2026-04-10 18:50:08 +08:00
NotificationPayload = (
NotificationPayloadNone
| NotificationPayloadRoute
| NotificationPayloadUrl
)
```
2026-04-10 18:50:08 +08:00
---
2026-04-10 18:50:08 +08:00
## 6. 生命周期语义
2026-04-10 18:50:08 +08:00
### 6.1 撤销
2026-04-10 18:50:08 +08:00
- 更新 `notifications.status = 'revoked'`
- 写入 `revoked_at`
- 查询列表和未读数时默认不返回已撤销通知
- 前台收到撤销事件后移除或失效本地项
2026-04-10 18:50:08 +08:00
### 6.2 统一删除
2026-04-10 18:50:08 +08:00
- 更新 `notifications.deleted_at`
- 查询列表和未读数时默认过滤 `deleted_at IS NULL`
- 如未来需要物理清理,单独实现后台清理任务
2026-04-10 18:50:08 +08:00
---
2026-04-10 18:50:08 +08:00
## 7. API 方案
2026-04-10 18:50:08 +08:00
正式实现前,先补协议文档:
2026-04-10 18:50:08 +08:00
- `docs/protocols/notification/notification-inbox-protocol.md`
2026-04-10 18:50:08 +08:00
本阶段接口:
2026-04-10 18:50:08 +08:00
| 方法 | 路径 | 说明 |
|------|------|------|
| GET | `/api/v1/notifications` | 获取当前用户通知列表 |
| GET | `/api/v1/notifications/unread-count` | 获取当前用户未读数 |
| PATCH | `/api/v1/notifications/{id}/read` | 标记单条通知已读 |
| PATCH | `/api/v1/notifications/mark-all-read` | 全部标记已读 |
2026-04-10 18:50:08 +08:00
约束:
2026-04-10 18:50:08 +08:00
- 所有接口只作用于当前登录用户
- `user_id` 必须来自 JWT `sub`
- `read``mark-all-read` 必须幂等
- 列表查询必须联表过滤 `notifications.status``notifications.deleted_at`
- 错误返回遵循 RFC 7807 + `code`
2026-04-10 18:50:08 +08:00
建议列表项响应字段:
2026-04-10 18:50:08 +08:00
- `id`
- `notification_id`
- `type`
- `title`
- `body`
- `payload`
- `is_read`
- `read_at`
- `created_at`
2026-04-10 18:50:08 +08:00
本阶段不提供:
2026-04-10 18:50:08 +08:00
- `PATCH /seen`
- `POST /opened`
- `DELETE /notifications/{id}`
- `/push/devices/*`
---
2026-04-10 18:50:08 +08:00
## 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 设计约束
2026-04-10 18:50:08 +08:00
- 遵循 `schema -> repository -> service` 分层
- 越权访问必须返回标准问题详情错误
- 默认按 `created_at DESC` 返回列表
- 已读更新只允许作用于当前用户自己的通知
- 任何 `JSONB` 字段都必须先有 Pydantic schema 和协议定义
2026-04-10 18:50:08 +08:00
### 8.3 通知写入方式
2026-04-10 18:50:08 +08:00
本阶段不做完整运营后台和复杂 fanout。
2026-04-10 18:50:08 +08:00
最小写入入口:
2026-04-10 18:50:08 +08:00
1. 业务服务内部创建 `notifications` 主记录
2. 为目标用户写入 `user_notifications`
3. 如需调试,可使用开发环境脚本或种子数据
2026-04-10 18:50:08 +08:00
本阶段不引入:
2026-04-10 18:50:08 +08:00
- Redis outbox
- Taskiq worker
- 推送 provider SDK
- 重试链路
---
2026-04-10 18:50:08 +08:00
## 9. Realtime 方案
2026-04-10 18:50:08 +08:00
Realtime 只负责前台同步,不负责离线触达。
2026-04-10 18:50:08 +08:00
目标:
2026-04-10 18:50:08 +08:00
- App 前台打开时,新通知自动出现
- 首页 badge 自动更新
- 撤销通知自动从前台生效
2026-04-10 18:50:08 +08:00
事件范围:
2026-04-10 18:50:08 +08:00
- `notification_created`
- `notification_read_updated`
- `notification_revoked`
2026-04-10 18:50:08 +08:00
原则:
- Realtime 是 HTTP 的增量补充,不替代首次全量拉取
- 客户端首次进入页面仍先拉 HTTP 列表和未读数
- 收到事件后只做本地增量更新
- 只同步当前用户自己的通知事件
---
2026-04-10 18:50:08 +08:00
## 10. Flutter 方案
2026-04-10 18:50:08 +08:00
### 10.1 入口
2026-04-10 18:50:08 +08:00
复用 `HomeScreen` 现有通知按钮:
2026-04-10 18:50:08 +08:00
- 位置不变
- 点击后从 `featurePending` 改为进入通知中心
- 右上角显示未读 badge
- 未读数为 `0` 时不显示 badge 或只显示红点
- 数量较大时显示 `99+`
2026-04-10 18:50:08 +08:00
### 10.2 状态承载
2026-04-10 18:50:08 +08:00
第一阶段优先沿用当前代码模式:
2026-04-10 18:50:08 +08:00
-`apps/lib/app/app.dart` 中创建通知 API 和状态
-`app/app.dart` 中持有通知列表与未读数
- 通过构造参数和回调传给 `HomeScreen` 与通知页面
2026-04-10 18:50:08 +08:00
不在本计划中预设新的 Bloc/Cubit/Provider 架构。
2026-04-10 18:50:08 +08:00
### 10.3 模块结构
2026-04-10 18:50:08 +08:00
通知 feature 复用现有 `data/apis``data/models``data/repositories` 组织方式。
2026-04-10 18:50:08 +08:00
建议目录:
2026-04-10 18:50:08 +08:00
```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
```
2026-04-10 18:50:08 +08:00
### 10.4 数据对接
前端必须先做强类型解析,再交给页面层使用。
复用现有模式:
2026-04-10 18:50:08 +08:00
- 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;
}
```
2026-04-10 18:50:08 +08:00
`payload` 也必须单独解析,不能在 widget 中直接读 map。
2026-04-10 18:50:08 +08:00
### 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;
}
```
2026-04-10 18:50:08 +08:00
解析原则:
2026-04-10 18:50:08 +08:00
- 后端响应 JSON 在 API 层一次性解析成强类型模型
- 解析失败必须抛错并记录
- 未知 `action` 视为协议错误
2026-04-10 18:50:08 +08:00
### 10.6 通知中心页面
2026-04-10 18:50:08 +08:00
页面形态:标准列表式 inbox。
2026-04-10 18:50:08 +08:00
页面包含:
2026-04-10 18:50:08 +08:00
- 标题栏:`通知`
- 右上角操作:`全部已读`
- 主体:通知列表
- 空状态
- 下拉刷新
列表排序:
- `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 处理
2026-04-10 18:50:08 +08:00
通知 Realtime 沿用当前仓库已有的“事件流 -> 解析 -> 强类型对象 -> 状态更新”思路。
2026-04-10 18:50:08 +08:00
建议事件模型:
2026-04-10 18:50:08 +08:00
- `NotificationCreatedEvent`
- `NotificationReadUpdatedEvent`
- `NotificationRevokedEvent`
2026-04-10 18:50:08 +08:00
处理原则:
- 事件到达后先校验结构,再更新本地状态
- 本地不存在对应通知时,不崩溃;必要时触发轻量刷新
- 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` 保持现状:
- 不删除
- 不扩字段
- 不承担站内通知已读状态
---
2026-04-10 18:50:08 +08:00
## 11. 实施清单
2026-04-10 18:50:08 +08:00
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. 完成最小测试
2026-04-10 18:50:08 +08:00
---
## 12. 验收标准
2026-04-10 18:50:08 +08:00
- [ ] 能为指定用户写入一条站内通知
- [ ] 用户能看到自己的通知列表
- [ ] 用户点击通知后可标记为已读
- [ ] “全部已读”后未读数归零
- [ ] 用户 A 不能读取或修改用户 B 的通知
- [ ] 已读接口重复调用不会报错,也不会产生脏状态
- [ ] App 前台打开时,服务端新写入的通知可自动出现在列表中
- [ ] 首页 badge 会随新增通知和已读操作自动更新
- [ ] 撤销或统一删除主通知后,用户侧列表不再展示对应通知
---
2026-04-10 18:50:08 +08:00
## 13. 测试要求
2026-04-10 18:50:08 +08:00
后端至少覆盖:
2026-04-10 18:50:08 +08:00
- 列表只返回当前用户数据
- 未读数统计正确
- 单条已读幂等
- 全部已读幂等
- 越权访问被拒绝
- 已撤销或已删除主通知不会出现在列表和未读统计中
2026-04-10 18:50:08 +08:00
Flutter 至少覆盖:
2026-04-10 18:50:08 +08:00
- 通知模型解析
- 未读数展示逻辑
- 列表点击后状态刷新
- Realtime 事件驱动下的列表或 badge 更新逻辑
2026-04-10 18:50:08 +08:00
本阶段不要求测试:
2026-04-10 18:50:08 +08:00
- 推送送达率
- 设备注册
- 系统级离线推送
---
2026-04-10 18:50:08 +08:00
## 14. 后续扩展条件
只有在真实需求出现时,才继续扩展:
### 14.1 扩到更多表
出现以下需求之一时,再评估扩展到三张或四张表:
- 同一通知内容批量投递给大量用户
- 需要模板复用
- 需要设备级投递状态追踪
- 需要运营后台批量发送
届时再评估是否新增:
- `user_push_devices`
- `notification_push_attempts`
### 14.2 接入系统级离线推送
只有在确认以下需求时才接入:
- App 在后台或离线时也要触达用户
- iOS / Android 需要真正弹出系统通知
届时再补:
2026-04-10 18:50:08 +08:00
- 设备 token 注册
- APNs / FCM 配置
- 推送发送服务
- 失败重试和审计链路