Add bind-time initial location fallback

This commit is contained in:
stu2not
2026-06-30 08:05:01 +08:00
parent 0283aa2fec
commit f33205d1bd
6 changed files with 330 additions and 0 deletions

View File

@@ -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()

View File

@@ -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',

View File

@@ -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(

View File

@@ -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,

View File

@@ -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:

View File

@@ -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 == []