Repository navigation
离线数据库草案 #148
Description
一、方案概要
服务端是唯一权威:什么时候该提醒、日程数据是什么,只有服务端说了算,任何终端接入都不需要懂业务规则。客户端的本地能力是"镜像/冗余",执行的是服务端下发的计划,不是自己算出来的结论。
核心机制:下发「提醒计划」,而不是下发「提醒」
| 客户端判断(方案 A) | 服务端算好,下发计划(本方案) | |
|---|---|---|
| 谁算"什么时候该响" | 客户端自己算 | 服务端 |
| 客户端做什么 | 实现完整判定逻辑 | 按计划注册 OS 定时器 / 围栏,到点执行 |
| 新终端接入成本 | 重写一整套判定 | 只需"能连、能播" |
| 规则一致性 | 各端可能不同 | 服务端统一保证 |
地理围栏同理:服务端下发围栏规则(圆心、半径、是否已布防),客户端注册给 OS。客户端不发明规则,只执行规则。
这能同时满足"服务端权威"和"离线可用",靠的是产品设计 §6.11 已经要求的触发判定与触达执行分离:判定由服务端从日程数据确定性推导,任何时刻可重算;触达是客户端上报的遥测,不是判断。
终端能力分级
| 分级 | 终端 | 能力 | 离线 |
|---|---|---|---|
| Tier A | 手机 | 缓存日程 + 注册 OS 定时器/围栏 + 上报触达 | ✅ 支持 |
| Tier B | 硬件、Web、未来终端 | 仅接收服务端推送并播出 | ❌ 不支持,可接受 |
服务端对两级终端的判定逻辑完全相同,差别只在投递方式。客户端注册失败时可自动降级为 Tier B。
| 谁说了算 | |
|---|---|
| 日程内容 | 服务端(客户端本地缓存,离线编辑入 outbox) |
| 触发判定 | 服务端 —— 客户端执行下发的计划;离线创建时用降级规则临时兜底 |
geofence_armed |
服务端;离线期间客户端按固定迁移表本地推进,联网后以服务端重算结果为准 |
时序图角色:App 客户端逻辑 · Mem App 内存(去重 Map)· LocalDB 客户端 SQLite(schedules + sync_outbox)· OS 系统闹钟 / 围栏 + 原生存储 · Server / CloudDB 云端。
图例:alt / else = 二选一;🟥 红底块 = 最容易漏、漏了就出 bug 的地方;【磁盘】/【内存】/【原生】 = 这次写入落在哪 —— 状态落点统一见 数据库设计.md 第一节,本文不重复。
二、时序图
2.1 创建日程
目标:把提醒规则注册进 OS。 在线用服务端权威计划,离线用降级规则兜底。
sequenceDiagram
autonumber
participant U as 用户
participant App
participant DB as LocalDB
participant OS
participant S as Server
participant C as CloudDB
U->>App: 填表 / 语音创建
App->>App: 生成 UUID(sch_9f2a)
rect rgb(255, 248, 240)
Note over App,DB: 【磁盘】本地先落地 —— 一个事务写两张表
App->>DB: INSERT schedules(18 列, version=1)
App->>DB: INSERT sync_outbox(op_3c81, create)
Note over DB: 「待同步」= outbox 里有它的 entity_id<br/>不另存 sync_status 列
end
App-->>U: 创建成功(不等服务端)
alt 在线
App->>S: POST /sync/push [op_3c81]
S->>C: 【磁盘】INSERT sync_operations(op_3c81) 幂等
S->>C: 【磁盘】INSERT schedules version=1
S->>S: 算权威计划 T<br/>TimeWindowTrigger / evaluate_geofence
S-->>App: applied + plan(T)
App->>OS: 【原生】register(alarmId=sch_9f2a, T+N)
Note over OS: SharedPreferences 存 (sch_9f2a, T+N)<br/>= 唯一的计划记录,SQLite 不再存一份
App->>DB: 【磁盘】DELETE sync_outbox WHERE op_3c81
else 离线
rect rgb(255, 250, 235)
Note over App,OS: 降级兜底 —— 服务端够不着时的临时方案
App->>App: 时间:start_time - offset<br/>围栏:用日程字段,armed 默认 true
App->>OS: 【原生】register(alarmId=sch_9f2a, 降级时刻)
end
Note over App,DB: 【磁盘】outbox 行保持不动,等联网(见 2.3)
end
要点
- 本地事务不可拆:日程和 outbox 必须同一事务
- 降级计划一定会被覆盖:联网后服务端权威计划到达,无条件 cancel 旧注册、重新注册。正因为无条件,不需要
plan_source去区分当前注册的是降级还是权威 - 围栏和闹钟同一性质:都是"注册给 OS 的规则",注册后断网照常触发
2.2 提醒触发
规则:服务端通知优先,本地闹钟只做兜底。
实现关键是本地闹钟延后 N 秒注册——两个通道不能注册在同一时刻,否则谁先到全看运气:
服务端计划触发时刻 T
本地兜底闹钟注册在 T + N (N = 10~15 秒)
时间轴上天然分先后,并发变成串行,竞态从根源消失:
sequenceDiagram
autonumber
participant S as Server
participant C as CloudDB
participant OS as OS + 原生存储
participant App
participant Mem as App 内存
participant DB as LocalDB
participant U as 用户
Note over S,OS: 服务端计划 T,本地兜底闹钟注册在 T+N
alt 在线 —— 服务端通道生效
Note over S: T 时刻
S->>App: reminder.control + TTS 音频
S->>S: 【内存】_pending[sch_9f2a] = 已发,等 ack
App->>Mem: 【内存】claim(sch_9f2a)
Note over Mem: Map 里没有 → 写入,返回 true
App->>U: 展示提醒 + 播放服务端音频
App->>S: ack (ok=true)
S->>C: 【磁盘】UPDATE time_triggered_at = now<br/>WHERE time_triggered_at IS NULL
S->>S: 【内存】_pending.delete(sch_9f2a)
Note over App,DB: 在线路径**不写 outbox** —— ack 就是上报
Note over OS,Mem: T+N 时刻 —— 必定同一进程(见要点)
OS->>App: 本地兜底闹钟响
App->>Mem: 【内存】claim(sch_9f2a)
Note over Mem: 已存在 → 返回 false<br/>静默丢弃,用户无感知
else 离线 / 推送未达 —— 本地兜底生效
Note over S: T 时刻:推送发不出去<br/>或发出但客户端没收到
Note over S: 【内存】_pending 里没有这条
Note over OS: T+N 时刻
OS->>App: 本地兜底闹钟响
App->>Mem: 【内存】claim(sch_9f2a)
Note over Mem: 没有 → 写入,返回 true
App->>U: 展示提醒 + 本地 TTS
rect rgb(255, 235, 235)
App->>DB: 【磁盘】INSERT sync_outbox(delivered,<br/>{sch_9f2a, shown_at, 'local'})
Note over DB: 必须落磁盘!只放内存的话<br/>App 被杀 → 联网后服务端补推 → 重复提醒
end
Note over App,S: 联网后按 outbox 顺序补报(见 2.3)
end
rect rgb(255, 235, 235)
Note over App,Mem: 服务端 ack 超时重发时:claim 返回 false<br/>→ 丢弃展示,但仍必须回 ack
end
要点
-
N 的取值:太短(2 秒)服务端推送来不及到,兜底误触发导致重复;太长(60 秒)离线时延迟明显。建议 10~15 秒,覆盖网络往返 + TTS 音频传输(150~220KB)
-
为什么去重能用内存:服务端推送只走 WS,WS 活着 ⟺ 进程活着,而兜底闹钟 15 秒后才响 —— 两个通道只可能在同一个进程里相遇。这是结构性保证不是概率,推导见数据库设计.md 第五节
-
不靠"判断是否在线"来决策。连接状态不可靠——socket 可能刚断还没检测到,或显示已连接但服务端挂了。这套设计只看结果:到了 T+N 服务端推送有没有真的送达并呈现
-
三种情况都能正确处理:
情况 T 时刻 T+N 时刻 用户体验 在线正常 服务端推送 → 呈现 查到已呈现 → 静默丢弃 准时,有高质量音频 完全离线 推不到 兜底呈现 延迟 N 秒,本地 TTS 在线但推送丢了 发出但没到 兜底呈现 延迟 N 秒,兜底生效 ✅ -
去重丢弃 ≠ 不回 ack。服务端因 ack 超时重发时,客户端查到已呈现会丢弃展示,但仍必须回 ack,否则服务端重发满 3 次,期间白传三次 TTS 音频
-
去重用内存,上报用磁盘 —— 两件事别混在一起:
async function onReminder(scheduleId: string, channel: 'server' | 'local') { if (claim(scheduleId)) { // 【内存】JS 单线程,天然原子 await presentReminder(scheduleId); if (channel === 'local') { // 【磁盘】离线兜底才入队。在线时 ack 就是上报,不用写 await enqueueOutbox('delivered', { scheduleId, shownAt: Date.now() }); } } await sendAck(scheduleId); // ← 两条路都要回 }
claim()的实现和"为什么这两件事要分开"见数据库设计.md §3.2 / §5.1
2.3 网络恢复:上传与对账
sequenceDiagram
autonumber
participant App
participant Mem as App 内存
participant DB as LocalDB
participant OS as OS + 原生存储
participant S as Server
participant C as CloudDB
rect rgb(240, 255, 240)
Note over App,C: 第一步 —— 按 outbox 顺序推,create 天然排在 delivered 前面
App->>DB: 【磁盘】SELECT * FROM sync_outbox ORDER BY created_at
App->>S: ① POST /sync/push [create / update / delete]
Note over App,S: 同一 entity_id 的操作严格保序
S->>C: 【磁盘】INSERT sync_operations(幂等)
S->>C: 【磁盘】落库 schedules
S->>S: 算权威计划
S-->>App: 逐条结果 + plan(支持部分成功)
App->>DB: 【磁盘】DELETE 已成功的 outbox 行
App->>OS: 【原生】cancel(sch_9f2a)
App->>OS: 【原生】register(sch_9f2a, 权威 T+N)
Note over OS: 无条件覆盖,不判断原来是不是降级计划<br/>→ 所以不需要 plan_source
end
rect rgb(235, 250, 255)
Note over App,C: 第二步 —— 同一队列里的 delivered 行(顺序自动满足)
App->>S: ② POST /sync/push [delivered]
S->>C: 【磁盘】UPDATE time_triggered_at = shown_at<br/>WHERE time_triggered_at IS NULL
App->>DB: 【磁盘】DELETE 该 outbox 行
end
rect rgb(255, 240, 245)
Note over S,App: 第三步 —— 对账补发
S->>C: 【磁盘】查:应触发但 *_triggered_at 仍为 NULL 的日程
alt 有遗漏且在时效内(5~10 分钟)
S->>App: 补推 reminder.control
App->>Mem: 【内存】claim(schedule_id)
Note over Mem: 若这次会话里已展示过 → false → 只回 ack
App->>S: ack
S->>C: 【磁盘】写 time_triggered_at
else 已送达 或 已超时
Note over S: 不补推<br/>超时的标记 expired 并记日志
end
end
App->>S: GET /sync/pull
S-->>App: 全量日程 + 最新计划
App->>DB: 【磁盘】覆盖本地 schedules
App->>OS: 【原生】按最新计划重新注册
要点
- ①② 的顺序不用额外控制了:
create入队在前、delivered在后,按队列顺序推即可 - 第三步的
claim是第二道防线。同一次会话内已展示过的,补推来了也只回 ack 不展示 - 先 push 再 pull,避免本地未上传的修改被云端旧数据覆盖
- 补发要有时效。两小时后再补一句"该开会了"只会让人烦,超时的直接标
expired
2.4 日程变更(修改 / 删除)
sequenceDiagram
autonumber
participant U as 用户
participant App
participant Mem as App 内存
participant DB as LocalDB
participant OS as OS + 原生存储
participant S as Server
participant C as CloudDB
alt 修改
U->>App: 把会议 15:00 改到 16:00
App->>DB: 【磁盘】事务:UPDATE schedules(version+1)<br/>+ INSERT sync_outbox(update, baseVersion=1)
App-->>U: 修改成功
App->>S: POST /sync/push
alt 版本匹配
S->>C: 【磁盘】UPDATE schedules version=2
rect rgb(255, 235, 235)
S->>C: 【磁盘】触发条件变了?<br/>→ 重置 time_triggered_at / geo_triggered_at = NULL
Note over S,C: 判定依据:start_time / offset /<br/>经纬度 / 半径 任一改变<br/>只改标题备注则不重置
end
S->>S: 重算权威计划
S-->>App: applied v2 + 新计划
App->>DB: 【磁盘】DELETE 该 outbox 行
App->>OS: 【原生】cancel(sch_9f2a)
App->>OS: 【原生】register(sch_9f2a, 新 T+N)
rect rgb(255, 235, 235)
App->>Mem: 【内存】shown.delete(sch_9f2a)
Note over Mem: 漏了这步:改期后新提醒会被<br/>旧的展示记录挡掉,静默不响
end
else 版本冲突
S-->>App: conflict + 服务端数据
App->>DB: 【磁盘】以服务端为准覆盖 + DELETE outbox 行
App->>OS: 【原生】按服务端计划重新注册
App->>Mem: 【内存】shown.delete(sch_9f2a)
end
else 删除
U->>App: 删除日程
App->>DB: 【磁盘】事务:status='deleted'<br/>+ INSERT sync_outbox(delete)
App->>OS: 【原生】cancel(sch_9f2a) + 注销围栏
App->>Mem: 【内存】shown.delete(sch_9f2a)
App->>S: POST /sync/push
S->>C: 【磁盘】status='deleted'(软删除墓碑,不物理删)
S->>S: 清理 TTS 音频文件
end
shown.delete()这一步在旧设计里是"清除shown_at列",现在变成一行内存操作 —— 但仍然不能漏,它是这张图里最容易忘的一步。
必须做对的五件事
| 场景 | 必须做的 | 漏了会怎样 |
|---|---|---|
| 改期(触发条件变) | 重置 *_triggered_at + 重算计划 + cancel 旧注册 + 注册新的 + 清内存 shown |
提醒永久丢失(当前已存在的 bug) |
| 只改标题/备注 | 不重置触发状态 | 已提醒过的日程会重复响 |
| 删除 | cancel 闹钟 + 注销围栏 + 清理 TTS 音频 | 已删日程仍响;孤儿音频堆积 |
| 离线改期后联网 | 服务端计划到达后覆盖本地临时计划 | 按旧时间响 |
| 冲突(服务端优先) | 以服务端版本为准,重新注册 | 按已被拒绝的修改响 |
⚠️ 当前business/schedules.py原样保留time_triggered_at=existing.time_triggered_at,导致改期后服务端算计划时会因该字段非空而跳过这条日程。这个 bug 必须先修,否则再完善的计划下发也没用。
三、离线创建的同步细节
第一步 —— 一个事务写两张表(缺一不可):
BEGIN;
INSERT INTO schedules (id, title, start_time, ..., version)
VALUES ('sch_9f2a...', '开会', '2026-08-04T15:00:00+00:00', ..., 1);
-- 没有 sync_status 列:「待同步」= 下面这行 outbox 存在
INSERT INTO sync_outbox (operation_id, entity_id, operation, payload, base_version, status)
VALUES ('op_3c81...', 'sch_9f2a...', 'create', '{...完整日程...}', NULL, 'pending');
COMMIT;第二步 —— 联网后批量推送,取 status='pending' 按 created_at 排序,一次 50~100 条:
POST /api/v1/sync/push
{ "operations": [
{ "operationId": "op_3c81...", "entity": "schedule", "entityId": "sch_9f2a...",
"operation": "create", "baseVersion": null, "payload": { } }
] }第三步 —— 服务端幂等落库并返回计划:
1. operation_id 已在 sync_operations 里? → 返回 duplicate,不重复插入
2. create 且 entityId 已存在? → 上次成功但响应丢了,返回 duplicate
3. 落库,version=1
4. 算权威提醒计划
5. 记录 operation_id 到幂等表
{ "results": [
{ "operationId": "op_3c81...", "status": "applied", "version": 1,
"plan": { "triggerAt": "2026-08-04T14:45:00+00:00", "geofence": null } }
] }第四步 —— 客户端收尾:
| 返回 | 处理 |
|---|---|
applied / duplicate |
删除 outbox 行(删掉即代表已同步);cancel 降级注册 → 按权威计划重新注册 |
conflict |
以服务端为准(create 基本不会冲突) |
| 5xx / 网络失败 | retry_count++,指数退避,operation_id 保持不变 |
三个边界情况
① 离线期间创建后又改了好几次 —— outbox 里是 create + 若干 update,必须按序推送。乱序的话 update 可能先于 create 到达,服务端找不到实体。初版做法:同一 entity_id 严格串行,不同实体可并行。
② 离线创建后又删除了 —— 照常推 create + delete,服务端先建再软删。不要在客户端提前抵消,否则服务端永远不知道这条数据存在过。
③ 离线创建 + 离线已触发提醒 —— 两条记录都在同一个 outbox 里:create 先入队、delivered 后入队。按 created_at 顺序推送,顺序依赖自动满足,不需要维护两个队列。见 2.3 图。
四、去重机制
四处去重,键各不相同:
| 场景 | 键 | 存放位置 | 目的 |
|---|---|---|---|
| 同步操作重试 | operation_id |
**【磁盘】**服务端 sync_operations 表 |
重复推送不产生重复数据 |
| 触达上报重试 | schedule_id |
**【磁盘】**服务端 schedules.*_triggered_at 列 |
重复上报不重复写 |
| 服务端推送 vs 本地兜底 | schedule_id |
**【内存】**客户端 shown Map |
兜底闹钟发现已呈现则静默丢弃 |
| ack 超时重发 | schedule_id |
**【内存】**服务端 _pending |
已确认的不再重发 |
只有 sync_operations 是新表。 两处磁盘去重落在 schedules 已有的列上(一条日程一个提醒,状态与日程 1:1);两处内存去重只需跨越十几秒,不上磁盘。详见 数据库设计.md 第一节。
磁盘上的"检查+写入"必须原子
不要读出来再决定写不写,让数据库的原子性替你判断:
| 位置 | 原子写法 | 判断依据 |
|---|---|---|
| 服务端标记已送达 | UPDATE schedules SET time_triggered_at=? WHERE id=? AND time_triggered_at IS NULL |
rowcount > 0 |
| 服务端同步幂等 | INSERT INTO sync_operations ... ON CONFLICT DO NOTHING |
冲突即为重复请求 |
第一条是现有代码里已经这么做的(data/schedule_dispatch.py 的 mark_time_triggered),可直接参照;唯一要改的是写入时机——从"推送前写"改成"收到 ack 后写",详见 数据库设计.md §7.1。
客户端去重不在这张表里 —— 它在内存中,JS 单线程,
claim()的"检查+写入"之间不会被抢占,天然原子,不需要数据库语句。
周期日程会让现在的去重键失效(未来)
现在用 schedule_id 够用,因为每条日程只触发一次。一旦支持周期日程,同一 schedule_id 触发多次,第二次、第三次会被全部误判为重复而丢弃。届时要改两处:
- 内存
shownMap 的键从schedule_id变成schedule_id + plan_id - 云端
*_triggered_at不能再挂在schedules列上,要拆成以schedule_id + plan_id为键的独立表
不要用触发时刻当键:离线降级规则算出的时刻和服务端权威计划可能不一致,两边对不上就去重失败了。
plan_id由服务端下发计划时生成。
五、优缺点
优点
- 新终端接入成本极低——只需"能连、能播"
- 规则一致性由服务端保证,行为可预测
- 完整复用现有服务端实现(PR feat(backend): 时间到点提醒触发与下发闭环 #121 / feat(backend): 地理围栏提醒闭环 + 提醒确认置 done + 音频下发有界等待 #128 一点不浪费)
- 客户端注册失败可自动降级为 Tier B,有兜底
- 与产品设计 §6.11「触发与触达分别记录」天然吻合
- 服务端下发高质量 TTS 音频
缺点
- 需要设计并维护"提醒计划"的下发协议和刷新机制(日程改了要重新下发)
- 手机端要维护"服务端权威"与"本地镜像"两份状态的一致性
- 计划有时效性,下发窗口多长、多久刷新一次需要调优
- 离线时用降级规则,若服务端计划以后加入更复杂因素(免打扰、多维感知)不会体现
六、对现有代码的影响
| 现有代码 | 变化 |
|---|---|
TimeWindowTriggerService / evaluate_geofence |
保留并升格为权威判定,产出从"立即推送"扩展为"下发计划 + 在线推送" |
ReminderDispatcher |
保留,增加"计划下发"通道;_pending 逻辑不变 |
data/schedule_dispatch.py 的 mark_time_triggered |
写法不变,调用时机改到收到 ack 之后 |
前端 syncScheduleAlarm.ts |
改造:从"自己算触发时刻"改为"执行服务端下发的计划",computeScheduleAlarmTriggerMillis 退役 |
原生 AlarmScheduler.java |
schedule() 改成接收外部传入的 alarmId(= schedule_id),不再内部 UUID.randomUUID() |
| 原生 AndroidManifest | 补 RECEIVE_BOOT_COMPLETED + 开机 receiver 调 loadAlarms() 重建 —— 现在缺这个,重启后闹钟全没 |
七、适用条件
以后要接入多种终端(硬件、Web),且要求行为统一。 这是本项目的既定方向,因此本方案为推荐架构。
Activity