告警记录保存位置快照
This commit is contained in:
@@ -336,6 +336,11 @@
|
||||
}
|
||||
}
|
||||
|
||||
.alarm-row-vertical {
|
||||
align-items: flex-start;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
.alarm-label {
|
||||
font-size: 28px;
|
||||
color: #666666;
|
||||
@@ -349,6 +354,28 @@
|
||||
color: #1A1A1A;
|
||||
}
|
||||
|
||||
.alarm-location-value {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.alarm-location-main {
|
||||
display: block;
|
||||
font-size: 28px;
|
||||
line-height: 1.5;
|
||||
word-break: break-all;
|
||||
color: #1A1A1A;
|
||||
}
|
||||
|
||||
.alarm-location-hint {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
font-size: 22px;
|
||||
line-height: 1.5;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.alarm-empty {
|
||||
padding: 24px;
|
||||
}
|
||||
@@ -434,6 +461,22 @@
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.alarm-history-location,
|
||||
.alarm-history-location-hint {
|
||||
display: block;
|
||||
margin-top: 8px;
|
||||
font-size: 24px;
|
||||
line-height: 1.5;
|
||||
color: #4B5563;
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.alarm-history-location-hint {
|
||||
margin-top: 4px;
|
||||
font-size: 22px;
|
||||
color: #9CA3AF;
|
||||
}
|
||||
|
||||
.alarm-history-empty {
|
||||
padding: 8px 0 4px;
|
||||
}
|
||||
|
||||
@@ -78,6 +78,32 @@ function getAlarmChildLabel(alarm: DeviceAlarmItem | null, fallbackChildName?: s
|
||||
return '未关联儿童'
|
||||
}
|
||||
|
||||
function formatAlarmCoordinates(alarm: DeviceAlarmItem | null): string {
|
||||
if (!alarm || alarm.lat === null || alarm.lat === undefined || alarm.lng === null || alarm.lng === undefined) {
|
||||
return ''
|
||||
}
|
||||
return `${Number(alarm.lat).toFixed(6)}, ${Number(alarm.lng).toFixed(6)}`
|
||||
}
|
||||
|
||||
function getAlarmLocationLabel(alarm: DeviceAlarmItem | null): string {
|
||||
const address = String(alarm?.address || '').trim()
|
||||
if (address) return address
|
||||
|
||||
const coordinates = formatAlarmCoordinates(alarm)
|
||||
if (coordinates) return coordinates
|
||||
|
||||
return '暂无告警位置'
|
||||
}
|
||||
|
||||
function getAlarmLocationHint(alarm: DeviceAlarmItem | null): string {
|
||||
if (!alarm?.location_updated_at) return '设备还没有可关联的位置上报'
|
||||
const prefix = `位置更新于 ${formatAlarmTime(alarm.location_updated_at)}`
|
||||
if (alarm.location_stale) return `${prefix},可能不是告警发生时的位置`
|
||||
if (!String(alarm.address || '').trim() && alarm.address_resolve_status === 0) return `${prefix},地址解析中`
|
||||
if (!String(alarm.address || '').trim() && alarm.address_resolve_status === 2) return `${prefix},地址解析失败`
|
||||
return prefix
|
||||
}
|
||||
|
||||
export default function Device() {
|
||||
const systemBanner = useSystemBanner()
|
||||
const [binding, setBinding] = useState<Binding | null>(null)
|
||||
@@ -425,6 +451,13 @@ export default function Device() {
|
||||
<Text className='alarm-label'>上报类型</Text>
|
||||
<Text className='alarm-value'>{getAlarmTypeLabel(latestAlarm.source_msg_id)}</Text>
|
||||
</View>
|
||||
<View className='alarm-row alarm-row-vertical'>
|
||||
<Text className='alarm-label'>告警位置</Text>
|
||||
<View className='alarm-location-value'>
|
||||
<Text className='alarm-location-main'>{getAlarmLocationLabel(latestAlarm)}</Text>
|
||||
<Text className='alarm-location-hint'>{getAlarmLocationHint(latestAlarm)}</Text>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View className='alarm-history'>
|
||||
@@ -446,6 +479,10 @@ export default function Device() {
|
||||
</Text>
|
||||
<Text className='alarm-history-device'>{alarm.device_id}</Text>
|
||||
</View>
|
||||
<Text className='alarm-history-location'>{getAlarmLocationLabel(alarm)}</Text>
|
||||
{alarm.location_stale ? (
|
||||
<Text className='alarm-history-location-hint'>位置可能不是告警发生时的位置</Text>
|
||||
) : null}
|
||||
</View>
|
||||
))
|
||||
) : (
|
||||
|
||||
@@ -45,6 +45,14 @@ export interface DeviceAlarmItem {
|
||||
child_id?: number | null
|
||||
child_name?: string | null
|
||||
source_msg_id: string
|
||||
coord_type?: string | null
|
||||
lat?: number | null
|
||||
lng?: number | null
|
||||
location_updated_at?: string | null
|
||||
location_stale?: boolean
|
||||
address?: string | null
|
||||
address_resolved_at?: string | null
|
||||
address_resolve_status?: number | null
|
||||
created_at: string
|
||||
}
|
||||
|
||||
|
||||
@@ -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`),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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`),
|
||||
|
||||
Reference in New Issue
Block a user