Repository navigation
feat(backend): 地理围栏提醒闭环 + 提醒确认置 done + 音频下发有界等待 #128
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
LUPENGHAN
merged 3 commits into
1024XEngineer:MVP
from
LUPENGHAN:feature/geofence-reminder-trigger
Jul 31, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
69 changes: 69 additions & 0 deletions
69
backend/src/timeflow/business/reminders/geofence_trigger.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,69 @@ | ||
| """日程地理围栏提醒的判定逻辑。""" | ||
|
|
||
| import math | ||
| from collections.abc import Iterable | ||
| from enum import Enum | ||
| from typing import Protocol | ||
|
|
||
| from timeflow.business.schedules import ScheduleRecord | ||
|
|
||
| _EARTH_RADIUS_METERS = 6_371_000.0 | ||
|
|
||
|
|
||
| class GeofenceQueryPort(Protocol): | ||
| """business.reminders 对外声明的 Query Port,由 data 层实现。""" | ||
|
|
||
| def list_geofence_schedules(self, user_id: str) -> Iterable[ScheduleRecord]: | ||
| """返回该用户名下状态为 scheduled、绑定了经纬度且尚未触发过地理提醒的日程。""" | ||
| ... | ||
|
|
||
|
|
||
| class GeofenceTransition(Enum): | ||
| """一次位置上报相对某条日程围栏状态的变化。""" | ||
|
|
||
| NO_CHANGE = "no_change" | ||
| ARMED = "armed" | ||
| TRIGGERED = "triggered" | ||
|
|
||
|
|
||
| def evaluate_geofence(schedule: ScheduleRecord, latitude: float, longitude: float) -> GeofenceTransition: | ||
| """判定这次上报的位置相对该日程围栏的状态变化。 | ||
|
|
||
| `geofence_armed=False` 表示"当前在围栏内、还没离开过"(比如日程创建时人已经在围栏内); | ||
| 只有先侦测到离开(布防),之后再次进入才算命中,避免创建时立刻误触发。 | ||
| """ | ||
| is_inside = _distance_meters(schedule.latitude, schedule.longitude, latitude, longitude) <= ( | ||
| schedule.geofence_radius_meters | ||
| ) | ||
| if schedule.geofence_armed: | ||
| return GeofenceTransition.TRIGGERED if is_inside else GeofenceTransition.NO_CHANGE | ||
| return GeofenceTransition.NO_CHANGE if is_inside else GeofenceTransition.ARMED | ||
|
|
||
|
|
||
| class GeofenceTriggerService: | ||
| """判定一次位置上报命中了哪些日程的围栏变化。""" | ||
|
|
||
| def __init__(self, query_port: GeofenceQueryPort) -> None: | ||
| self._query_port = query_port | ||
|
|
||
| def find_geofence_transitions( | ||
| self, user_id: str, latitude: float, longitude: float | ||
| ) -> list[tuple[ScheduleRecord, GeofenceTransition]]: | ||
| """返回该用户名下所有发生了状态变化(非 NO_CHANGE)的日程及其变化类型。""" | ||
| results: list[tuple[ScheduleRecord, GeofenceTransition]] = [] | ||
| for schedule in self._query_port.list_geofence_schedules(user_id): | ||
| transition = evaluate_geofence(schedule, latitude, longitude) | ||
| if transition is not GeofenceTransition.NO_CHANGE: | ||
| results.append((schedule, transition)) | ||
| return results | ||
|
|
||
|
|
||
| def _distance_meters(lat1: float | None, lon1: float | None, lat2: float, lon2: float) -> float: | ||
| """Haversine 公式计算两点间的球面距离(米)。""" | ||
| if lat1 is None or lon1 is None: | ||
| return math.inf | ||
| phi1, phi2 = math.radians(lat1), math.radians(lat2) | ||
| delta_phi = math.radians(lat2 - lat1) | ||
| delta_lambda = math.radians(lon2 - lon1) | ||
| a = math.sin(delta_phi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(delta_lambda / 2) ** 2 | ||
| return 2 * _EARTH_RADIUS_METERS * math.asin(math.sqrt(a)) |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
91 changes: 91 additions & 0 deletions
91
backend/src/timeflow/infrastructure/websocket/handlers/location.py
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,91 @@ | ||
| """WebSocket handlers for location reports (geofence trigger).""" | ||
|
|
||
| import asyncio | ||
| from collections.abc import Callable | ||
| from datetime import UTC, datetime | ||
| from typing import Any, Protocol | ||
|
|
||
| from pydantic import ValidationError | ||
|
|
||
| from timeflow.business.reminders.reminder_dispatch import TriggeredSchedule | ||
| from timeflow.infrastructure.websocket.envelope import build_error_envelope | ||
| from timeflow.infrastructure.websocket.messages.location import LocationReport, LocationReportAck | ||
|
|
||
|
|
||
| class _Dispatcher(Protocol): | ||
| async def dispatch(self, schedules: list[TriggeredSchedule], now: datetime) -> None: ... | ||
|
|
||
|
|
||
| class _Sender(Protocol): | ||
| async def send(self, device_id: str, message: dict[str, Any]) -> bool: ... | ||
|
|
||
|
|
||
| class LocationWebSocketHandlers: | ||
| """Adapt `location.report` to the geofence trigger use case + reminder dispatch.""" | ||
|
|
||
| def __init__( | ||
| self, | ||
| run_report: Callable[[str, float, float, datetime], list[TriggeredSchedule]], | ||
| dispatcher: _Dispatcher, | ||
| connections: _Sender, | ||
| ) -> None: | ||
| self._run_report = run_report | ||
| self._dispatcher = dispatcher | ||
| self._connections = connections | ||
|
|
||
| async def handle_report(self, raw_message: dict[str, Any], device_id: str) -> None: | ||
| """Handle `location.report`: validate, judge geofence transitions, dispatch hits. | ||
|
|
||
| 自己发 `location.report.ack` 而不是 return 给路由层代发,是为了保证顺序: | ||
| 先把这次上报的 ack 回出去,再推可能命中的 `reminder.control`。如果 return 给 | ||
| 路由层,dispatch 就必须发生在 return 之前,客户端会先收到提醒再收到 ack。 | ||
| """ | ||
| error = self._validate(raw_message) | ||
| if error is not None: | ||
| await self._connections.send(device_id, error) | ||
| return None | ||
|
|
||
| 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 | ||
| ) | ||
| await self._connections.send( | ||
| device_id, LocationReportAck(ok=True).model_dump(exclude_none=True) | ||
| ) | ||
| await self._dispatcher.dispatch(triggered, now) | ||
| return None | ||
|
|
||
| @staticmethod | ||
| def _validate(raw_message: dict[str, Any]) -> dict[str, Any] | None: | ||
| """返回错误信封;`None` 表示这条上报合法。""" | ||
| try: | ||
| message = LocationReport.model_validate(raw_message) | ||
| except ValidationError as exc: | ||
| return build_error_envelope( | ||
| "location.report.ack", | ||
| None, | ||
| "INVALID_LOCATION", | ||
| "位置信息不合法", | ||
| {"errors": exc.errors()}, | ||
| ) | ||
| if not -90 <= message.latitude <= 90: | ||
| return build_error_envelope( | ||
| "location.report.ack", | ||
|
LUPENGHAN marked this conversation as resolved.
|
||
| None, | ||
| "INVALID_LOCATION", | ||
| "位置信息不合法", | ||
| {"field": "latitude"}, | ||
| ) | ||
| if not -180 <= message.longitude <= 180: | ||
| return build_error_envelope( | ||
| "location.report.ack", | ||
| None, | ||
| "INVALID_LOCATION", | ||
| "位置信息不合法", | ||
| {"field": "longitude"}, | ||
| ) | ||
| return None | ||
|
|
||
|
|
||
| __all__ = ["LocationWebSocketHandlers"] | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
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.
ScheduleWebSocketHandlersignoresdevice_id, andScheduleServicestores every schedule underdefault_user, but this query uses the arbitrary WebSocketdevice_id. A client connected asdevice_1can create a schedule successfully, then every location report queriesuser_id='device_1'and finds nothing, so geofence reminders never fire. The lookup and dispatch destination need a consistent user/device mapping.