# 通知系统实现方案 > 创建时间:2026-04-10 > 状态:已评审 ## 1. 需求理解 ### 1.1 核心问题 当前项目(觅爻签问 App)没有通知系统,需要从零建设。核心需求包括: - 通知中心(通知列表/收件箱) - 用户通知存储与读取 - 已读/已看状态追踪 - 系统推送触达(APNs/FCM) - 前台实时通知同步 - 新版本通知、活动通知等业务通知类型 ### 1.2 通知类型区分 | 类型 | 说明 | 当前状态 | |------|------|----------| | 应用内通知记录 | 存储在 DB 的通知数据 | **不存在** | | 系统推送通知 | 通过 APNs/FCM 发送 | **不存在** | | 前台实时同步 | App 打开时实时拉取 | **不存在** | | 本地通知 | App 本地触发的通知 | **不存在** | | 已看状态 | 用户是否打开过通知详情 | **未设计** | | 已读状态 | 用户是否标记为已读 | **未设计** | | 推送触达 | 消息是否成功送达设备 | **未设计** | ### 1.3 系统边界 ``` ┌─────────────────────────────────────────────────────────┐ │ Flutter App │ │ ┌──────────┐ ┌──────────┐ ┌──────────┐ ┌─────────┐ │ │ │ 本地通知 │ │ 推送接收 │ │ 通知中心 │ │ Badge │ │ │ └──────────┘ └──────────┘ └──────────┘ └─────────┘ │ └───────────────┬─────────────────────┬───────────────────┘ │ │ │ REST API │ Supabase Realtime ▼ ▼ ┌───────────────────────────────────────────────────────────┐ │ Backend (FastAPI) │ │ ┌────────────┐ ┌────────────┐ ┌────────────────────┐ │ │ │ 通知写入 │ │ Fanout │ │ 推送发送 (APNs/FCM)│ │ │ └────────────┘ └────────────┘ └────────────────────┘ │ └───────────────┬─────────────────────┬───────────────────────┘ │ │ ▼ ▼ ┌─────────────────────────┐ ┌─────────────────────────────┐ │ PostgreSQL (Supabase) │ │ Redis / Supabase │ │ ┌──────────────────┐ │ │ Realtime │ │ │ notifications │ │ │ ┌─────────────────────┐ │ │ │ notification_ │ │ │ │ user:{id}:notif:new │ │ │ │ receipts │ │ │ └─────────────────────┘ │ │ │ user_push_ │ │ └─────────────────────────────┘ │ │ devices │ │ │ └──────────────────┘ │ └─────────────────────────┘ │ ▼ ┌─────────────────────────┐ │ Push Provider │ │ (APNs / FCM / 备用) │ └─────────────────────────┘ ``` --- ## 2. 现有代码调研结果 ### 2.1 已发现的相关模块 #### Flutter 端 | 路径 | 说明 | |------|------| | `apps/lib/features/settings/presentation/screens/privacy_notification_settings_screen.dart` | 通知设置页面(占位 UI) | | `apps/lib/features/settings/data/models/profile_settings.dart` | `NotificationSettings`(`allowNotifications`, `allowVibration`) | | `apps/lib/features/home/presentation/screens/home_screen.dart:204` | 通知图标(点击显示 `featurePending`) | #### 后端 | 路径 | 说明 | |------|------| | `backend/src/schemas/shared/user.py:44` | `NotificationSettings` schema | | `backend/src/v1/users/service.py` | 用户服务(含 settings 更新) | | `backend/src/v1/users/router.py` | 用户路由 | | `backend/src/services/base/supabase.py` | Supabase 服务封装 | | `backend/src/core/config/settings.py` | 配置管理 | #### 数据库迁移 | 文件 | 说明 | |------|------| | `backend/alembic/versions/20260403_0002_user_points_chat_schema.py` | profiles, sessions, messages 表 | | `backend/alembic/versions/20260407_0001_update_notification_settings.py` | 通知设置默认值 | ### 2.2 已有能力 1. **Profile + Settings 系统**:`profiles.settings` 是 JSONB,可扩展存储通知偏好 2. **用户认证**:Supabase Auth 完整 3. **数据库迁移框架**:Alembic 已建立 4. **后台任务**:Taskiq (基于 Redis) 5. **日志系统**:已集成 structlog 6. **API 模式**:Pydantic schemas, RFC7807 错误格式 ### 2.3 当前缺口 | 缺口 | 说明 | |------|------| | 通知数据模型 | 无 `notifications` 表 | | 推送设备管理 | 无 `user_push_devices` 表 | | 通知状态追踪 | 无 `user_notifications` 状态字段体系 | | 推送服务集成 | 无 APNs/FCM/备用推送集成 | | Flutter 通知中心 | 无通知列表页面 | | Flutter 推送接收 | 无 firebase_messaging 等依赖 | | Supabase Realtime | 未用于通知同步 | | 未读数 Badge | 未实现 | ### 2.4 潜在冲突或风险 1. **AGENTS.md 约束**:`apps/AGENTS.md` 明确要求通知相关代码放在 `core/notification/` 和 `shared/widgets/notification/`,需要遵循 2. **现有 Settings 结构**:`NotificationSettings` 只有两个布尔字段,未来需要扩展 3. **数据库 JSONB 查询**:`profiles.settings` 使用 GIN 索引,但通知列表不适合放 JSONB --- ## 3. 当前架构判断 ### 3.1 推荐架构:混合推送 + 实时同步(Broadcast 主通道) **推荐方案:Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)** **原因**: 1. App 打开时(前台):使用 Broadcast 推送状态变化,避免直接依赖 `postgres_changes` 在高并发下的 RLS 扫描压力 2. App 关闭时(后台/离线):通过 APNs/FCM 触达设备 3. 两条链路统一写入 `user_notifications`,状态由服务端收敛,客户端只上报回执 **不适合当前项目的方案**: | 方案 | 不适合原因 | |------|------------| | 纯轮询 | 电量/服务器压力大,不优雅 | | WebSocket 直连 | 需要自己维护连接,复杂度高 | | 仅本地通知 | 无法触达离线用户 | | 仅 APNs/FCM | 无法查看历史通知列表 | ### 3.2 职责划分 | 组件 | 职责 | |------|------| | `notifications` 表 | 存储通知内容,永久记录 | | `user_notifications` 表 | 追踪每个用户对每条通知的状态 | | `notification_push_attempts` 表 | 追踪推送每次尝试和失败原因 | | `user_push_devices` 表 | 存储设备 token | | Supabase Realtime | 前台实时通知同步 | | APNs/FCM | 后台/离线推送 | --- ## 4. 推荐实现方案 ### 4.1 数据模型设计 #### 4.1.1 `notifications` 表 ```sql CREATE TABLE notifications ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), type VARCHAR(32) NOT NULL, -- 'system'|'activity'|'version'|'social' priority SMALLINT DEFAULT 0, -- 0=normal, 1=high title TEXT NOT NULL, body TEXT NOT NULL, data JSONB, -- 透传数据(deeplink 等) action_url TEXT, expires_at TIMESTAMPTZ, -- 过期时间,NULL=永不过期 created_at TIMESTAMPTZ DEFAULT now(), deleted_at TIMESTAMPTZ ); -- 索引 CREATE INDEX ix_notifications_type ON notifications(type); CREATE INDEX ix_notifications_created_at ON notifications(created_at DESC); ``` #### 4.1.2 `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, dedupe_key TEXT NOT NULL, -- 幂等键(建议: campaign_id:user_id 或业务事件ID) -- 状态字段 is_read BOOLEAN DEFAULT FALSE, -- 已读(点击进入详情) is_seen BOOLEAN DEFAULT FALSE, -- 已看(列表中曝光) is_opened BOOLEAN DEFAULT FALSE, -- 从推送打开(客户端回执) read_at TIMESTAMPTZ, seen_at TIMESTAMPTZ, opened_at TIMESTAMPTZ, -- 推送状态(服务端可验证状态,不直接使用 delivered 语义) push_state SMALLINT DEFAULT 0, -- 0=queued, 1=sent, 2=provider_ack, 3=failed push_provider VARCHAR(16), -- apns|fcm push_error_code TEXT, push_sent_at TIMESTAMPTZ, push_provider_ack_at TIMESTAMPTZ, created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), deleted_at TIMESTAMPTZ, UNIQUE(user_id, dedupe_key) ); -- 索引 CREATE INDEX ix_user_notifications_user_id ON user_notifications(user_id); CREATE INDEX ix_user_notifications_user_unread ON user_notifications(user_id, is_read) WHERE deleted_at IS NULL; CREATE INDEX ix_user_notifications_created_at ON user_notifications(created_at DESC); CREATE INDEX ix_user_notifications_user_seen ON user_notifications(user_id, is_seen) WHERE deleted_at IS NULL; CREATE INDEX ix_user_notifications_push_state ON user_notifications(push_state) WHERE deleted_at IS NULL; ``` #### 4.1.3 `user_push_devices` 表 ```sql CREATE TABLE user_push_devices ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES auth.users(id) ON DELETE CASCADE, device_token TEXT NOT NULL, -- APNs/FCM token device_type VARCHAR(16) NOT NULL, -- 'ios'|'android' push_provider VARCHAR(32) NOT NULL, -- 'apns'|'fcm'|'huawei'|'xiaomi' app_version TEXT, locale VARCHAR(16), is_active BOOLEAN DEFAULT TRUE, last_used_at TIMESTAMPTZ DEFAULT now(), created_at TIMESTAMPTZ DEFAULT now(), updated_at TIMESTAMPTZ DEFAULT now(), deleted_at TIMESTAMPTZ, UNIQUE(user_id, device_token, push_provider) ); -- 索引 CREATE INDEX ix_user_push_devices_user_id ON user_push_devices(user_id); CREATE INDEX ix_user_push_devices_token ON user_push_devices(device_token); CREATE INDEX ix_user_push_devices_active ON user_push_devices(user_id, is_active) WHERE deleted_at IS NULL; ``` #### 4.1.4 `notification_push_attempts` 表(推送尝试日志) ```sql CREATE TABLE notification_push_attempts ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_notification_id UUID NOT NULL REFERENCES user_notifications(id) ON DELETE CASCADE, provider VARCHAR(16) NOT NULL, -- apns|fcm attempt_no SMALLINT NOT NULL, request_id TEXT, result SMALLINT NOT NULL, -- 0=sent, 1=provider_ack, 2=failed, 3=timeout error_code TEXT, error_detail TEXT, created_at TIMESTAMPTZ DEFAULT now() ); CREATE INDEX ix_notification_push_attempts_un_id ON notification_push_attempts(user_notification_id, created_at DESC); ``` #### 4.1.5 状态字段说明 | 字段 | 含义 | 触发条件 | |------|------|----------| | `is_seen` | 用户在列表中看到该通知 | 列表滚动曝光(停留 > 1秒)或拉取 | | `is_read` | 用户点击进入详情 | 点击通知项 | | `is_opened` | 用户从系统通知打开 App | 客户端上报 open 回执 | | `push_state=sent` | 推送请求已发出 | 服务端调用 APNs/FCM 成功返回 | | `push_state=provider_ack` | 平台确认接收 | APNs/FCM Provider 级确认 | | `push_state=failed` | 推送失败 | 服务端重试耗尽或不可恢复错误 | > 注意:`provider_ack` 不等价于“用户已看到通知”。用户可见性必须以 `is_opened`/`is_seen`/`is_read` 为准。 ### 4.2 后端 API 设计 #### 4.2.1 通知路由 (`backend/src/v1/notifications/`) | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/notifications` | 获取当前用户通知列表 | | GET | `/api/v1/notifications/unread-count` | 获取未读数 | | PATCH | `/api/v1/notifications/{id}/seen` | 标记为已看 | | PATCH | `/api/v1/notifications/{id}/read` | 标记为已读 | | PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | | DELETE | `/api/v1/notifications/{id}` | 删除通知 | #### 4.2.2 设备路由 (`backend/src/v1/push/`) | 方法 | 路径 | 说明 | |------|------|------| | POST | `/api/v1/push/devices` | 注册/更新设备 token | | DELETE | `/api/v1/push/devices/{id}` | 删除设备 | | GET | `/api/v1/push/devices` | 获取用户设备列表 | ### 4.3 Flutter 端设计 #### 4.3.1 目录结构(遵循 AGENTS.md) ``` apps/lib/ ├── core/ │ └── notification/ # NEW │ ├── models/ │ │ ├── notification.dart │ │ └── push_device.dart │ ├── services/ │ │ ├── notification_service.dart │ │ └── push_service.dart │ └── repositories/ │ └── notification_repository.dart ├── features/ │ └── notifications/ # NEW │ ├── data/ │ │ ├── apis/notification_api.dart │ │ └── repositories/notification_repository_impl.dart │ ├── presentation/ │ │ ├── screens/notification_center_screen.dart │ │ ├── widgets/notification_item.dart │ │ └── bloc/notification_bloc.dart ├── shared/ │ └── widgets/ │ └── notification/ # NEW │ ├── notification_badge.dart │ └── notification_toast.dart ``` #### 4.3.2 通知中心流程 ``` ┌──────────────────────────────────────────────────────────────┐ │ App 打开 / HomeScreen │ │ 1. 初始化时连接 Supabase Realtime 私有频道 │ │ 2. 监听 Broadcast 通知事件 │ │ 3. 实时更新本地列表和未读数 Badge │ └──────────────────────────────────────────────────────────────┘ │ ▼ ┌──────────────────────────────────────────────────────────────┐ │ NotificationCenterScreen │ │ 1. 首次进入 → 调用 GET /api/v1/notifications │ │ 2. 滚动曝光 → 调用 PATCH /api/v1/notifications/{id}/seen │ │ 3. 点击通知 → 调用 PATCH /api/v1/notifications/{id}/read │ │ 4. 下拉刷新 → 重新拉取列表 │ └──────────────────────────────────────────────────────────────┘ ``` ### 4.4 实时同步方案 **Supabase Realtime 职责**: - 当用户在另一设备读取通知时,当前设备实时更新 `is_read` / `is_seen` / `is_opened` 状态 - 仅做前台状态分发,不承担离线触达 **推荐通道:Broadcast(私有频道)** ```text DB update(user_notifications) -> trigger 调用 realtime.broadcast_changes(...) -> channel: user:{user_id}:notifications -> Flutter 收到事件并更新本地缓存 ``` **为什么不用 postgres_changes 作为主通道**: - 在订阅规模扩大时,`postgres_changes` 会放大 RLS 评估开销 - Broadcast 在通知场景下更可控,可按用户私有频道精准下发 ```dart final channel = supabase.channel( 'user:${userId}:notifications', opts: const RealtimeChannelConfig(private: true), ); channel.onBroadcast( event: 'notification_changed', callback: (payload) { // 更新本地状态和 Badge }, ).subscribe(); ``` ### 4.5 推送发送流程(Outbox + Worker) ``` ┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ │ 业务触发 │────▶│ DB 事务写入 │────▶│ push_outbox │ │ (后台任务) │ │ notifications + │ │ pending 事件 │ └─────────────┘ │ user_notifications│ └────────┬────────┘ └──────────────────┘ │ ▼ ┌─────────────────┐ │ Fanout Worker │ │ 读取 outbox │ └────────┬────────┘ │ ┌─────────────────────────────────┼─────────────────────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ Broadcast │ │ APNs / FCM │ │ 重试 / DLQ │ │ 前台增量同步 │ │ 离线触达 │ │ 指数退避 │ └──────────────┘ └──────────────┘ └──────────────┘ ``` ### 4.6 幂等、重试与失败处理 #### 4.6.1 幂等策略 - 每次业务发通知必须携带 `dedupe_key` - `UNIQUE(user_id, dedupe_key)` 保证重复投递不会生成多条记录 - `PATCH /read`、`PATCH /seen` 必须幂等,重复调用返回 200 且不重复更新统计 #### 4.6.2 重试策略 - 可重试错误:超时、5xx、429 - 不可重试错误:token invalid、payload invalid、认证失败 - 退避策略:`30s -> 2m -> 10m -> 1h`,最多 4 次 - 超过上限进入 DLQ,由运维任务定时重放或人工处理 #### 4.6.3 失败模式清单 | 场景 | 风险 | 方案 | |------|------|------| | 推送平台短时不可用 | 批量失败 | Outbox + 指数退避 + DLQ | | 客户端重复上报 read | 状态抖动/统计偏差 | 幂等更新 + 仅首次写时间戳 | | 多设备并发 read/seen | 最终状态不一致 | 单条记录原子更新 + `updated_at` 冲突检测 | | Realtime 断连 | UI 未及时同步 | 前台重连后触发增量拉取(按 `updated_at`) | ### 4.7 版本通知与活动通知 #### 4.7.1 版本通知 - 触发条件:用户登录时检测到 App 版本低于最新版本 - 写入方式:后台任务扫描所有用户,批量写入 `user_notifications` - `notification.type = 'version'` #### 4.7.2 活动通知 - 触发条件:运营后台或定时任务触发 - 目标用户:可按标签/行为筛选 - `notification.type = 'activity'` ### 4.8 权限与安全策略 #### RLS 设计 ```sql -- user_notifications ALTER TABLE user_notifications ENABLE ROW LEVEL SECURITY; CREATE POLICY auth_select ON user_notifications FOR SELECT USING (auth.uid() = user_id AND deleted_at IS NULL); CREATE POLICY auth_update ON user_notifications FOR UPDATE USING (auth.uid() = user_id) WITH CHECK (auth.uid() = user_id); -- user_push_devices ALTER TABLE user_push_devices ENABLE ROW LEVEL SECURITY; CREATE POLICY auth_all ON user_push_devices FOR ALL USING (auth.uid() = user_id AND deleted_at IS NULL) WITH CHECK (auth.uid() = user_id); ``` #### 信任边界 - 客户端不能创建通知主记录,通知内容仅由服务端写入 - 客户端只能操作自己的 `seen/read/open` 回执和设备 token - `owner_id/user_id` 必须来自 JWT `sub`,禁止客户端传入 - 管理后台发通知接口必须走服务角色鉴权,不暴露给普通用户 token ### 4.9 可观测性与运行指标 | 指标 | 目标/SLO | 备注 | |------|----------|------| | 通知写入成功率 | >= 99.9% | 5 分钟窗口 | | 推送发送成功率(sent) | >= 99.5% | 按 provider 分维度 | | 推送 provider_ack 延迟 P95 | < 3s | 仅平台确认链路 | | 前台实时同步延迟 P95 | < 1s | Broadcast 收包到 UI 更新 | | read API P95 | < 150ms | 排除网络抖动 | 日志要求:每次通知发送链路打通 `trace_id`(创建 -> outbox -> provider -> 回执)。 ### 4.10 NOT in scope(本阶段不做) - 厂商通道(小米/华为)深度集成 - 通知模板多语言管理后台 - 通知 A/B 实验平台 - 全量历史归档/冷热分层 --- ## 5. 分阶段落地计划 ### 第一阶段:最小可用通知中心(MVP 收敛) **目标**:在 App 内显示通知列表,支持标记已读 **改动范围**: - Backend: `notifications` 表 + API - Flutter: 通知中心页面 + Repository **主要任务**: 1. 创建 `notifications` 和 `user_notifications` 表 2. 实现 `GET /api/v1/notifications` API 3. 实现 `PATCH /api/v1/notifications/{id}/read` API 4. Flutter 通知中心页面(列表 + 标记已读) 5. 未读数 Badge(在 HomeScreen 通知图标上显示红点) 6. 完成协议文档:`docs/protocols/notification/notification-protocol.md` 7. 完成基础测试(见第 7 节) **依赖项**:无 **风险点**: - 无推送,通知需要后台手动写入 DB - 不支持实时同步,多设备体验差 **验收标准**: - [ ] 能看到通知列表 - [ ] 点击通知能标记为已读 - [ ] 未读通知有 Badge 提示 - [ ] 已读/未读状态在下拉刷新后正确 - [ ] 越权访问被拒绝(用户 A 无法读写用户 B 通知) - [ ] read 接口重复调用幂等 --- ### 第二阶段:接入系统推送 **目标**:支持离线推送通知 **改动范围**: - Backend: `user_push_devices` 表 + Push Service - Flutter: 推送接收 + 设备注册 **主要任务**: 1. 创建 `user_push_devices` 表 2. 实现 `POST /api/v1/push/devices` API(注册 token) 3. 集成 firebase_messaging(Android)/ apns(iOS) 4. 实现 Push Sending Service(APNs/FCM SDK)+ Outbox Worker 5. Flutter 端:获取 FCM/APNs token 并注册 6. 服务端发送后更新 `push_state`,客户端仅上报 open/read/seen 回执 7. 落地重试与 DLQ **依赖项**: - 第一阶段完成 - Firebase 项目配置(Android) - APNs 证书/Key(iOS) **风险点**: - APNs/FCM 配置复杂 - 推送送达率不稳定(特别是国内 Android) **验收标准**: - [ ] 离线设备能收到推送 - [ ] 点击推送能打开对应通知详情 - [ ] 推送状态(sent/provider_ack/failed)正确回写 - [ ] provider 短时故障时消息可自动重试并可追踪 --- ### 第三阶段:实时同步与统计 **目标**:多设备实时同步 + 完整状态追踪 **改动范围**: - Backend: Supabase Realtime + 状态统计 - Flutter: Realtime 订阅 + 曝光追踪 **主要任务**: 1. Supabase Realtime Broadcast 私有频道接入 2. 实现 `seen` 状态(曝光追踪) 3. 添加统计接口(送达率、点击率) 4. Flutter 端:列表滚动时标记 `seen` 5. 版本通知、活动通知的后台写入逻辑 6. 断线重连后的增量拉取机制(按 `updated_at`) **依赖项**: - 第二阶段完成 - Supabase Realtime 已启用 **风险点**: - Realtime 连接数限制(根据 plan) - 曝光追踪可能影响性能 **验收标准**: - [ ] 一设备读取通知,另一设备实时更新 - [ ] 列表曝光能正确标记 `seen` - [ ] 能查看送达/点击统计 - [ ] Realtime 断连恢复后不丢状态更新 --- ## 6. 建议改动清单 ### 6.1 新增/修改的表 | 表名 | 操作 | 说明 | |------|------|------| | `notifications` | 新增 | 通知模板表 | | `user_notifications` | 新增 | 用户通知记录+状态 | | `user_push_devices` | 新增 | 设备 token 存储 | | `notification_push_attempts` | 新增 | 推送尝试日志 | | `profiles` | 修改 | 暂不改,优先在线计算 unread_count | ### 6.2 新增后端模块 | 路径 | 说明 | |------|------| | `backend/src/v1/notifications/` | 通知路由+服务+仓库 | | `backend/src/v1/push/` | 推送设备路由+服务 | | `backend/src/services/push/` | APNs/FCM 发送服务 | | `backend/src/services/push/outbox_worker.py` | 推送 outbox 消费与重试 | | `backend/src/models/notification.py` | 通知 ORM 模型 | | `backend/src/models/notification_push_attempt.py` | 推送尝试日志模型 | | `backend/src/schemas/notification.py` | Pydantic schemas | ### 6.3 新增 Flutter 模块 | 路径 | 说明 | |------|------| | `apps/lib/core/notification/` | 通知核心逻辑 | | `apps/lib/features/notifications/` | 通知功能模块 | | `apps/lib/shared/widgets/notification/` | 通知 UI 组件 | ### 6.4 新增接口 | 方法 | 路径 | 说明 | |------|------|------| | GET | `/api/v1/notifications` | 通知列表 | | GET | `/api/v1/notifications/unread-count` | 未读数 | | PATCH | `/api/v1/notifications/{id}/seen` | 标记已看 | | PATCH | `/api/v1/notifications/{id}/read` | 标记已读 | | PATCH | `/api/v1/notifications/mark-all-read` | 全部已读 | | DELETE | `/api/v1/notifications/{id}` | 删除通知 | | POST | `/api/v1/notifications/{id}/opened` | 上报从推送打开 | | POST | `/api/v1/push/devices` | 注册设备 | | DELETE | `/api/v1/push/devices/{id}` | 删除设备 | ### 6.5 新增配置项 ```env # .env ERYAO_PUSH__APNS_KEY_ID=xxx ERYAO_PUSH__APNS_TEAM_ID=xxx ERYAO_PUSH__APNS_KEY_PATH=/path/to/key.p8 ERYAO_PUSH__FCM_SERVER_KEY=xxx ERYAO_PUSH__ENABLED=true ``` ### 6.6 Flutter 依赖(pubspec.yaml) ```yaml dependencies: firebase_messaging: ^15.0.0 # FCM flutter_local_notifications: ^18.0.0 # 本地通知 supabase_flutter: ^2.5.0 # Supabase 客户端(含 Realtime) ``` ### 6.7 协议文档(先于实现) ```text docs/protocols/notification/ ├── notification-protocol.md # 数据模型、字段语义、兼容策略 ├── notification-api-protocol.md # API 请求/响应、错误码 └── notification-realtime-protocol.md # Broadcast payload 与版本约束 ``` --- ## 7. 测试策略(必须随阶段落地) ### 7.1 测试框架 - 后端:`pytest` + `pytest-asyncio`(见 `pyproject.toml`) - Flutter:单测 + Widget 测试 + 关键链路集成测试 ### 7.2 覆盖图(关键分支) ```text Create Notification -> validate payload -> invalid type [unit] -> expires_at in past [unit] -> fanout target users -> empty target set [unit] -> partial user write failed [integration] -> write user_notifications (dedupe) -> first write success [unit] -> duplicate dedupe_key [unit] -> enqueue outbox -> enqueue success [integration] -> enqueue failed rollback [integration] Push Worker -> send provider -> sent/provider_ack [integration] -> timeout retry [integration] -> permanent failure to DLQ [integration] User Callback -> PATCH seen/read/opened -> own record success [api] -> cross-user forbidden [api/security] -> repeat call idempotent [api] Realtime -> broadcast receive update [integration] -> reconnect + incremental pull [e2e] ``` ### 7.3 最低覆盖要求 - 新增后端核心分支(通知创建、状态更新、重试)语句覆盖率 >= 90% - 安全相关路径(越权、伪造 user_id、非法 token)必须 100% 有测试 - 每个阶段必须包含至少 1 条回归测试,防止旧行为被破坏 ### 7.4 回归测试强制项 1. 同一通知重复发送不应生成重复 `user_notifications` 2. `mark-all-read` 与单条 `read` 并发时,最终状态一致 3. Realtime 中断后恢复,未读数与服务端一致 4. 无效/过期 token 不应导致消息丢失(进入失败态并可重试) --- ## 8. 最终推荐 ### 8.1 推荐总体方案 **采用 Supabase Realtime Broadcast(前台)+ APNs/FCM(后台/离线)的混合方案** ### 8.2 推荐原因 1. **用户体验最优**:前台实时同步,多设备状态一致 2. **离线可达**:通过 APNs/FCM 触达离线用户 3. **扩展性更稳**:Broadcast 规避高并发下 `postgres_changes` 的权限扫描放大 4. **状态语义更准确**:`provider_ack` 与用户可见状态分离,统计更可信 5. **项目适配**:Supabase 已在用,Realtime 只是扩展使用 ### 8.3 不确定点 | 问题 | 推断依据 | |------|----------| | 是否已有 Firebase 项目 | 未在代码库中找到 `google-services.json` 或 Firebase 配置 | | 是否已有 APNs 证书 | 未在代码库中找到证书文件 | | 国内 Android 推送需求 | 国内 Android ROM 需要厂商通道(如小米、华为),建议预留 | ### 8.4 实施优先级排序 | 优先级 | 阶段 | 说明 | |--------|------|------| | **P0** | 第一阶段 | MVP:通知列表 + 标记已读 | | **P1** | 第一阶段 | 未读数 Badge | | **P2** | 第二阶段 | 推送接入(Android FCM 先,iOS APNs 后) | | **P3** | 第三阶段 | Realtime 同步 | | **P4** | 第三阶段 | 曝光追踪 + 统计 | ### 8.5 关键约束提醒 1. **遵循 AGENTS.md**:通知代码放在 `core/notification/` 和 `shared/widgets/notification/` 2. **Error Swallowing**:所有异常必须传播,禁止静默捕获 3. **Protocol 先行**:新建通知相关协议文档在 `docs/protocols/notification/` 4. **渐进演进**:不废弃现有 `NotificationSettings`,而是扩展它 --- ## 附录:推断依据 | 信息点 | 推断依据 | |--------|----------| | 无通知表 | 所有 migration 文件中均未发现 `notifications` 相关表 | | 无推送集成 | `pubspec.yaml` 无 firebase_messaging / apns 相关依赖 | | 无 Realtime 通知 | Supabase Service 仅用于 Storage,未配置 Realtime | | AGENTS.md 约束 | `apps/AGENTS.md` 明确提到 "Reminder/Notification Rewrite Boundary" | | 推送服务配置 | `settings.py` 无任何 APNs/FCM 相关配置 |