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:
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user