Skip to content

Proposal:地理围栏提醒完整闭环(位置上报 → 围栏判定 → 提醒下发) #103

Description

@LUPENGHAN

1. 功能描述

处理 location.report 上报:根据日程已有的 geofence_armed 状态和上报位置,判断是否命中围栏、是否需要触发。命中后直接复用现有的 ReminderDispatcher.dispatch(...) 下发提醒,并写 geo_triggered_at 防止重复触发。

不再同步系统日历/系统闹钟,命中后不经过任何系统侧引用检查——这套机制已整体拿掉,时间维度改用客户端本地 AlarmManager(另有单独 issue),地理围栏命中后直接推送提醒即可。

不处理日程创建时 geofence_armed 初始值怎么算(不在本期范围,该逻辑属于日程创建流程)。不做时间提醒。

2. 用户故事

场景一:创建时已在围栏内
一条日程 geofence_armed=false(创建时已经在围栏内,这个初始值不是本期算的,当作既有事实)。用户离开围栏后,服务端收到 location.report 判定"已离开",把 geofence_armed 置为 true。之后用户再次进入围栏,服务端判定"已布防+命中进入",直接下发 reminder.control

场景二:创建时在围栏外
一条日程 geofence_armed=true(创建时人不在围栏内,默认已布防)。用户持续上报位置,在围栏外时不触发。进入围栏后判定命中,直接下发提醒。

场景三:同一位置连续上报两次
第一次命中后立刻写 geo_triggered_at,第二次上报因为查询条件(geo_triggered_at IS NULL)已经不成立,不会被处理。

场景四:命中围栏但下发失败
下发提醒时设备离线,ConnectionManager.send() 静默返回 False,不影响这次上报里其他日程的处理——这部分逻辑已经在 ReminderDispatcher._send() 里实现好了,直接复用,不用重写。

3. 现有做法及不足

  • location.report/location.report.ack 消息结构已定义(infrastructure/websocket/messages/location.py),跟架构设计.md §7.6 一致;schedules 表已有 geofence_radius_meters/geofence_armed/geo_triggered_at(迁移已跑,不需要新迁移)。
  • location.report 目前没有注册 handler,收到只会返回"未知消息类型"。
  • business/reminders/ 现在只有时间维度的判定逻辑(time_window_trigger.py),没有围栏判定。
  • infrastructure/workers/reminder_dispatcher.pyReminderDispatcher.dispatch(schedules, now) 已经是一个不区分触发来源的通用入口(时间轮询已经在用),地理围栏可以直接复用,不需要另起一套发送/待确认/超时重试逻辑。
  • 之前设计里"命中后先查系统日历引用是否仍存在"这一步已经不需要了——系统日历/系统闹钟同步机制整体拿掉,不再有外部系统对象需要确认存在性。

4. 本期范围

  1. business/reminders/geofence_trigger.py(新):
    • 直接复用 business.schedulesScheduleRecord 作为快照类型(跟 time_window_trigger.py的做法一致),不新增 GeofenceScheduleSnapshot
    • GeofenceQueryPort(Protocol):list_geofence_schedules(user_id),只返回有经纬度、status=scheduledgeo_triggered_at IS NULL 的日程。
    • 纯函数判断这次上报相对某条日程的围栏状态变化:命中(距离 ≤ geofence_radius_meters)时,按当前 geofence_armed 决定"无变化"/"离开后布防"/"已布防且命中,应触发"。
    • 命中的日程组装成 TriggeredSchedule(schedule_id, user_id, reason="geofence_entered")(复用 business/reminders/reminder_dispatch.py 已有的类型),不需要新的数据结构来表达"待下发的提醒"。
  2. 写端口:扩展 data/schedule_dispatch.py 里的 Command Port,加 set_geofence_armed(schedule_id, armed)mark_geo_triggered(schedule_id, triggered_at) 两个方法。
  3. data/schedule_dispatch.py 扩展:加 list_geofence_schedules 的实现;对应写方法的实现。(这两个 Port 现在合在一个适配器类里,时间维度已经是这样,地理维度保持一致,不重新拆分)
  4. infrastructure/websocket/location.report:新 handler 只做解析(LocationReport.model_validate)+ 调用 GeofenceTriggerService + 把命中结果转给 dispatcher.dispatch(...),返回 location.report.ack,不写判断逻辑。
  5. 命中后直接下发:调用现有的 ReminderDispatcher.dispatch([...]),复用它已经实现好的发送、待确认登记、超时重试、来源设备校验——不重新实现一套。
  6. 防重复触发:命中即写 geo_triggered_at

5. 明确不做

  • 不做日程创建时 geofence_armed 初始值的计算逻辑(不在本期范围)。
  • 不做时间提醒,时间维度是单独的 PR(服务端轮询判断 + 客户端本地 AlarmManager 离线兜底),已经合并。
  • 不做系统闹钟/系统日历相关逻辑——这套机制已整体拿掉。
  • 不做位置有效期/过期判断(架构设计文档未定义,不臆造)。
  • 不持久化"最近一次位置"(架构设计.md §5.3 第 11 条)。
  • 不做轨迹回放、地图渲染、多设备同步一致性、Outbox/可靠投递、后台高频 GPS 分析。
  • 不新增 has_left_geofence/created_inside_geofence 字段(现有 geofence_armed 已经够用)。
  • 不做天气、设备状态、用户偏好等"提醒强度/是否打扰"相关判断——这些属于"智能提醒"模块(独立于时间/地理触发,以后单独立项,服务端做),不在这次触发条件的范围内。
  • 不做客户端系统级地理围栏注册(那是 Proposal:位置临近提醒 #120 的方向,跟本 issue 的服务端判定路线不是一回事,这次不做)。

6. 关键决策

决策点 选择 理由
触发入口 location.report(事件驱动) 地理提醒由上报驱动,不需要轮询
围栏状态表达 复用现有 geofence_armed 单字段 架构设计.md §8.2 已定义
命中后处理 直接复用 ReminderDispatcher.dispatch(...),不经过系统引用检查 时间维度已经验证过这套发送/待确认/重试机制,地理维度直接复用,不重新实现;系统日历/系统闹钟同步机制已整体拿掉
提醒内容 无——reminder.control 只有 schedule_id/reason/action,跟时间提醒统一 架构设计.md 已经把提醒内容渲染整体移到独立的 reminder.audio.* 接口,不挂在 reminder.control
重复触发控制 geo_triggered_at,查询时过滤 沿用已有去重方式
时间/地理 vs 智能提醒的定位 时间、地理是各自独立的触发条件模块(服务端判定,日程自带条件,各一个 PR);天气/设备状态等信息由"智能提醒"模块消费(包住时间+地理两个触发结果,以后单独立项,服务端做,不是 Issue #80 说的客户端本地判断) 三者是不同层级:先判定触发条件,再由智能提醒模块决定最终要不要发、怎么发;不需要为了以后的智能提醒改造现在的触发条件存储结构
App 进程被杀后地理围栏检测失效 暂不解决 地理围栏依赖客户端持续上报位置,App 被系统杀死后无法上报;这个可靠性问题本期明确不处理,#120 是解决这个问题的另一条路线

7. 边界与异常

情况 处理方式
位置未命中任何围栏 不触发,不改变 geofence_armed
geofence_armed=false 且已离开围栏 true,不触发提醒
geofence_armed=true 且命中进入围栏 触发提醒流程
geofence_armed=false 且仍在围栏内(未离开过) 不触发,状态不变
同一日程收到重复命中上报 geo_triggered_at IS NULL 过滤,跳过
设备不在线 ReminderDispatcher._send() 静默失败,不影响其他日程
日程没有经纬度/围栏配置 不出现在查询结果里
提醒下发后客户端没有及时 ack 复用 ReminderDispatcher 已有的 30 秒超时重发、最多 3 次的机制

8. 基本概念与信息结构

business/reminders/
└── geofence_trigger.py(新)
    ├── GeofenceQueryPort(Protocol)
    │   └── list_geofence_schedules(user_id) -> Iterable[ScheduleRecord]
    ├── GeofenceTransition(Enum): NO_CHANGE | ARMED | TRIGGERED
    ├── evaluate_geofence(schedule: ScheduleRecord, latitude, longitude) -> GeofenceTransition
    └── GeofenceTriggerService.handle_location_report(user_id, latitude, longitude) -> list[TriggeredSchedule]

infrastructure/websocket/handlers/
└── location.py(新) location.report handler
    ├── LocationReport.model_validate(raw_message)
    ├── 调 GeofenceTriggerService.handle_location_report(...)
    └── 命中的日程调 dispatcher.dispatch(triggered, now) —— 复用时间提醒那套发送/ack/重试

9. 验收标准

  1. geofence_armed=true 且命中进入围栏时,通过 ReminderDispatcher.dispatch(...) 直接下发一次提醒。
  2. geofence_armed=false 时不会触发,状态不变。
  3. geofence_armed=false 且判定为离开围栏后,状态变为 true,不触发提醒。
  4. 下发的 reminder.control 格式和时间提醒完全一致(schedule_id/reason="geofence_entered"/action),复用同一套 ack/超时/重试机制。
  5. 未命中围栏时不会误触发,geofence_armed/geo_triggered_at 都不变。
  6. 同一日程不会因连续位置上报被触发多次。
  7. 设备下发失败时,不影响同批次其他日程的处理。

Metadata

Metadata

Assignees

No one assigned

    Labels

    FullSpec完整规格提案:影响面较大,需要写清楚动机、范围、不做、备选方案、接口/数据结构、原型、验收标准proposal

    Type

    No type

    Projects

    No projects

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions