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

785 lines
31 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. 需求理解
### 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_messagingAndroid/ apnsiOS
4. 实现 Push Sending ServiceAPNs/FCM SDK+ Outbox Worker
5. Flutter 端:获取 FCM/APNs token 并注册
6. 服务端发送后更新 `push_state`,客户端仅上报 open/read/seen 回执
7. 落地重试与 DLQ
**依赖项**
- 第一阶段完成
- Firebase 项目配置(Android
- APNs 证书/KeyiOS
**风险点**
- 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 相关配置 |