Files
notify/docs/spec.md
Michael Dong a98e12f286 first commit
2026-02-05 11:24:40 +08:00

152 lines
5.8 KiB
Markdown
Raw Permalink 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.
# Notify 产品与技术规格
## 目标与定位
- **目标**:打造简洁明了、同时适配桌面与移动浏览器的提醒应用。
- **核心能力**Todo 与多用户提醒、周期性规则、提前提醒、网页通知 + Bark 推送。
## 需求澄清后的产品规格
### 核心对象
- **Todo**:个人待办,支持单次与周期性,配置一个或多个提醒策略。
- **提醒任务**:可指定多个接收者(含自己),支持单次与周期性,提醒策略与触达方式同 Todo。
- **通知**:一次提醒事件在“某接收者”维度的投递记录,支持站内与 Bark 两种渠道。
- **邀请码**:仅邀请码注册,所有用户可生成/管理邀请码。
### 业务规则与边界条件
1. **周期性任务的下一次触发时间**
- 使用 **用户时区**(默认 `Asia/Shanghai` 可配置)。
- 规则类型:`hourly | daily | weekly | monthly | yearly`
- 计算方式:以“本次 dueAt”为基准按规则添加时间并做 **日历对齐**
- `hourly`: `dueAt + n hours`
- `daily`: `dueAt + n days`(保持时分)
- `weekly`: `dueAt + n weeks`(保持星期与时分)
- `monthly`: 若下月无对应日期,则取 **该月最后一天** 同时分
- `yearly`: 若跨闰年导致日期不存在,取 **当年同月最后一天** 同时分
2. **多提醒策略**
- 每个任务可配置多条提前量(如 10 分钟、1 小时)。
- 对每条提前量生成独立触发点:`triggerAt = dueAt - offsetMinutes`
3. **浏览器不在线**
- 服务端仍生成通知记录(状态 `pending/queued/sent`)。
- 用户上线后,客户端拉取未读站内通知并展示(通知中心 + 弹窗)。
4. **Bark 推送失败/重试**
- 失败进入重试队列指数退避1m/5m/15m/1h
- 最多重试 5 次,超出后标记 `failed`
- 幂等:同一通知记录仅允许一次成功发送。
5. **多用户提醒**
- 每个接收者创建独立通知记录(便于去重与投递状态追踪)。
6. **邀请码策略(默认方案)**
- 每个邀请码 **可用次数 = 5**
- **有效期 = 7 天**,可撤销。
- 邀请码可由任意已注册用户生成与管理。
## 数据模型(关系型)
### 核心表
- `users`: 用户
- `invites`: 邀请码
- `todos`: 个人待办
- `reminder_tasks`: 多用户提醒任务
- `recurrence_rules`: 周期规则
- `reminder_offsets`: 提前提醒策略
- `notifications`: 通知实例(按接收者维度)
- `delivery_logs`: 投递日志(站内/Bark
### 字段示意(详见 schema
- `users(id, username, password_hash, timezone, bark_url, created_at)`
- `invites(id, code, creator_id, max_uses, used_count, expires_at, revoked_at)`
- `todos(id, owner_id, title, description, due_at, recurrence_rule_id)`
- `reminder_tasks(id, creator_id, title, due_at, recurrence_rule_id)`
- `reminder_task_recipients(task_id, user_id)`
- `recurrence_rules(id, type, interval, by_weekday, by_monthday, timezone)`
- `reminder_offsets(id, target_type, target_id, offset_minutes, channel_inapp, channel_bark)`
- `notifications(id, recipient_id, target_type, target_id, trigger_at, channel, status)`
- `delivery_logs(id, notification_id, attempt_no, channel, status, response_meta)`
## 核心接口设计REST
### 认证与邀请码
- `POST /api/auth/register` { username, password, inviteCode }
- `POST /api/auth/login` { username, password } -> JWT
- `POST /api/invites` 创建邀请码
- `GET /api/invites` 获取邀请码列表
- `POST /api/invites/:id/revoke`
### Todo
- `GET /api/todos`
- `POST /api/todos`
- `GET /api/todos/:id`
- `PUT /api/todos/:id`
- `DELETE /api/todos/:id`
### 多用户提醒任务
- `GET /api/reminder-tasks`
- `POST /api/reminder-tasks`
- `GET /api/reminder-tasks/:id`
- `PUT /api/reminder-tasks/:id`
- `DELETE /api/reminder-tasks/:id`
### 用户与设置
- `GET /api/users?query=`
- `GET /api/me`
- `PUT /api/me/settings` { timezone, barkUrl, inappEnabled, barkEnabled }
### 通知中心
- `GET /api/notifications?status=unread`
- `POST /api/notifications/:id/read`
## Bark 推送设计
### 调用形式
- 采用 Bark 官方接口:
- GET: `https://bark.server/push/{title}/{body}?group=notify&icon=...`
- POST: JSON body `{ title, body, group, icon, url, badge, sound }`
### 发送内容
- `title`: 任务标题
- `body`: 触发时间 + 备注
- `group`: `notify`
- 额外参数:`url` 指向站内通知详情
## 调度方案(可靠与幂等)
### 核心思想
-**通知表** 为唯一投递来源(幂等)。
- 调度器只负责生成通知实例;投递 worker 只发送 `pending` 通知。
### 通知状态机
- `pending` -> `queued` -> `sent``failed`
### 触发流程
1. 用户创建/更新任务 -> 生成 `next_due_at`
2. 生成通知实例:`trigger_at = due_at - offset`
3. Worker 扫描 `trigger_at <= now``status = pending`,锁定并投递
4. 成功则更新 `sent`,失败则记录 `failed` 并按策略重试
## 关键页面与交互
1. **登录/注册(邀请码)**
- 注册页要求邀请码与密码确认
2. **Todo 列表**
- 列表 + 新增/编辑弹窗
- 支持单次/周期选择、提前提醒策略
3. **提醒任务**
- 支持多接收者选择、搜索用户
4. **用户列表**
- 全部用户可见,支持搜索
5. **个人设置**
- Bark 链接、站内通知偏好
6. **通知中心**
- 未读/历史提醒,点击标记已读
## 工程结构与实现要点
- `backend/`: REST API、调度 worker、Bark 集成
- `frontend/`: Next.js UI响应式布局
- `docker-compose.yml`: 数据库与服务
## 示例伪代码
### 计算下一次触发
```
function nextDueAt(dueAt, rule) {
switch (rule.type) {
case "monthly":
return addMonthWithClamp(dueAt, rule.interval);
case "yearly":
return addYearWithClamp(dueAt, rule.interval);
// hourly/daily/weekly...
}
}
```