Repository navigation
feat(backend): 地理围栏提醒闭环 + 提醒确认置 done + 音频下发有界等待 - #128
Conversation
- business/reminders/geofence_trigger.py: 围栏状态判定纯逻辑 (NO_CHANGE/ARMED/TRIGGERED),复用 ScheduleRecord,距离用 Haversine 公式 - data/schedule_dispatch.py: 新增 list_geofence_schedules/set_geofence_armed/ mark_geo_triggered;查询用 start_time IS NULL 只取纯地点日程,避免既有时间 又有地点的日程被时间和地理两条链路各推一次提醒 - infrastructure/websocket/handlers/location.py: location.report handler, 校验经纬度后判定围栏变化,命中的直接复用 ReminderDispatcher.dispatch(); 自己按序发 ack 和提醒,保证 location.report.ack 先于 reminder.control - main.py: run_geofence_report 闭包 + 注册 location.report 路由
- business/reminders + data: 新增 mark_done Port 与实现,UPDATE ... WHERE status='scheduled' 保证幂等,已删除的日程不会被迟到的 ack 改回 done - reminder_dispatcher: handle_ack 在设备校验通过后把日程置为 done,结束后续 监听(架构设计.md §8.4);伪造的 ack 既不能清待确认状态也不能标记完成; 状态写入失败只记日志,不打断 WS 消息循环 - reminder_audio: 抽出 ReminderAudioGenerationTracker 统一持有 TTS 生成任务, 下发提醒前有界等待(默认 3 秒,实测合成约 0.9 秒)。没有在飞任务时立即返回, TTS 失败或音频已就绪都不会拖慢提醒;用 shield 保证超时不取消生成任务 - handlers/schedules: 任务注册表交给 tracker,移除本类内的重复管理逻辑 修复的问题:地理围栏没有任何固有提前量,刚创建的日程可能一两秒内就命中, 此前音频尚未生成完会静默降级为纯文本,且 geo_triggered_at 已写死无法补发。
There was a problem hiding this comment.
Review summary
Found four correctness issues that can prevent geofence delivery, lose reminders during normal task cancellation, or leave acknowledged schedules stuck. The checked-out head matches the current PR SHA. Local pytest execution was unavailable because neither uv nor pytest is installed in the runner; GitHub CI was still in progress when reviewed.
| message = LocationReport.model_validate(raw_message) | ||
| now = datetime.now(UTC) | ||
| triggered = await asyncio.to_thread( | ||
| self._run_report, device_id, message.latitude, message.longitude, now |
There was a problem hiding this comment.
[P1] Use the same identity key as schedule persistence. ScheduleWebSocketHandlers ignores device_id, and ScheduleService stores every schedule under default_user, but this query uses the arbitrary WebSocket device_id. A client connected as device_1 can create a schedule successfully, then every location report queries user_id='device_1' and finds nothing, so geofence reminders never fire. The lookup and dispatch destination need a consistent user/device mapping.
review 指出的两个 P1: 1. ReminderAudioGenerationTracker.wait_for 只接了 Exception,而 CancelledError 是 BaseException。日程在提醒等待音频期间被更新(submit 取消旧任务)或删除 (discard)时,CancelledError 会一路冲出 send_reminder 和 dispatch,而触发 时间戳此前已写死,这条提醒就永久丢失。现在区分两种取消:内层生成任务被取消 当作"没有音频"继续下发;调用方自身被取消则原样向上传播。 2. handle_ack 先弹出 pending 再写 done,且 main.py 把写失败吞成 False。数据库 抖动时 ack 被"遗忘",日程永远停在 scheduled——触发时间戳已置,两条候选查询 都不会再选中它,没有任何补救路径。现在 mark_schedule_done 写失败向上抛出, handle_ack 只在写成功(或返回 False 这种终态)后才清除 pending,失败则保留 登记走既有的超时重发路径。代价是数据库故障期间可能重复提醒,受 max_attempts 约束。 另一条 review 意见(NaN 坐标可绕过范围校验)经实证为误报:`not (-90 <= nan <= 90)` 求值为 True,NaN 和 Infinity 都已被正确拒绝。
Summary
三块内容:
1. 地理围栏提醒闭环(Closes #103)
business/reminders/geofence_trigger.py:围栏状态判定纯逻辑(NO_CHANGE/ARMED/TRIGGERED),复用ScheduleRecord,距离用 Haversine 公式,不引入新依赖data/schedule_dispatch.py:list_geofence_schedules/set_geofence_armed/mark_geo_triggeredinfrastructure/websocket/handlers/location.py:接上location.report,命中后复用现有ReminderDispatcher.dispatch(),发送/待确认/超时重试/来源设备校验全部复用,没有重写第二套2. 提醒确认后置
done(架构设计.md §6.1 状态机 + §8.4)此前
scheduled → done这条状态流转在代码里没有任何实现路径。现在收到reminder.control.ack(ok:true且设备校验通过)后把日程置为done,两条监听链路的候选查询都带status='scheduled',状态一变监听自然结束。done之前,伪造的 ack 无法标记别人的日程完成UPDATE ... WHERE status='scheduled'保证已删除的日程不会被迟到的 ack 改回done3. 音频下发有界等待
音频生成是
schedule.upsert之后的后台任务(实测约 0.9 秒),而地理围栏没有任何固有提前量——用户可能在创建日程一两秒内就走进围栏。此前这种情况音频还没生成完,提醒会静默降级成纯文本,而且geo_triggered_at在下发前已写死,这条提醒永远拿不到语音。抽出
ReminderAudioGenerationTracker统一持有生成任务,下发前有界等待(默认 3 秒)。没有在飞任务时立即返回,所以 TTS 配置错误或音频早已就绪都不会拖慢提醒;用asyncio.shield保证等待超时不会取消生成任务。一处行为修正
list_geofence_schedules()加了start_time IS NULL:既有时间又有地点的日程只走时间维度。此前两条链路会同时命中同一条日程(time_triggered_at/geo_triggered_at是独立字段,幂等保护互不干涉),用户会为同一件事收到两条reminder.control。架构设计.md §8.3 已同步更新为此口径。Test plan
ruff check .mypy src/timeflowpytest -q(180 用例)done流转、超时重发、ok:false语义)