feat: 性能优化 + 设备总览轨迹展示 + 广播指令API

性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、
device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询
批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG

功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、
前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-31 09:41:09 +00:00
parent b970b78136
commit b25eafc483
12 changed files with 1030 additions and 244 deletions

View File

@@ -235,10 +235,11 @@ class PacketBuilder:
class ConnectionInfo:
"""Metadata about a single device TCP connection."""
__slots__ = ("imei", "addr", "connected_at", "last_activity", "serial_counter")
__slots__ = ("imei", "device_id", "addr", "connected_at", "last_activity", "serial_counter")
def __init__(self, addr: Tuple[str, int]) -> None:
self.imei: Optional[str] = None
self.device_id: Optional[int] = None
self.addr = addr
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
self.last_activity = self.connected_at
@@ -254,12 +255,16 @@ class ConnectionInfo:
# Helper: look up device_id from IMEI
# ---------------------------------------------------------------------------
async def _get_device_id(session, imei: str) -> Optional[int]:
"""Query the Device table and return the integer id for the given IMEI."""
async def _get_device_id(session, imei: str, conn_info: Optional["ConnectionInfo"] = None) -> Optional[int]:
"""Return the device id for the given IMEI, using ConnectionInfo cache if available."""
if conn_info is not None and conn_info.device_id is not None:
return conn_info.device_id
result = await session.execute(
select(Device.id).where(Device.imei == imei)
)
row = result.scalar_one_or_none()
if row is not None and conn_info is not None:
conn_info.device_id = row
return row
@@ -273,6 +278,7 @@ class TCPManager:
def __init__(self) -> None:
# {imei: (reader, writer, connection_info)}
self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {}
self._conn_lock = asyncio.Lock()
self._server: Optional[asyncio.AbstractServer] = None
# Protocol number -> handler coroutine mapping
@@ -316,11 +322,22 @@ class TCPManager:
conn_info = ConnectionInfo(addr)
logger.info("New TCP connection from %s:%d", addr[0], addr[1])
recv_buffer = b""
recv_buffer = bytearray()
try:
idle_timeout = settings.TCP_IDLE_TIMEOUT or None
while True:
data = await reader.read(4096)
try:
if idle_timeout:
data = await asyncio.wait_for(reader.read(4096), timeout=idle_timeout)
else:
data = await reader.read(4096)
except asyncio.TimeoutError:
logger.info(
"Idle timeout (%ds) for %s:%d (IMEI=%s), closing",
idle_timeout, addr[0], addr[1], conn_info.imei,
)
break
if not data:
logger.info(
"Connection closed by remote %s:%d (IMEI=%s)",
@@ -371,7 +388,7 @@ class TCPManager:
"Receive buffer overflow for IMEI=%s, discarding",
conn_info.imei,
)
recv_buffer = b""
recv_buffer = bytearray()
except asyncio.CancelledError:
logger.info(
@@ -402,14 +419,15 @@ class TCPManager:
) -> None:
"""Remove connection from tracking and update the device status."""
imei = conn_info.imei
if imei and imei in self.connections:
# Only remove if this is still the active connection (not replaced by reconnect)
_, stored_writer, _ = self.connections[imei]
if stored_writer is writer:
del self.connections[imei]
logger.info("Device IMEI=%s removed from active connections", imei)
else:
logger.info("Device IMEI=%s has reconnected, keeping new connection", imei)
async with self._conn_lock:
if imei and imei in self.connections:
# Only remove if this is still the active connection (not replaced by reconnect)
_, stored_writer, _ = self.connections[imei]
if stored_writer is writer:
del self.connections[imei]
logger.info("Device IMEI=%s removed from active connections", imei)
else:
logger.info("Device IMEI=%s has reconnected, keeping new connection", imei)
# Don't mark offline since device reconnected
try:
writer.close()
@@ -599,16 +617,16 @@ class TCPManager:
conn_info.imei = imei
# Close existing connection if device reconnects
old_conn = self.connections.get(imei)
if old_conn is not None:
_, old_writer, old_info = old_conn
logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1])
try:
old_writer.close()
except Exception:
pass
self.connections[imei] = (reader, writer, conn_info)
async with self._conn_lock:
old_conn = self.connections.get(imei)
if old_conn is not None:
_, old_writer, old_info = old_conn
logger.info("Closing stale connection for IMEI=%s (old %s:%d)", imei, old_info.addr[0], old_info.addr[1])
try:
old_writer.close()
except Exception:
pass
self.connections[imei] = (reader, writer, conn_info)
logger.info(
"Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1]
)
@@ -739,7 +757,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Heartbeat for unknown IMEI=%s", imei)
return
@@ -1046,22 +1064,29 @@ class TCPManager:
except Exception:
logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei)
# --- Reverse geocoding: coordinates -> address ---
address: Optional[str] = None
# --- Reverse geocoding (run concurrently with DB store below) ---
address_task = None
if latitude is not None and longitude is not None:
try:
address = await reverse_geocode(latitude, longitude)
except Exception:
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
address_task = asyncio.ensure_future(reverse_geocode(latitude, longitude))
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Location for unknown IMEI=%s", imei)
if address_task:
address_task.cancel()
return
# Await reverse geocode result if running
address: Optional[str] = None
if address_task:
try:
address = await address_task
except Exception:
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
record = LocationRecord(
device_id=device_id,
imei=conn_info.imei,
@@ -1086,41 +1111,35 @@ class TCPManager:
recorded_at=recorded_at,
)
session.add(record)
# Broadcast to WebSocket subscribers
# --- Fence auto-attendance check (same session) ---
fence_events = []
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
try:
from app.services.fence_checker import check_device_fences
fence_events = await check_device_fences(
session, device_id, imei,
latitude, longitude, location_type,
address, recorded_at,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
)
except Exception:
logger.exception("Fence check failed for IMEI=%s", imei)
# Broadcast to WebSocket subscribers (after commit)
ws_manager.broadcast_nonblocking("location", {
"imei": imei, "device_id": device_id, "location_type": location_type,
"latitude": latitude, "longitude": longitude, "speed": speed,
"address": address, "recorded_at": str(recorded_at),
})
for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt)
ws_manager.broadcast_nonblocking("attendance", evt)
except Exception:
logger.exception(
"DB error storing %s location for IMEI=%s", location_type, imei
)
# --- Fence auto-attendance check ---
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
try:
from app.services.fence_checker import check_device_fences
async with async_session() as fence_session:
async with fence_session.begin():
device_id_for_fence = device_id
if device_id_for_fence is None:
# Resolve device_id if not available from above
device_id_for_fence = await _get_device_id(fence_session, imei)
if device_id_for_fence is not None:
fence_events = await check_device_fences(
fence_session, device_id_for_fence, imei,
latitude, longitude, location_type,
address, recorded_at,
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
)
for evt in fence_events:
ws_manager.broadcast_nonblocking("fence_attendance", evt)
ws_manager.broadcast_nonblocking("attendance", evt)
except Exception:
logger.exception("Fence check failed for IMEI=%s", imei)
return address
@staticmethod
@@ -1564,7 +1583,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Alarm for unknown IMEI=%s", imei)
return
@@ -1865,7 +1884,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Attendance for unknown IMEI=%s", imei)
return attendance_type, reserved_bytes, datetime_bytes
@@ -2001,7 +2020,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is not None:
# Look up beacon location from beacon_configs
beacon_lat = None
@@ -2206,7 +2225,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("BT location for unknown IMEI=%s", imei)
return
@@ -2390,7 +2409,7 @@ class TCPManager:
try:
async with async_session() as session:
async with session.begin():
device_id = await _get_device_id(session, imei)
device_id = await _get_device_id(session, imei, conn_info)
if device_id is None:
logger.warning("Command reply for unknown IMEI=%s", imei)
return
@@ -2439,20 +2458,17 @@ class TCPManager:
bool
``True`` if the command was successfully written to the socket.
"""
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
# Check if the writer is still alive
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
async with self._conn_lock:
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
# Build 0x80 online-command packet
# Payload: length(1) + server_flag(4) + content_bytes + language(2)
@@ -2497,19 +2513,17 @@ class TCPManager:
bool
``True`` if the message was successfully written to the socket.
"""
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
async with self._conn_lock:
conn = self.connections.get(imei)
if conn is None:
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
return False
_reader, writer, conn_info = conn
if writer.is_closing():
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
del self.connections[imei]
return False
serial = conn_info.next_serial()
msg_bytes = message.encode("utf-16-be")
server_flag = b"\x00\x00\x00\x00"