3f3d613d99
- 后端: 新增 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 及错误码注册
709 lines
16 KiB
Markdown
709 lines
16 KiB
Markdown
# 通知系统计划
|
||
|
||
> 更新时间: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 配置
|
||
- 推送发送服务
|
||
- 失败重试和审计链路
|