2026-03-18 19:12:47 +08:00
|
|
|
|
# Calendar Reminder Alert Lifecycle Protocol
|
|
|
|
|
|
|
|
|
|
|
|
## Version
|
|
|
|
|
|
|
|
|
|
|
|
- Current: `1.0`
|
|
|
|
|
|
- Status: Draft
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Goal
|
|
|
|
|
|
|
2026-03-20 01:30:34 +08:00
|
|
|
|
定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Canonical Rules
|
|
|
|
|
|
|
2026-03-20 01:30:34 +08:00
|
|
|
|
1. 提醒弹窗动作语义跨平台一致:`archive`、`snooze10m`。
|
|
|
|
|
|
2. 展示文案“取消”必须映射到内部动作 `archive`,并归档对应日程(`status=archived`)。
|
|
|
|
|
|
3. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。
|
|
|
|
|
|
4. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Reminder Payload Contract
|
|
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"eventId": "uuid",
|
|
|
|
|
|
"title": "string",
|
|
|
|
|
|
"startAt": "iso8601-with-offset",
|
|
|
|
|
|
"endAt": "iso8601-with-offset|null",
|
|
|
|
|
|
"timezone": "IANA",
|
|
|
|
|
|
"location": "string|null",
|
|
|
|
|
|
"notes": "string|null",
|
|
|
|
|
|
"color": "#RRGGBB|null",
|
|
|
|
|
|
"mode": "single|aggregate",
|
|
|
|
|
|
"aggregateIds": ["uuid"],
|
|
|
|
|
|
"version": 1
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Constraints
|
|
|
|
|
|
|
|
|
|
|
|
- `eventId` 必填且为 UUID。
|
|
|
|
|
|
- `startAt` 必须带时区偏移。
|
|
|
|
|
|
- `mode=aggregate` 时,`aggregateIds` 至少包含 2 个 id。
|
|
|
|
|
|
- `version` 必填,用于后续协议升级兼容。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Action Contract
|
|
|
|
|
|
|
|
|
|
|
|
动作枚举:
|
|
|
|
|
|
|
2026-03-20 01:30:34 +08:00
|
|
|
|
- `archive`: 内部归档动作(UI 展示文案为“取消”)
|
|
|
|
|
|
- `snooze10m`: 用户点击稍后提醒,重排到 `now + 10m`
|
|
|
|
|
|
|
|
|
|
|
|
### UI Label Mapping
|
|
|
|
|
|
|
|
|
|
|
|
- 按钮展示文案“取消”仅为 UI 文案,不作为协议动作值。
|
|
|
|
|
|
- “取消”按钮点击后的上报动作值必须为 `archive`。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Outbox Contract (Frontend)
|
|
|
|
|
|
|
|
|
|
|
|
```json
|
|
|
|
|
|
{
|
|
|
|
|
|
"opId": "uuid",
|
|
|
|
|
|
"eventId": "uuid",
|
2026-03-20 01:30:34 +08:00
|
|
|
|
"action": "archive|snooze10m",
|
2026-03-18 19:12:47 +08:00
|
|
|
|
"targetStatus": "archived|null",
|
|
|
|
|
|
"occurredAt": "iso8601-with-offset",
|
|
|
|
|
|
"retryCount": 0,
|
|
|
|
|
|
"nextRetryAt": "iso8601-with-offset|null",
|
|
|
|
|
|
"state": "pending|done|dead",
|
|
|
|
|
|
"lastError": "string|null"
|
|
|
|
|
|
}
|
|
|
|
|
|
```
|
|
|
|
|
|
|
|
|
|
|
|
### Constraints
|
|
|
|
|
|
|
2026-03-20 01:30:34 +08:00
|
|
|
|
- 幂等键:`actionExecutionId = notificationId + "|" + actionId + "|" + fireTimeBucket`。
|
|
|
|
|
|
- `notificationId`: 本地通知唯一 id(整数或其字符串化值)。
|
|
|
|
|
|
- `actionId`: 内部动作 id,取值仅允许 `archive` 或 `snooze10m`。
|
|
|
|
|
|
- `fireTimeBucket`: 触发时间按“分钟”向下取整后的 epoch minute(`millisecondsSinceEpoch / 60000`)。
|
|
|
|
|
|
- 执行前必须先按 `actionExecutionId` 查重;若命中历史执行,直接 ACK,不得产生任何业务副作用。
|
|
|
|
|
|
- 重试策略:指数退避。默认参数:首次重试 0 分钟,之后依次 1/2/4/8/16/32/64 分钟;最大重试次数默认 8(可配置但需跨端一致)。
|
|
|
|
|
|
- `archive` 映射到后端 `PATCH status=archived`。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
- Outbox 记录必须本地持久化,App 重启后可恢复。
|
|
|
|
|
|
|
2026-03-20 01:30:34 +08:00
|
|
|
|
### cold-start Queue Replay Contract
|
|
|
|
|
|
|
|
|
|
|
|
- App cold-start 恢复 outbox/动作队列时,必须按入队顺序进行回放。
|
|
|
|
|
|
- 单条回放失败只记录失败并进入重试,不得阻塞后续条目继续回放。
|
|
|
|
|
|
- 回放过程必须复用 `actionExecutionId` 做去重,防止重复归档或重复稍后提醒。
|
|
|
|
|
|
- 回放执行模型为全局串行(concurrency=1),处理顺序与入队顺序一致。
|
|
|
|
|
|
- ACK 时序:仅在动作被成功执行或命中幂等去重时返回 ACK;失败条目进入重试状态并继续处理下一条。
|
|
|
|
|
|
|
2026-03-18 19:12:47 +08:00
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Scheduling and Compensation Rules
|
|
|
|
|
|
|
|
|
|
|
|
### Normal schedule
|
|
|
|
|
|
|
|
|
|
|
|
- `remindAt = startAt - reminderMinutes`
|
|
|
|
|
|
|
|
|
|
|
|
### Bootstrap/reinstall compensation
|
|
|
|
|
|
|
|
|
|
|
|
启动重建时对每个 active 事件执行:
|
|
|
|
|
|
|
|
|
|
|
|
1. `now < remindAt`:按 `remindAt` 正常调度。
|
|
|
|
|
|
2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。
|
2026-03-20 01:30:34 +08:00
|
|
|
|
3. `now >= endAt`:不再补发提醒;后续状态流转由上层业务策略决定(不在本协议强制范围内)。
|
|
|
|
|
|
4. `endAt = null`:视为无结束时间,沿用规则 1/2,不适用规则 3。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Uniqueness and Dedupe Rules
|
|
|
|
|
|
|
|
|
|
|
|
- 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`。
|
2026-03-20 01:30:34 +08:00
|
|
|
|
- `cycleStartEpochMinutes` 定义为“该轮提醒触发时间(fireAt)按分钟向下取整后的 epoch minute”。
|
2026-03-18 19:12:47 +08:00
|
|
|
|
- 每次创建提醒前必须取消同 dedupe key 的旧提醒(upsert 语义)。
|
|
|
|
|
|
- 补偿提醒在同一 cycle 窗口内最多触发一次。
|
|
|
|
|
|
- 启动恢复时要同时参考 pending notification 和 outbox 状态,避免重复调度。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Overlap Rule
|
|
|
|
|
|
|
|
|
|
|
|
- 同一分钟内多个提醒合并为一个 aggregate 弹窗。
|
|
|
|
|
|
- aggregate 弹窗默认操作作用于全部成员事件。
|
|
|
|
|
|
- aggregate 负载中必须包含 `aggregateIds` 以支持后续批处理。
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Backend Contract Reuse
|
|
|
|
|
|
|
|
|
|
|
|
- 复用现有接口:`PATCH /schedule-items/{item_id}`,请求体传 `{"status":"archived"}`。
|
|
|
|
|
|
- 建议提供/改造 overlap 查询语义用于启动补偿:
|
|
|
|
|
|
- `start_at <= window_end`
|
|
|
|
|
|
- `end_at IS NULL OR end_at >= window_start`
|
|
|
|
|
|
- `status=active`
|
|
|
|
|
|
|
|
|
|
|
|
---
|
|
|
|
|
|
|
|
|
|
|
|
## Platform Notes
|
|
|
|
|
|
|
|
|
|
|
|
### Android
|
|
|
|
|
|
|
|
|
|
|
|
- 优先 full-screen intent;系统可能因策略降级为 heads-up/横幅。
|
|
|
|
|
|
- 声音和振动受通知通道及系统设置影响。
|
|
|
|
|
|
|
|
|
|
|
|
### iOS
|
|
|
|
|
|
|
|
|
|
|
|
- 支持动作按钮与本地提醒语义。
|
|
|
|
|
|
- 不保证 Android 式强制全屏闹钟弹窗;以锁屏/横幅提醒为主。
|