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.py 的 ReminderDispatcher.dispatch(schedules, now) 已经是一个不区分触发来源的通用入口(时间轮询已经在用),地理围栏可以直接复用,不需要另起一套发送/待确认/超时重试逻辑。
之前设计里"命中后先查系统日历引用是否仍存在"这一步已经不需要了——系统日历/系统闹钟同步机制整体拿掉,不再有外部系统对象需要确认存在性。
4. 本期范围
business/reminders/geofence_trigger.py(新) :
直接复用 business.schedules 的 ScheduleRecord 作为快照类型(跟 time_window_trigger.py的做法一致),不新增 GeofenceScheduleSnapshot。
GeofenceQueryPort(Protocol):list_geofence_schedules(user_id),只返回有经纬度、status=scheduled、geo_triggered_at IS NULL 的日程。
纯函数判断这次上报相对某条日程的围栏状态变化:命中(距离 ≤ geofence_radius_meters)时,按当前 geofence_armed 决定"无变化"/"离开后布防"/"已布防且命中,应触发"。
命中的日程组装成 TriggeredSchedule(schedule_id, user_id, reason="geofence_entered")(复用 business/reminders/reminder_dispatch.py 已有的类型),不需要新的数据结构来表达"待下发的提醒"。
写端口 :扩展 data/schedule_dispatch.py 里的 Command Port,加 set_geofence_armed(schedule_id, armed)、mark_geo_triggered(schedule_id, triggered_at) 两个方法。
data/schedule_dispatch.py 扩展 :加 list_geofence_schedules 的实现;对应写方法的实现。(这两个 Port 现在合在一个适配器类里,时间维度已经是这样,地理维度保持一致,不重新拆分)
infrastructure/websocket/ 接 location.report :新 handler 只做解析(LocationReport.model_validate)+ 调用 GeofenceTriggerService + 把命中结果转给 dispatcher.dispatch(...),返回 location.report.ack,不写判断逻辑。
命中后直接下发 :调用现有的 ReminderDispatcher.dispatch([...]),复用它已经实现好的发送、待确认登记、超时重试、来源设备校验——不重新实现一套。
防重复触发 :命中即写 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. 验收标准
geofence_armed=true 且命中进入围栏时,通过 ReminderDispatcher.dispatch(...) 直接下发一次提醒。
geofence_armed=false 时不会触发,状态不变。
geofence_armed=false 且判定为离开围栏后,状态变为 true,不触发提醒。
下发的 reminder.control 格式和时间提醒完全一致(schedule_id/reason="geofence_entered"/action),复用同一套 ack/超时/重试机制。
未命中围栏时不会误触发,geofence_armed/geo_triggered_at 都不变。
同一日程不会因连续位置上报被触发多次。
设备下发失败时,不影响同批次其他日程的处理。
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.py的ReminderDispatcher.dispatch(schedules, now)已经是一个不区分触发来源的通用入口(时间轮询已经在用),地理围栏可以直接复用,不需要另起一套发送/待确认/超时重试逻辑。4. 本期范围
business/reminders/geofence_trigger.py(新):business.schedules的ScheduleRecord作为快照类型(跟time_window_trigger.py的做法一致),不新增GeofenceScheduleSnapshot。GeofenceQueryPort(Protocol):list_geofence_schedules(user_id),只返回有经纬度、status=scheduled、geo_triggered_at IS NULL的日程。geofence_radius_meters)时,按当前geofence_armed决定"无变化"/"离开后布防"/"已布防且命中,应触发"。TriggeredSchedule(schedule_id, user_id, reason="geofence_entered")(复用business/reminders/reminder_dispatch.py已有的类型),不需要新的数据结构来表达"待下发的提醒"。data/schedule_dispatch.py里的 Command Port,加set_geofence_armed(schedule_id, armed)、mark_geo_triggered(schedule_id, triggered_at)两个方法。data/schedule_dispatch.py扩展:加list_geofence_schedules的实现;对应写方法的实现。(这两个 Port 现在合在一个适配器类里,时间维度已经是这样,地理维度保持一致,不重新拆分)infrastructure/websocket/接location.report:新 handler 只做解析(LocationReport.model_validate)+ 调用GeofenceTriggerService+ 把命中结果转给dispatcher.dispatch(...),返回location.report.ack,不写判断逻辑。ReminderDispatcher.dispatch([...]),复用它已经实现好的发送、待确认登记、超时重试、来源设备校验——不重新实现一套。geo_triggered_at。5. 明确不做
geofence_armed初始值的计算逻辑(不在本期范围)。AlarmManager离线兜底),已经合并。has_left_geofence/created_inside_geofence字段(现有geofence_armed已经够用)。6. 关键决策
location.report(事件驱动)geofence_armed单字段ReminderDispatcher.dispatch(...),不经过系统引用检查reminder.control只有schedule_id/reason/action,跟时间提醒统一reminder.audio.*接口,不挂在reminder.control上geo_triggered_at,查询时过滤7. 边界与异常
geofence_armedgeofence_armed=false且已离开围栏true,不触发提醒geofence_armed=true且命中进入围栏geofence_armed=false且仍在围栏内(未离开过)geo_triggered_at IS NULL过滤,跳过ReminderDispatcher._send()静默失败,不影响其他日程ReminderDispatcher已有的 30 秒超时重发、最多 3 次的机制8. 基本概念与信息结构
9. 验收标准
geofence_armed=true且命中进入围栏时,通过ReminderDispatcher.dispatch(...)直接下发一次提醒。geofence_armed=false时不会触发,状态不变。geofence_armed=false且判定为离开围栏后,状态变为true,不触发提醒。reminder.control格式和时间提醒完全一致(schedule_id/reason="geofence_entered"/action),复用同一套 ack/超时/重试机制。geofence_armed/geo_triggered_at都不变。