417 lines
14 KiB
Python
417 lines
14 KiB
Python
from collections.abc import Mapping
|
|
from dataclasses import dataclass
|
|
from datetime import datetime
|
|
import math
|
|
from typing import Any
|
|
|
|
from services.database_service_base import DatabaseServiceBase
|
|
from database.models import ChildLocationCurrent
|
|
from services.task_manager import task_manager
|
|
from utils.logger import session_logger
|
|
try:
|
|
from banban.dao.location import LocationDAO, ParentDeviceAccess
|
|
from banban.schemas.location import DeviceLocationReportRequest
|
|
from banban.service.amap_geocode import amap_geocode_service
|
|
from banban.service.im import im_service
|
|
except ModuleNotFoundError:
|
|
from banban.dao.location import LocationDAO, ParentDeviceAccess
|
|
from banban.schemas.location import DeviceLocationReportRequest
|
|
from banban.service.amap_geocode import amap_geocode_service
|
|
from banban.service.im import im_service
|
|
|
|
|
|
ADDRESS_RESOLVE_PENDING = 0
|
|
ADDRESS_RESOLVE_SUCCESS = 1
|
|
ADDRESS_RESOLVE_FAILED = 2
|
|
INVALID_COORDINATE_EPSILON = 0.0000001
|
|
GCJ02_COORD_TYPE = "gcj02"
|
|
WGS84_COORD_TYPES = {"", "wgs84", "gps"}
|
|
WGS84_TO_GCJ02_A = 6378245.0
|
|
WGS84_TO_GCJ02_EE = 0.00669342162296594323
|
|
|
|
|
|
def is_valid_coordinate_pair(latitude: float | None, longitude: float | None) -> bool:
|
|
if latitude is None or longitude is None:
|
|
return False
|
|
try:
|
|
lat = float(latitude)
|
|
lng = float(longitude)
|
|
except (TypeError, ValueError):
|
|
return False
|
|
if lat < -90 or lat > 90 or lng < -180 or lng > 180:
|
|
return False
|
|
return not (abs(lat) < INVALID_COORDINATE_EPSILON and abs(lng) < INVALID_COORDINATE_EPSILON)
|
|
|
|
|
|
def normalize_location_coordinate(
|
|
*,
|
|
latitude: float,
|
|
longitude: float,
|
|
coord_type: str | None,
|
|
) -> tuple[float, float, str]:
|
|
normalized_type = str(coord_type or "").strip().lower()
|
|
if normalized_type == GCJ02_COORD_TYPE:
|
|
return float(latitude), float(longitude), GCJ02_COORD_TYPE
|
|
if normalized_type in WGS84_COORD_TYPES:
|
|
lng, lat = wgs84_to_gcj02(float(longitude), float(latitude))
|
|
return lat, lng, GCJ02_COORD_TYPE
|
|
return float(latitude), float(longitude), normalized_type
|
|
|
|
|
|
def wgs84_to_gcj02(longitude: float, latitude: float) -> tuple[float, float]:
|
|
if _is_out_of_china(longitude, latitude):
|
|
return longitude, latitude
|
|
|
|
dlat = _transform_lat(longitude - 105.0, latitude - 35.0)
|
|
dlng = _transform_lng(longitude - 105.0, latitude - 35.0)
|
|
radlat = latitude / 180.0 * math.pi
|
|
magic = math.sin(radlat)
|
|
magic = 1 - WGS84_TO_GCJ02_EE * magic * magic
|
|
sqrtmagic = math.sqrt(magic)
|
|
dlat = (dlat * 180.0) / (
|
|
(WGS84_TO_GCJ02_A * (1 - WGS84_TO_GCJ02_EE)) / (magic * sqrtmagic) * math.pi
|
|
)
|
|
dlng = (dlng * 180.0) / (
|
|
WGS84_TO_GCJ02_A / sqrtmagic * math.cos(radlat) * math.pi
|
|
)
|
|
return longitude + dlng, latitude + dlat
|
|
|
|
|
|
def _is_out_of_china(longitude: float, latitude: float) -> bool:
|
|
return not (72.004 <= longitude <= 137.8347 and 0.8293 <= latitude <= 55.8271)
|
|
|
|
|
|
def _transform_lat(x: float, y: float) -> float:
|
|
ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y
|
|
ret += 0.2 * math.sqrt(abs(x))
|
|
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
|
ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0
|
|
ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0
|
|
return ret
|
|
|
|
|
|
def _transform_lng(x: float, y: float) -> float:
|
|
ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y
|
|
ret += 0.1 * math.sqrt(abs(x))
|
|
ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0
|
|
ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0
|
|
ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0
|
|
return ret
|
|
|
|
|
|
class LocationService(DatabaseServiceBase):
|
|
def __init__(self):
|
|
super().__init__(service_name="location_service")
|
|
|
|
async def assert_parent_device_access(self, *, device_id: str, user_id: int) -> ParentDeviceAccess:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
return await dao.assert_parent_device_access(device_id=device_id, user_id=user_id)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def report_device_location(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
serial_number: str,
|
|
payload: DeviceLocationReportRequest,
|
|
) -> tuple[Any, Mapping[str, Any] | None]:
|
|
device_identity = await im_service.authenticate_device_identity(
|
|
device_id=device_id,
|
|
serial_number=serial_number,
|
|
)
|
|
if not is_valid_coordinate_pair(payload.lat, payload.lng):
|
|
session_logger.warning(
|
|
device_id,
|
|
"location",
|
|
f"忽略无效设备定位: lat={payload.lat}, lng={payload.lng}",
|
|
)
|
|
return device_identity, None
|
|
|
|
payload = self._normalized_location_payload(
|
|
coord_type=self._get_explicit_coord_type(payload),
|
|
latitude=payload.lat,
|
|
longitude=payload.lng,
|
|
accuracy_m=payload.accuracy_m,
|
|
altitude_m=payload.altitude_m,
|
|
speed_mps=payload.speed_mps,
|
|
heading_deg=payload.heading_deg,
|
|
source=payload.source,
|
|
battery_pct=payload.battery_pct,
|
|
device_time=payload.device_time,
|
|
)
|
|
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
current_row = await dao.report_device_location(
|
|
device_id=device_id,
|
|
child_id=device_identity.child_id,
|
|
payload=payload,
|
|
)
|
|
await self._queue_location_address_resolution(current_row)
|
|
return device_identity, current_row
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def get_device_current_location(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
user_id: int,
|
|
) -> Mapping[str, Any]:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
return await dao.get_device_current_location(device_id=device_id, user_id=user_id)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def get_device_trajectory(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
user_id: int,
|
|
start_at: datetime | None,
|
|
end_at: datetime | None,
|
|
limit: int,
|
|
) -> tuple[ParentDeviceAccess, list[Mapping[str, Any]]]:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
return await dao.get_device_trajectory(
|
|
device_id=device_id,
|
|
user_id=user_id,
|
|
start_at=start_at,
|
|
end_at=end_at,
|
|
limit=limit,
|
|
)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def insert_or_update(self, device_id: str, location: ChildLocationCurrent) -> None:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
current_row = await dao.get_current_location_by_device_id(device_id=device_id)
|
|
if current_row:
|
|
await dao.update(device_id=device_id, latitude=location.lat, longitude=location.lng)
|
|
# 更新成功加到历史记录表
|
|
else:
|
|
await dao.insert(device_id=device_id, latitude=location.lat, longitude=location.lng)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
@dataclass(frozen=True)
|
|
class _LocationPayload:
|
|
coord_type: str
|
|
lat: float
|
|
lng: float
|
|
accuracy_m: int | None
|
|
altitude_m: float | None
|
|
speed_mps: float | None
|
|
heading_deg: int | None
|
|
source: int
|
|
battery_pct: int | None
|
|
device_time: datetime
|
|
|
|
def _normalized_location_payload(
|
|
self,
|
|
*,
|
|
coord_type: str | None,
|
|
latitude: float,
|
|
longitude: float,
|
|
accuracy_m: int | None,
|
|
altitude_m: float | None,
|
|
speed_mps: float | None,
|
|
heading_deg: int | None,
|
|
source: int,
|
|
battery_pct: int | None,
|
|
device_time: datetime,
|
|
) -> _LocationPayload:
|
|
normalized_lat, normalized_lng, normalized_coord_type = normalize_location_coordinate(
|
|
latitude=float(latitude),
|
|
longitude=float(longitude),
|
|
coord_type=coord_type,
|
|
)
|
|
return self._LocationPayload(
|
|
coord_type=normalized_coord_type,
|
|
lat=normalized_lat,
|
|
lng=normalized_lng,
|
|
accuracy_m=accuracy_m,
|
|
altitude_m=altitude_m,
|
|
speed_mps=speed_mps,
|
|
heading_deg=heading_deg,
|
|
source=source,
|
|
battery_pct=battery_pct,
|
|
device_time=device_time,
|
|
)
|
|
|
|
@staticmethod
|
|
def _get_explicit_coord_type(payload: DeviceLocationReportRequest) -> str | None:
|
|
fields_set = getattr(payload, "model_fields_set", None)
|
|
if fields_set is None:
|
|
fields_set = getattr(payload, "__fields_set__", set())
|
|
if "coord_type" not in fields_set:
|
|
return None
|
|
return payload.coord_type
|
|
|
|
async def report_mqtt_device_location(
|
|
self,
|
|
*,
|
|
device_id: str,
|
|
latitude: float | None,
|
|
longitude: float | None,
|
|
coord_type: str | None = None,
|
|
accuracy_m: int | None = None,
|
|
altitude_m: float | None = None,
|
|
speed_mps: float | None = None,
|
|
heading_deg: int | None = None,
|
|
source: int | str | None = None,
|
|
battery_pct: int | None = None,
|
|
device_time: datetime | None = None,
|
|
) -> Mapping[str, Any] | None:
|
|
if not is_valid_coordinate_pair(latitude, longitude):
|
|
if latitude is not None and longitude is not None:
|
|
session_logger.warning(
|
|
device_id,
|
|
"location",
|
|
f"忽略无效MQTT定位: lat={latitude}, lng={longitude}",
|
|
)
|
|
return None
|
|
|
|
source_value = self._normalize_location_source(source)
|
|
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
binding_row = await dao.get_active_binding_by_device(device_id=device_id)
|
|
if not binding_row or binding_row["child_id"] is None:
|
|
return None
|
|
|
|
payload = self._normalized_location_payload(
|
|
coord_type=coord_type,
|
|
latitude=float(latitude),
|
|
longitude=float(longitude),
|
|
accuracy_m=accuracy_m,
|
|
altitude_m=altitude_m,
|
|
speed_mps=speed_mps,
|
|
heading_deg=heading_deg,
|
|
source=source_value,
|
|
battery_pct=battery_pct,
|
|
device_time=device_time or datetime.now(),
|
|
)
|
|
current_row = await dao.report_device_location(
|
|
device_id=device_id,
|
|
child_id=int(binding_row["child_id"]),
|
|
payload=payload,
|
|
)
|
|
await self._queue_location_address_resolution(current_row)
|
|
return current_row
|
|
finally:
|
|
await db_session.close()
|
|
|
|
async def _queue_location_address_resolution(self, row: Mapping[str, Any] | None) -> None:
|
|
if not row or row.get("lat") is None or row.get("lng") is None:
|
|
return
|
|
if not is_valid_coordinate_pair(row.get("lat"), row.get("lng")):
|
|
return
|
|
if not row.get("should_resolve_address"):
|
|
return
|
|
await task_manager.create_task(
|
|
self.resolve_current_location_address(
|
|
child_id=int(row["child_id"]),
|
|
device_id=str(row["device_id"]),
|
|
lat=float(row["lat"]),
|
|
lng=float(row["lng"]),
|
|
),
|
|
device_id=str(row["device_id"]),
|
|
task_type="location_address",
|
|
)
|
|
|
|
async def resolve_current_location_address(
|
|
self,
|
|
*,
|
|
child_id: int,
|
|
device_id: str,
|
|
lat: float,
|
|
lng: float,
|
|
) -> None:
|
|
if not is_valid_coordinate_pair(lat, lng):
|
|
return
|
|
try:
|
|
result = await amap_geocode_service.reverse_geocode(lng=lng, lat=lat)
|
|
except Exception as exc:
|
|
session_logger.warning(
|
|
device_id,
|
|
"location_address",
|
|
f"当前位置地址解析失败: child_id={child_id}, error={exc}",
|
|
)
|
|
await self.update_current_location_address(
|
|
child_id=child_id,
|
|
device_id=device_id,
|
|
lat=lat,
|
|
lng=lng,
|
|
address=None,
|
|
status=ADDRESS_RESOLVE_FAILED,
|
|
)
|
|
return
|
|
|
|
await self.update_current_location_address(
|
|
child_id=child_id,
|
|
device_id=device_id,
|
|
lat=lat,
|
|
lng=lng,
|
|
address=result.address,
|
|
status=ADDRESS_RESOLVE_SUCCESS,
|
|
)
|
|
|
|
async def update_current_location_address(
|
|
self,
|
|
*,
|
|
child_id: int,
|
|
device_id: str,
|
|
lat: float,
|
|
lng: float,
|
|
address: str | None,
|
|
status: int,
|
|
) -> bool:
|
|
db_session = await self.get_session()
|
|
try:
|
|
dao = LocationDAO(db_session)
|
|
return await dao.update_current_location_address(
|
|
child_id=child_id,
|
|
device_id=device_id,
|
|
lat=lat,
|
|
lng=lng,
|
|
address=address,
|
|
status=status,
|
|
)
|
|
finally:
|
|
await db_session.close()
|
|
|
|
@staticmethod
|
|
def _normalize_location_source(source: int | str | None) -> int:
|
|
if source is None:
|
|
return 0
|
|
if isinstance(source, int):
|
|
return source
|
|
text = str(source).strip().lower()
|
|
if not text:
|
|
return 0
|
|
if text.isdigit():
|
|
return int(text)
|
|
return {
|
|
"gps": 1,
|
|
"wifi": 2,
|
|
"cell": 3,
|
|
"base_station": 3,
|
|
"manual": 4,
|
|
"mock": 9,
|
|
}.get(text, 0)
|
|
|
|
# 创建全局 LocationService 实例
|
|
location_service = LocationService()
|