# Calendar Reminder Alert Lifecycle Protocol ## Version - Current: `1.0` - Status: Draft --- ## Goal 定义日程提醒弹窗在 Android/iOS 的统一行为语义,覆盖提醒触发、用户操作、离线补偿、归档与重装恢复,确保多端一致性和可恢复性。 --- ## Canonical Rules 1. 提醒弹窗动作语义跨平台一致:`archive`、`snooze10m`。 2. 展示文案“取消”必须映射到内部动作 `archive`,并归档对应日程(`status=archived`)。 3. 归档后的 UI 渲染必须灰色显示;不强制改写原始 metadata 颜色。 4. 前端动作上报后端采用最终一致性:本地 outbox + 重放机制。 --- ## 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 动作枚举: - `archive`: 内部归档动作(UI 展示文案为“取消”) - `snooze10m`: 用户点击稍后提醒,重排到 `now + 10m` ### UI Label Mapping - 按钮展示文案“取消”仅为 UI 文案,不作为协议动作值。 - “取消”按钮点击后的上报动作值必须为 `archive`。 --- ## Outbox Contract (Frontend) ```json { "opId": "uuid", "eventId": "uuid", "action": "archive|snooze10m", "targetStatus": "archived|null", "occurredAt": "iso8601-with-offset", "retryCount": 0, "nextRetryAt": "iso8601-with-offset|null", "state": "pending|done|dead", "lastError": "string|null" } ``` ### Constraints - 幂等键:`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`。 - Outbox 记录必须本地持久化,App 重启后可恢复。 ### cold-start Queue Replay Contract - App cold-start 恢复 outbox/动作队列时,必须按入队顺序进行回放。 - 单条回放失败只记录失败并进入重试,不得阻塞后续条目继续回放。 - 回放过程必须复用 `actionExecutionId` 做去重,防止重复归档或重复稍后提醒。 - 回放执行模型为全局串行(concurrency=1),处理顺序与入队顺序一致。 - ACK 时序:仅在动作被成功执行或命中幂等去重时返回 ACK;失败条目进入重试状态并继续处理下一条。 --- ## Scheduling and Compensation Rules ### Normal schedule - `remindAt = startAt - reminderMinutes` ### Bootstrap/reinstall compensation 启动重建时对每个 active 事件执行: 1. `now < remindAt`:按 `remindAt` 正常调度。 2. `remindAt <= now < endAt`:立刻补偿提醒(建议 `+5s`),然后进入 10 分钟节奏。 3. `now >= endAt`:不再补发提醒;后续状态流转由上层业务策略决定(不在本协议强制范围内)。 4. `endAt = null`:视为无结束时间,沿用规则 1/2,不适用规则 3。 --- ## Uniqueness and Dedupe Rules - 通知唯一键:`hash(eventId + cycleStartEpochMinutes + mode)`。 - `cycleStartEpochMinutes` 定义为“该轮提醒触发时间(fireAt)按分钟向下取整后的 epoch minute”。 - 每次创建提醒前必须取消同 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 式强制全屏闹钟弹窗;以锁屏/横幅提醒为主。