From f33205d1bdb2088b0a9457e23ddc669aa67a2ba8 Mon Sep 17 00:00:00 2001 From: stu2not Date: Tue, 30 Jun 2026 08:05:01 +0800 Subject: [PATCH] Add bind-time initial location fallback --- banban-mini/src/pages/bind/index.tsx | 25 +++ banban-mini/src/services/binding.ts | 17 ++ talkingq-url/banban/dao/location.py | 42 +++++ talkingq-url/banban/routers/bindings.py | 32 ++++ talkingq-url/banban/service/location.py | 49 ++++++ .../test_location_coordinate_normalization.py | 165 ++++++++++++++++++ 6 files changed, 330 insertions(+) diff --git a/banban-mini/src/pages/bind/index.tsx b/banban-mini/src/pages/bind/index.tsx index 294f666..c13f9a9 100644 --- a/banban-mini/src/pages/bind/index.tsx +++ b/banban-mini/src/pages/bind/index.tsx @@ -5,6 +5,7 @@ import Taro, { useDidShow, useDidHide, useRouter } from '@tarojs/taro' import { getToken } from '@/services/auth' import { getNFCBindSession, + reportBindInitialLocation, resolveActiveBinding, setBindingChild, setSelectedBindingDeviceId, @@ -210,6 +211,26 @@ export default function Bind() { Taro.switchTab({ url: '/pages/sleep/index' }) } + const uploadBindInitialLocation = async (targetDeviceId?: string | null) => { + const normalizedDeviceId = String(targetDeviceId || '').trim() + if (!normalizedDeviceId) return + + try { + const location = await Taro.getLocation({ + type: 'gcj02', + isHighAccuracy: true, + }) + await reportBindInitialLocation(normalizedDeviceId, { + latitude: location.latitude, + longitude: location.longitude, + coord_type: 'gcj02', + accuracy_m: typeof location.accuracy === 'number' ? Math.round(location.accuracy) : undefined, + }) + } catch (error) { + console.log('[bind] initial location skipped:', error) + } + } + const handleBack = () => { if (getCurrentPages().length > 1) { Taro.navigateBack() @@ -335,6 +356,9 @@ export default function Bind() { if (session.status === SESSION_STATUS_COMPLETED) { setSelectedBindingDeviceId(session.device_id) + if (!isAddingCard && session.child_id) { + void uploadBindInitialLocation(session.device_id) + } setBindHint( isAddingCard ? session.card_uuid ? `新卡添加完成,卡号 ${session.card_uuid}` : '新卡添加完成' @@ -480,6 +504,7 @@ export default function Bind() { } await setBindingChild(pendingDeviceId!, { child_id: childId }) setSelectedBindingDeviceId(pendingDeviceId) + void uploadBindInitialLocation(pendingDeviceId) Taro.showToast({ title: '关联完成', icon: 'success' }) setTimeout(() => { goDevicePage() diff --git a/banban-mini/src/services/binding.ts b/banban-mini/src/services/binding.ts index 6e5c5f4..fcb55ce 100644 --- a/banban-mini/src/services/binding.ts +++ b/banban-mini/src/services/binding.ts @@ -87,6 +87,13 @@ export interface NFCSessionStatus { card_uuid?: string | null } +export interface BindInitialLocationPayload { + latitude: number + longitude: number + coord_type: 'gcj02' + accuracy_m?: number +} + export function getSelectedBindingDeviceId(): string | null { const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY) const deviceId = String(value || '').trim() @@ -211,6 +218,16 @@ export async function setBindingChild( }) } +export async function reportBindInitialLocation( + deviceId: string, + data: BindInitialLocationPayload +): Promise<{ accepted: boolean; inserted: boolean }> { + return request(`/banban/bindings/${deviceId}/initial-location`, { + method: 'POST', + data, + }) +} + export async function unbindDevice(deviceId: string): Promise { return request(`/banban/bindings/${deviceId}`, { method: 'DELETE', diff --git a/talkingq-url/banban/dao/location.py b/talkingq-url/banban/dao/location.py index 137c77e..d9fb962 100644 --- a/talkingq-url/banban/dao/location.py +++ b/talkingq-url/banban/dao/location.py @@ -18,6 +18,16 @@ class ParentDeviceAccess: class LocationDAO(BaseDAO): + @staticmethod + def _valid_coordinate_sql() -> str: + return """ + lat IS NOT NULL + AND lng IS NOT NULL + AND lat BETWEEN -90 AND 90 + AND lng BETWEEN -180 AND 180 + AND NOT (ABS(lat) < 0.0000001 AND ABS(lng) < 0.0000001) + """ + async def get_active_binding_by_device(self, *, device_id: str) -> Mapping[str, Any] | None: result = await self.execute( text( @@ -33,6 +43,38 @@ class LocationDAO(BaseDAO): ) return result.mappings().first() + async def has_valid_location(self, *, device_id: str, child_id: int) -> bool: + current_result = await self.execute( + text( + f""" + SELECT 1 + FROM child_location_current + WHERE child_id = :child_id + AND device_id = :device_id + AND {self._valid_coordinate_sql()} + LIMIT 1 + """ + ), + {"device_id": device_id, "child_id": child_id}, + ) + if current_result.first(): + return True + + history_result = await self.execute( + text( + f""" + SELECT 1 + FROM child_location_history + WHERE child_id = :child_id + AND device_id = :device_id + AND {self._valid_coordinate_sql()} + LIMIT 1 + """ + ), + {"device_id": device_id, "child_id": child_id}, + ) + return bool(history_result.first()) + async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess: from fastapi import HTTPException, status result = await self.execute( diff --git a/talkingq-url/banban/routers/bindings.py b/talkingq-url/banban/routers/bindings.py index ce90983..c78393f 100644 --- a/talkingq-url/banban/routers/bindings.py +++ b/talkingq-url/banban/routers/bindings.py @@ -13,6 +13,7 @@ from banban.dao.binding import ( ) from banban.security import get_current_user_id from banban.service.binding import BindingError, binding_service +from banban.service.location import location_service router = APIRouter(prefix="/bindings", tags=["bindings"]) @@ -208,6 +209,18 @@ class BindSetChildRequest(BaseModel): child_id: int +class BindInitialLocationRequest(BaseModel): + latitude: float + longitude: float + coord_type: str = "gcj02" + accuracy_m: int | None = None + + +class BindInitialLocationResponse(BaseModel): + accepted: bool + inserted: bool + + @router.post("/direct", response_model=DirectBindResponse) async def direct_bind( payload: DirectBindRequest, @@ -244,6 +257,25 @@ async def set_binding_child( return DirectBindResponse(**result) +@router.post("/{device_id}/initial-location", response_model=BindInitialLocationResponse) +async def report_bind_initial_location( + device_id: str, + payload: BindInitialLocationRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> BindInitialLocationResponse: + del request + row = await location_service.report_bind_initial_location( + device_id=device_id, + user_id=current_user_id, + latitude=payload.latitude, + longitude=payload.longitude, + coord_type=payload.coord_type, + accuracy_m=payload.accuracy_m, + ) + return BindInitialLocationResponse(accepted=True, inserted=row is not None) + + @router.get("/current", response_model=BindingGetResponse) async def get_current_binding( request: Request, diff --git a/talkingq-url/banban/service/location.py b/talkingq-url/banban/service/location.py index 4957c01..f548d20 100644 --- a/talkingq-url/banban/service/location.py +++ b/talkingq-url/banban/service/location.py @@ -23,6 +23,7 @@ except ModuleNotFoundError: ADDRESS_RESOLVE_PENDING = 0 ADDRESS_RESOLVE_SUCCESS = 1 ADDRESS_RESOLVE_FAILED = 2 +LOCATION_SOURCE_BIND_SNAPSHOT = 5 INVALID_COORDINATE_EPSILON = 0.0000001 GCJ02_COORD_TYPE = "gcj02" WGS84_COORD_TYPES = {"", "wgs84", "gps"} @@ -191,6 +192,54 @@ class LocationService(DatabaseServiceBase): finally: await db_session.close() + async def report_bind_initial_location( + self, + *, + device_id: str, + user_id: int, + latitude: float, + longitude: float, + coord_type: str | None, + accuracy_m: int | None = None, + device_time: datetime | None = None, + ) -> Mapping[str, Any] | None: + if not is_valid_coordinate_pair(latitude, longitude): + session_logger.warning( + device_id, + "location", + f"忽略无效绑定初始化定位: lat={latitude}, lng={longitude}", + ) + return None + + db_session = await self.get_session() + try: + dao = LocationDAO(db_session) + access = await dao.assert_parent_device_access(device_id=device_id, user_id=user_id) + if await dao.has_valid_location(device_id=device_id, child_id=access.child_id): + return None + + payload = self._normalized_location_payload( + coord_type=coord_type, + latitude=float(latitude), + longitude=float(longitude), + accuracy_m=accuracy_m, + altitude_m=None, + speed_mps=None, + heading_deg=None, + source=LOCATION_SOURCE_BIND_SNAPSHOT, + battery_pct=None, + device_time=device_time or datetime.now(), + ) + current_row = await dao.report_device_location( + device_id=device_id, + child_id=access.child_id, + payload=payload, + ) + await self._queue_location_address_resolution(current_row) + return current_row + finally: + await db_session.close() + async def insert_or_update(self, device_id: str, location: ChildLocationCurrent) -> None: db_session = await self.get_session() try: diff --git a/talkingq-url/tests/test_location_coordinate_normalization.py b/talkingq-url/tests/test_location_coordinate_normalization.py index 6af1695..78ff287 100644 --- a/talkingq-url/tests/test_location_coordinate_normalization.py +++ b/talkingq-url/tests/test_location_coordinate_normalization.py @@ -1,6 +1,7 @@ import pytest from banban.schemas.location import DeviceLocationReportRequest +from banban.dao.location import LocationDAO from banban.service.location import ( LocationService, normalize_location_coordinate, @@ -115,3 +116,167 @@ async def test_http_location_report_without_coord_type_converts_before_persist(m assert persisted_payload.coord_type == "gcj02" assert persisted_payload.lat == pytest.approx(30.22066658053894) assert persisted_payload.lng == pytest.approx(120.26284572518867) + + +@pytest.mark.asyncio +async def test_bind_initial_location_writes_only_when_no_valid_location(monkeypatch): + service = LocationService() + captured = {} + + class FakeAccess: + child_id = 9 + + class FakeSession: + async def close(self): + captured["closed"] = True + + class FakeLocationDAO: + def __init__(self, session): + captured["session"] = session + + async def assert_parent_device_access(self, *, device_id, user_id): + captured["access"] = (device_id, user_id) + return FakeAccess() + + async def has_valid_location(self, *, device_id, child_id): + captured["valid_check"] = (device_id, child_id) + return False + + async def report_device_location(self, *, device_id, child_id, payload): + captured["report"] = (device_id, child_id, payload) + return { + "child_id": child_id, + "device_id": device_id, + "coord_type": payload.coord_type, + "lat": payload.lat, + "lng": payload.lng, + "should_resolve_address": False, + } + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr("banban.service.location.LocationDAO", FakeLocationDAO) + monkeypatch.setattr(service, "get_session", fake_get_session) + + row = await service.report_bind_initial_location( + device_id="TalkingQ_device001", + user_id=3, + latitude=30.223122166666666, + longitude=120.25837883333334, + coord_type="gcj02", + accuracy_m=20, + ) + + _, _, payload = captured["report"] + assert captured["access"] == ("TalkingQ_device001", 3) + assert captured["valid_check"] == ("TalkingQ_device001", 9) + assert captured["closed"] is True + assert row["coord_type"] == "gcj02" + assert payload.coord_type == "gcj02" + assert payload.lat == pytest.approx(30.223122166666666) + assert payload.lng == pytest.approx(120.25837883333334) + assert payload.source == 5 + assert payload.accuracy_m == 20 + + +@pytest.mark.asyncio +async def test_bind_initial_location_skips_when_valid_location_exists(monkeypatch): + service = LocationService() + captured = {} + + class FakeAccess: + child_id = 9 + + class FakeSession: + async def close(self): + captured["closed"] = True + + class FakeLocationDAO: + def __init__(self, session): + del session + + async def assert_parent_device_access(self, *, device_id, user_id): + captured["access"] = (device_id, user_id) + return FakeAccess() + + async def has_valid_location(self, *, device_id, child_id): + captured["valid_check"] = (device_id, child_id) + return True + + async def report_device_location(self, **kwargs): + captured["report"] = kwargs + raise AssertionError("initial location must not overwrite an existing valid location") + + async def fake_get_session(): + return FakeSession() + + monkeypatch.setattr("banban.service.location.LocationDAO", FakeLocationDAO) + monkeypatch.setattr(service, "get_session", fake_get_session) + + row = await service.report_bind_initial_location( + device_id="TalkingQ_device001", + user_id=3, + latitude=30.223122166666666, + longitude=120.25837883333334, + coord_type="gcj02", + ) + + assert row is None + assert captured["access"] == ("TalkingQ_device001", 3) + assert captured["valid_check"] == ("TalkingQ_device001", 9) + assert captured["closed"] is True + assert "report" not in captured + + +@pytest.mark.asyncio +async def test_has_valid_location_checks_history_when_current_is_empty(): + calls = [] + + class FakeResult: + def __init__(self, row): + self.row = row + + def first(self): + return self.row + + class FakeDB: + async def execute(self, statement, params=None): + calls.append((str(statement), params)) + if len(calls) == 1: + return FakeResult(None) + return FakeResult((1,)) + + dao = LocationDAO(FakeDB()) + + exists = await dao.has_valid_location(device_id="TalkingQ_device001", child_id=9) + + assert exists is True + assert len(calls) == 2 + assert "FROM child_location_current" in calls[0][0] + assert "FROM child_location_history" in calls[1][0] + assert calls[0][1] == {"device_id": "TalkingQ_device001", "child_id": 9} + assert calls[1][1] == {"device_id": "TalkingQ_device001", "child_id": 9} + + +@pytest.mark.asyncio +async def test_bind_initial_location_ignores_invalid_coordinate_before_db(monkeypatch): + service = LocationService() + sessions = [] + + async def fail_get_session(): + sessions.append("called") + raise AssertionError("invalid bind location should not open a database session") + + monkeypatch.setattr(service, "get_session", fail_get_session) + + row = await service.report_bind_initial_location( + device_id="TalkingQ_device001", + user_id=3, + latitude=0, + longitude=0, + coord_type="gcj02", + ) + + assert row is None + assert sessions == []