一、方案概要
服务端是唯一权威 :什么时候该提醒、日程数据是什么,只有服务端说了算,任何终端接入都不需要懂业务规则 。客户端的本地能力是"镜像/冗余",执行的是服务端下发的计划 ,不是自己算出来的结论。
核心机制:下发「提醒计划」,而不是下发「提醒」
客户端判断(方案 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
Loading
要点
本地事务不可拆 :日程和 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
Loading
要点
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: 【原生】按最新计划重新注册
Loading
要点
①② 的顺序不用额外控制了 :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
Loading
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 触发多次,第二次、第三次会被全部误判为重复而丢弃 。届时要改两处:
内存 shown Map 的键从 schedule_id 变成 schedule_id + plan_id
云端 *_triggered_at 不能再挂在 schedules 列上,要拆成以 schedule_id + plan_id 为键的独立表
不要用触发时刻当键 :离线降级规则算出的时刻和服务端权威计划可能不一致,两边对不上就去重失败了。plan_id 由服务端下发计划时生成。
五、优缺点
优点
缺点
需要设计并维护"提醒计划"的下发协议和刷新机制(日程改了要重新下发)
手机端要维护"服务端权威"与"本地镜像"两份状态的一致性
计划有时效性,下发窗口多长、多久刷新一次需要调优
离线时用降级规则,若服务端计划以后加入更复杂因素(免打扰、多维感知)不会体现
六、对现有代码的影响
现有代码
变化
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),且要求行为统一。 这是本项目的既定方向,因此本方案为推荐架构。
一、方案概要
服务端是唯一权威:什么时候该提醒、日程数据是什么,只有服务端说了算,任何终端接入都不需要懂业务规则。客户端的本地能力是"镜像/冗余",执行的是服务端下发的计划,不是自己算出来的结论。
核心机制:下发「提醒计划」,而不是下发「提醒」
地理围栏同理:服务端下发围栏规则(圆心、半径、是否已布防),客户端注册给 OS。客户端不发明规则,只执行规则。
这能同时满足"服务端权威"和"离线可用",靠的是产品设计 §6.11 已经要求的触发判定与触达执行分离:判定由服务端从日程数据确定性推导,任何时刻可重算;触达是客户端上报的遥测,不是判断。
终端能力分级
服务端对两级终端的判定逻辑完全相同,差别只在投递方式。客户端注册失败时可自动降级为 Tier B。
geofence_armed时序图角色:
App客户端逻辑 ·MemApp 内存(去重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要点
plan_source去区分当前注册的是降级还是权威2.2 提醒触发
规则:服务端通知优先,本地闹钟只做兜底。
实现关键是本地闹钟延后 N 秒注册——两个通道不能注册在同一时刻,否则谁先到全看运气:
时间轴上天然分先后,并发变成串行,竞态从根源消失:
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 服务端推送有没有真的送达并呈现
三种情况都能正确处理:
去重丢弃 ≠ 不回 ack。服务端因 ack 超时重发时,客户端查到已呈现会丢弃展示,但仍必须回 ack,否则服务端重发满 3 次,期间白传三次 TTS 音频
去重用内存,上报用磁盘 —— 两件事别混在一起:
claim()的实现和"为什么这两件事要分开"见数据库设计.md §3.2 / §5.12.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 不展示expired2.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必须做对的五件事
*_triggered_at+ 重算计划 + cancel 旧注册 + 注册新的 + 清内存shown三、离线创建的同步细节
第一步 —— 一个事务写两张表(缺一不可):
第二步 —— 联网后批量推送,取
status='pending'按created_at排序,一次 50~100 条:第三步 —— 服务端幂等落库并返回计划:
{ "results": [ { "operationId": "op_3c81...", "status": "applied", "version": 1, "plan": { "triggerAt": "2026-08-04T14:45:00+00:00", "geofence": null } } ] }第四步 —— 客户端收尾:
applied/duplicateconflictretry_count++,指数退避,operation_id保持不变三个边界情况
① 离线期间创建后又改了好几次 —— outbox 里是
create+ 若干update,必须按序推送。乱序的话 update 可能先于 create 到达,服务端找不到实体。初版做法:同一entity_id严格串行,不同实体可并行。② 离线创建后又删除了 —— 照常推
create+delete,服务端先建再软删。不要在客户端提前抵消,否则服务端永远不知道这条数据存在过。③ 离线创建 + 离线已触发提醒 —— 两条记录都在同一个 outbox 里:
create先入队、delivered后入队。按created_at顺序推送,顺序依赖自动满足,不需要维护两个队列。见 2.3 图。四、去重机制
四处去重,键各不相同:
operation_idsync_operations表schedule_idschedules.*_triggered_at列schedule_idshownMapschedule_id_pending只有
sync_operations是新表。 两处磁盘去重落在schedules已有的列上(一条日程一个提醒,状态与日程 1:1);两处内存去重只需跨越十几秒,不上磁盘。详见 数据库设计.md 第一节。磁盘上的"检查+写入"必须原子
不要读出来再决定写不写,让数据库的原子性替你判断:
UPDATE schedules SET time_triggered_at=? WHERE id=? AND time_triggered_at IS NULLrowcount > 0INSERT INTO sync_operations ... ON CONFLICT DO NOTHING第一条是现有代码里已经这么做的(
data/schedule_dispatch.py的mark_time_triggered),可直接参照;唯一要改的是写入时机——从"推送前写"改成"收到 ack 后写",详见 数据库设计.md §7.1。周期日程会让现在的去重键失效(未来)
现在用
schedule_id够用,因为每条日程只触发一次。一旦支持周期日程,同一schedule_id触发多次,第二次、第三次会被全部误判为重复而丢弃。届时要改两处:shownMap 的键从schedule_id变成schedule_id + plan_id*_triggered_at不能再挂在schedules列上,要拆成以schedule_id + plan_id为键的独立表五、优缺点
优点
缺点
六、对现有代码的影响
TimeWindowTriggerService/evaluate_geofenceReminderDispatcher_pending逻辑不变data/schedule_dispatch.py的mark_time_triggeredsyncScheduleAlarm.tscomputeScheduleAlarmTriggerMillis退役AlarmScheduler.javaschedule()改成接收外部传入的 alarmId(=schedule_id),不再内部UUID.randomUUID()RECEIVE_BOOT_COMPLETED+ 开机 receiver 调loadAlarms()重建 —— 现在缺这个,重启后闹钟全没七、适用条件
以后要接入多种终端(硬件、Web),且要求行为统一。 这是本项目的既定方向,因此本方案为推荐架构。