Skip to content

feat(backend): 地理围栏提醒闭环 + 提醒确认置 done + 音频下发有界等待 - #128

Merged
LUPENGHAN merged 3 commits into
1024XEngineer:MVPfrom
LUPENGHAN:feature/geofence-reminder-trigger
Jul 31, 2026
Merged

LUPENGHAN merged 3 commits into
1024XEngineer:MVPfrom
LUPENGHAN:feature/geofence-reminder-trigger

Conversation

@LUPENGHAN

Copy link
Copy Markdown
Contributor

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_triggered
  • infrastructure/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 改回 done

3. 音频下发有界等待

音频生成是 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/timeflow
  • pytest -q(180 用例)
  • 真实 Postgres + 真实 uvicorn + 真实 WebSocket 客户端 + 真实阿里云 TTS,全场景手工测试 29 项全过,服务端日志零异常:
    • 会话层 4 项(握手、非法 device_id、未握手拦截)
    • 日程 CRUD 9 项(增删查、冲突检测、四类校验失败)
    • 时间提醒 6 项(到点下发 + TTS 音频 + ack + done 流转、超时重发、ok:false 语义)
    • 地理围栏 8 项(两种布防场景、防重复、时间+地点隔离、消息顺序、经纬度校验)
    • TTS 时序 2 项(有界等待、删除后音频清理)

- 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 已写死无法补发。

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

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.

View job run

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

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[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.

Comment thread backend/src/timeflow/infrastructure/websocket/reminder_audio.py
Comment thread backend/src/timeflow/infrastructure/workers/reminder_dispatcher.py
Comment thread backend/src/timeflow/infrastructure/websocket/handlers/location.py
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 都已被正确拒绝。
@LUPENGHAN
LUPENGHAN merged commit 344b8c6 into 1024XEngineer:MVP Jul 31, 2026
1 check passed
@LUPENGHAN LUPENGHAN mentioned this pull request Aug 3, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant