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 { getToken } from '@/services/auth'
|
||||||
import {
|
import {
|
||||||
getNFCBindSession,
|
getNFCBindSession,
|
||||||
|
reportBindInitialLocation,
|
||||||
resolveActiveBinding,
|
resolveActiveBinding,
|
||||||
setBindingChild,
|
setBindingChild,
|
||||||
setSelectedBindingDeviceId,
|
setSelectedBindingDeviceId,
|
||||||
@@ -210,6 +211,26 @@ export default function Bind() {
|
|||||||
Taro.switchTab({ url: '/pages/sleep/index' })
|
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 = () => {
|
const handleBack = () => {
|
||||||
if (getCurrentPages().length > 1) {
|
if (getCurrentPages().length > 1) {
|
||||||
Taro.navigateBack()
|
Taro.navigateBack()
|
||||||
@@ -335,6 +356,9 @@ export default function Bind() {
|
|||||||
|
|
||||||
if (session.status === SESSION_STATUS_COMPLETED) {
|
if (session.status === SESSION_STATUS_COMPLETED) {
|
||||||
setSelectedBindingDeviceId(session.device_id)
|
setSelectedBindingDeviceId(session.device_id)
|
||||||
|
if (!isAddingCard && session.child_id) {
|
||||||
|
void uploadBindInitialLocation(session.device_id)
|
||||||
|
}
|
||||||
setBindHint(
|
setBindHint(
|
||||||
isAddingCard
|
isAddingCard
|
||||||
? session.card_uuid ? `新卡添加完成,卡号 ${session.card_uuid}` : '新卡添加完成'
|
? session.card_uuid ? `新卡添加完成,卡号 ${session.card_uuid}` : '新卡添加完成'
|
||||||
@@ -480,6 +504,7 @@ export default function Bind() {
|
|||||||
}
|
}
|
||||||
await setBindingChild(pendingDeviceId!, { child_id: childId })
|
await setBindingChild(pendingDeviceId!, { child_id: childId })
|
||||||
setSelectedBindingDeviceId(pendingDeviceId)
|
setSelectedBindingDeviceId(pendingDeviceId)
|
||||||
|
void uploadBindInitialLocation(pendingDeviceId)
|
||||||
Taro.showToast({ title: '关联完成', icon: 'success' })
|
Taro.showToast({ title: '关联完成', icon: 'success' })
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
goDevicePage()
|
goDevicePage()
|
||||||
|
|||||||
@@ -87,6 +87,13 @@ export interface NFCSessionStatus {
|
|||||||
card_uuid?: string | null
|
card_uuid?: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface BindInitialLocationPayload {
|
||||||
|
latitude: number
|
||||||
|
longitude: number
|
||||||
|
coord_type: 'gcj02'
|
||||||
|
accuracy_m?: number
|
||||||
|
}
|
||||||
|
|
||||||
export function getSelectedBindingDeviceId(): string | null {
|
export function getSelectedBindingDeviceId(): string | null {
|
||||||
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
const value = Taro.getStorageSync(SELECTED_BINDING_DEVICE_ID_KEY)
|
||||||
const deviceId = String(value || '').trim()
|
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> {
|
export async function unbindDevice(deviceId: string): Promise<void> {
|
||||||
return request(`/banban/bindings/${deviceId}`, {
|
return request(`/banban/bindings/${deviceId}`, {
|
||||||
method: 'DELETE',
|
method: 'DELETE',
|
||||||
|
|||||||
@@ -18,6 +18,16 @@ class ParentDeviceAccess:
|
|||||||
|
|
||||||
|
|
||||||
class LocationDAO(BaseDAO):
|
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:
|
async def get_active_binding_by_device(self, *, device_id: str) -> Mapping[str, Any] | None:
|
||||||
result = await self.execute(
|
result = await self.execute(
|
||||||
text(
|
text(
|
||||||
@@ -33,6 +43,38 @@ class LocationDAO(BaseDAO):
|
|||||||
)
|
)
|
||||||
return result.mappings().first()
|
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:
|
async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess:
|
||||||
from fastapi import HTTPException, status
|
from fastapi import HTTPException, status
|
||||||
result = await self.execute(
|
result = await self.execute(
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ from banban.dao.binding import (
|
|||||||
)
|
)
|
||||||
from banban.security import get_current_user_id
|
from banban.security import get_current_user_id
|
||||||
from banban.service.binding import BindingError, binding_service
|
from banban.service.binding import BindingError, binding_service
|
||||||
|
from banban.service.location import location_service
|
||||||
|
|
||||||
|
|
||||||
router = APIRouter(prefix="/bindings", tags=["bindings"])
|
router = APIRouter(prefix="/bindings", tags=["bindings"])
|
||||||
@@ -208,6 +209,18 @@ class BindSetChildRequest(BaseModel):
|
|||||||
child_id: int
|
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)
|
@router.post("/direct", response_model=DirectBindResponse)
|
||||||
async def direct_bind(
|
async def direct_bind(
|
||||||
payload: DirectBindRequest,
|
payload: DirectBindRequest,
|
||||||
@@ -244,6 +257,25 @@ async def set_binding_child(
|
|||||||
return DirectBindResponse(**result)
|
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)
|
@router.get("/current", response_model=BindingGetResponse)
|
||||||
async def get_current_binding(
|
async def get_current_binding(
|
||||||
request: Request,
|
request: Request,
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ except ModuleNotFoundError:
|
|||||||
ADDRESS_RESOLVE_PENDING = 0
|
ADDRESS_RESOLVE_PENDING = 0
|
||||||
ADDRESS_RESOLVE_SUCCESS = 1
|
ADDRESS_RESOLVE_SUCCESS = 1
|
||||||
ADDRESS_RESOLVE_FAILED = 2
|
ADDRESS_RESOLVE_FAILED = 2
|
||||||
|
LOCATION_SOURCE_BIND_SNAPSHOT = 5
|
||||||
INVALID_COORDINATE_EPSILON = 0.0000001
|
INVALID_COORDINATE_EPSILON = 0.0000001
|
||||||
GCJ02_COORD_TYPE = "gcj02"
|
GCJ02_COORD_TYPE = "gcj02"
|
||||||
WGS84_COORD_TYPES = {"", "wgs84", "gps"}
|
WGS84_COORD_TYPES = {"", "wgs84", "gps"}
|
||||||
@@ -191,6 +192,54 @@ class LocationService(DatabaseServiceBase):
|
|||||||
finally:
|
finally:
|
||||||
await db_session.close()
|
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:
|
async def insert_or_update(self, device_id: str, location: ChildLocationCurrent) -> None:
|
||||||
db_session = await self.get_session()
|
db_session = await self.get_session()
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import pytest
|
import pytest
|
||||||
|
|
||||||
from banban.schemas.location import DeviceLocationReportRequest
|
from banban.schemas.location import DeviceLocationReportRequest
|
||||||
|
from banban.dao.location import LocationDAO
|
||||||
from banban.service.location import (
|
from banban.service.location import (
|
||||||
LocationService,
|
LocationService,
|
||||||
normalize_location_coordinate,
|
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.coord_type == "gcj02"
|
||||||
assert persisted_payload.lat == pytest.approx(30.22066658053894)
|
assert persisted_payload.lat == pytest.approx(30.22066658053894)
|
||||||
assert persisted_payload.lng == pytest.approx(120.26284572518867)
|
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