告警记录保存位置快照

This commit is contained in:
stu2not
2026-05-27 17:06:12 +08:00
parent 356e333c81
commit 1b75435a3c
11 changed files with 321 additions and 5 deletions

View File

@@ -14,14 +14,34 @@ class DeviceAlarmDAO(BaseDAO):
owner_user_id: int | None,
child_id: int | None,
source_msg_id: str = "010",
location: Mapping[str, Any] | None = None,
) -> int:
location = location or {}
result = await self.execute(
"""
INSERT INTO device_alarm_events (
device_id, owner_user_id, child_id, source_msg_id, created_at
device_id,
owner_user_id,
child_id,
source_msg_id,
coord_type,
lat,
lng,
location_updated_at,
address_resolve_status,
created_at
)
VALUES (
:device_id, :owner_user_id, :child_id, :source_msg_id, CURRENT_TIMESTAMP
:device_id,
:owner_user_id,
:child_id,
:source_msg_id,
:coord_type,
:lat,
:lng,
:location_updated_at,
:address_resolve_status,
CURRENT_TIMESTAMP
)
""",
{
@@ -29,11 +49,60 @@ class DeviceAlarmDAO(BaseDAO):
"owner_user_id": owner_user_id,
"child_id": child_id,
"source_msg_id": source_msg_id,
"coord_type": location.get("coord_type"),
"lat": location.get("lat"),
"lng": location.get("lng"),
"location_updated_at": location.get("updated_at"),
"address_resolve_status": 0 if location.get("lat") is not None and location.get("lng") is not None else None,
},
)
await self.commit()
return int(result.lastrowid)
async def get_current_location_snapshot(self, *, child_id: int) -> Mapping[str, Any] | None:
result = await self.execute(
text(
"""
SELECT
coord_type,
lat,
lng,
updated_at
FROM child_location_current
WHERE child_id = :child_id
LIMIT 1
"""
),
{"child_id": child_id},
)
return result.mappings().first()
async def update_resolved_address(
self,
*,
alarm_id: int,
address: str | None,
status: int,
) -> bool:
result = await self.execute(
text(
"""
UPDATE device_alarm_events
SET address = :address,
address_resolve_status = :status,
address_resolved_at = CURRENT_TIMESTAMP
WHERE alarm_id = :alarm_id
"""
),
{
"alarm_id": alarm_id,
"address": address,
"status": status,
},
)
await self.commit()
return bool(result.rowcount)
async def get_active_binding_context(self, *, device_id: str) -> Mapping[str, Any] | None:
result = await self.execute(
text(
@@ -64,6 +133,13 @@ class DeviceAlarmDAO(BaseDAO):
dae.owner_user_id,
dae.child_id,
dae.source_msg_id,
dae.coord_type,
dae.lat,
dae.lng,
dae.location_updated_at,
dae.address,
dae.address_resolved_at,
dae.address_resolve_status,
dae.created_at,
c.child_name
FROM device_alarm_events AS dae

View File

@@ -7,6 +7,7 @@ from sqlalchemy import text
import asyncio
from datetime import timedelta
from handlers.mqtt_handler import TalkingQMQTTService
from config import settings
from services.connection_manager import connection_manager
try:
from banban.security import get_current_user_id
@@ -113,6 +114,14 @@ class DeviceAlarmItem(BaseModel):
child_id: int | None = None
child_name: str | None = None
source_msg_id: str
coord_type: str | None = None
lat: float | None = None
lng: float | None = None
location_updated_at: datetime | None = None
location_stale: bool = False
address: str | None = None
address_resolved_at: datetime | None = None
address_resolve_status: int | None = None
created_at: datetime
@@ -313,16 +322,47 @@ def _seconds_since(value: datetime | None, now: datetime) -> float | None:
def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem:
location_updated_at = row.get("location_updated_at")
created_at = row["created_at"]
location_stale = False
if location_updated_at is not None:
location_age_seconds = _seconds_between(created_at, location_updated_at)
location_stale = (
location_age_seconds is None
or location_age_seconds < 0
or location_age_seconds > settings.alarm_location_stale_seconds
)
return DeviceAlarmItem(
alarm_id=int(row["alarm_id"]),
device_id=str(row["device_id"]),
child_id=int(row["child_id"]) if row["child_id"] is not None else None,
child_name=row.get("child_name"),
source_msg_id=str(row["source_msg_id"]),
created_at=row["created_at"],
coord_type=row.get("coord_type"),
lat=float(row["lat"]) if row.get("lat") is not None else None,
lng=float(row["lng"]) if row.get("lng") is not None else None,
location_updated_at=location_updated_at,
location_stale=location_stale,
address=row.get("address"),
address_resolved_at=row.get("address_resolved_at"),
address_resolve_status=int(row["address_resolve_status"]) if row.get("address_resolve_status") is not None else None,
created_at=created_at,
)
def _seconds_between(later: datetime | None, earlier: datetime | None) -> float | None:
if later is None or earlier is None:
return None
normalized_later = later
normalized_earlier = earlier
if normalized_later.tzinfo is not None and normalized_earlier.tzinfo is None:
normalized_earlier = normalized_earlier.replace(tzinfo=normalized_later.tzinfo)
elif normalized_later.tzinfo is None and normalized_earlier.tzinfo is not None:
normalized_later = normalized_later.replace(tzinfo=normalized_earlier.tzinfo)
return (normalized_later - normalized_earlier).total_seconds()
@router.get("/{device_id}/messages", response_model=DeviceMessageListResponse)
async def list_device_messages(
device_id: str,

View File

@@ -2,7 +2,15 @@ from collections.abc import Mapping
from typing import Any
from banban.dao.device_alarm import DeviceAlarmDAO
from banban.service.amap_geocode import amap_geocode_service
from services.task_manager import task_manager
from services.database_service_base import DatabaseServiceBase
from utils.logger import session_logger
ADDRESS_RESOLVE_PENDING = 0
ADDRESS_RESOLVE_SUCCESS = 1
ADDRESS_RESOLVE_FAILED = 2
class DeviceAlarmService(DatabaseServiceBase):
@@ -16,11 +24,76 @@ class DeviceAlarmService(DatabaseServiceBase):
binding = await dao.get_active_binding_context(device_id=device_id)
if not binding:
return None
return await dao.create(
child_id = int(binding["child_id"]) if binding.get("child_id") is not None else None
location = None
if child_id is not None:
location = await dao.get_current_location_snapshot(child_id=child_id)
alarm_id = await dao.create(
device_id=device_id,
owner_user_id=int(binding["owner_user_id"]) if binding.get("owner_user_id") is not None else None,
child_id=int(binding["child_id"]) if binding.get("child_id") is not None else None,
child_id=child_id,
source_msg_id=source_msg_id,
location=location,
)
if alarm_id and location and location.get("lat") is not None and location.get("lng") is not None:
await task_manager.create_task(
self.resolve_alarm_address(
alarm_id=alarm_id,
device_id=device_id,
lat=float(location["lat"]),
lng=float(location["lng"]),
),
device_id=device_id,
task_type="alarm_address",
)
return alarm_id
finally:
await db_session.close()
async def resolve_alarm_address(
self,
*,
alarm_id: int,
device_id: str,
lat: float,
lng: float,
) -> None:
try:
result = await amap_geocode_service.reverse_geocode(lng=lng, lat=lat)
except Exception as exc:
session_logger.warning(
device_id,
"alarm_address",
f"告警地址解析失败: alarm_id={alarm_id}, error={exc}",
)
await self.update_alarm_address(
alarm_id=alarm_id,
address=None,
status=ADDRESS_RESOLVE_FAILED,
)
return
await self.update_alarm_address(
alarm_id=alarm_id,
address=result.address,
status=ADDRESS_RESOLVE_SUCCESS,
)
async def update_alarm_address(
self,
*,
alarm_id: int,
address: str | None,
status: int,
) -> bool:
db_session = await self.get_session()
try:
dao = DeviceAlarmDAO(db_session)
return await dao.update_resolved_address(
alarm_id=alarm_id,
address=address,
status=status,
)
finally:
await db_session.close()

View File

@@ -47,6 +47,7 @@ class Settings(BaseSettings):
validation_alias="AMAP_REVERSE_GEOCODE_URL",
)
amap_http_timeout_seconds: float = Field(default=5.0, validation_alias="AMAP_HTTP_TIMEOUT_SECONDS")
alarm_location_stale_seconds: int = Field(default=600, validation_alias="ALARM_LOCATION_STALE_SECONDS")
aliyun_api_key: str = Field(default="", validation_alias="ALIYUN_API_KEY")
aliyun_vocabulary_id: str = Field(default="", validation_alias="ALIYUN_VOCABULARY_ID")

View File

@@ -78,6 +78,22 @@ async def _ensure_columns(conn, *, table_name: str, columns: list[tuple[str, str
await conn.execute(text(f"ALTER TABLE {table_name} ADD COLUMN {definition}"))
async def _ensure_device_alarm_location_columns(conn) -> None:
await _ensure_columns(
conn,
table_name="device_alarm_events",
columns=[
("coord_type", "coord_type VARCHAR(16) NULL AFTER source_msg_id"),
("lat", "lat DECIMAL(10, 7) NULL AFTER coord_type"),
("lng", "lng DECIMAL(10, 7) NULL AFTER lat"),
("location_updated_at", "location_updated_at DATETIME NULL AFTER lng"),
("address", "address VARCHAR(255) NULL AFTER location_updated_at"),
("address_resolved_at", "address_resolved_at DATETIME NULL AFTER address"),
("address_resolve_status", "address_resolve_status TINYINT NULL AFTER address_resolved_at"),
],
)
async def _ensure_child_location_address_columns(conn) -> None:
await _ensure_columns(
conn,
@@ -204,6 +220,7 @@ async def init_db():
await conn.run_sync(Base.metadata.create_all)
await _ensure_manual_sleep_mode_column(conn)
await _ensure_schedule_suppressed_until_column(conn)
await _ensure_device_alarm_location_columns(conn)
await _ensure_child_location_address_columns(conn)
await _ensure_device_family_tables(conn)
await engine.dispose()

View File

@@ -344,6 +344,13 @@ class DeviceAlarmEvent(Base):
owner_user_id: Mapped[Optional[int]] = mapped_column(Integer)
child_id: Mapped[Optional[int]] = mapped_column(Integer)
source_msg_id: Mapped[str] = mapped_column(String(8), server_default=text("'010'"))
coord_type: Mapped[Optional[str]] = mapped_column(String(16))
lat: Mapped[Optional[float]] = mapped_column(Numeric(10, 7))
lng: Mapped[Optional[float]] = mapped_column(Numeric(10, 7))
location_updated_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
address: Mapped[Optional[str]] = mapped_column(String(255))
address_resolved_at: Mapped[Optional[datetime]] = mapped_column(DateTime)
address_resolve_status: Mapped[Optional[int]] = mapped_column(Integer)
created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow)
class IMConversation(Base):

View File

@@ -448,6 +448,13 @@ CREATE TABLE IF NOT EXISTS `device_alarm_events` (
`owner_user_id` BIGINT NULL,
`child_id` BIGINT NULL,
`source_msg_id` VARCHAR(8) NOT NULL DEFAULT '010',
`coord_type` VARCHAR(16) NULL,
`lat` DECIMAL(10, 7) NULL,
`lng` DECIMAL(10, 7) NULL,
`location_updated_at` DATETIME NULL,
`address` VARCHAR(255) NULL,
`address_resolved_at` DATETIME NULL,
`address_resolve_status` TINYINT NULL COMMENT '0=pending,1=success,2=failed',
`created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (`alarm_id`),
KEY `idx_device_alarm_device_created` (`device_id`, `created_at`),