# 通知系统计划 > 更新时间: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 配置 - 推送发送服务 - 失败重试和审计链路