Add bind-time initial location fallback
This commit is contained in:
@@ -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()
|
||||
|
||||
@@ -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<void> {
|
||||
return request(`/banban/bindings/${deviceId}`, {
|
||||
method: 'DELETE',
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 == []
|
||||
|
||||
Reference in New Issue
Block a user