From 1b75435a3c95e1230485d55d7510e264fcd0abcf Mon Sep 17 00:00:00 2001 From: stu2not Date: Wed, 27 May 2026 17:06:12 +0800 Subject: [PATCH] =?UTF-8?q?=E5=91=8A=E8=AD=A6=E8=AE=B0=E5=BD=95=E4=BF=9D?= =?UTF-8?q?=E5=AD=98=E4=BD=8D=E7=BD=AE=E5=BF=AB=E7=85=A7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- banban-mini/src/pages/device/index.scss | 43 +++++++++++ banban-mini/src/pages/device/index.tsx | 37 ++++++++++ banban-mini/src/services/device.ts | 8 +++ database/talkingq_shared_schema.sql | 7 ++ talkingq-url/banban/dao/device_alarm.py | 80 ++++++++++++++++++++- talkingq-url/banban/routers/devices.py | 42 ++++++++++- talkingq-url/banban/service/device_alarm.py | 77 +++++++++++++++++++- talkingq-url/config.py | 1 + talkingq-url/database/init_db.py | 17 +++++ talkingq-url/database/models.py | 7 ++ talkingq-url/mysql/init/02-init.sql | 7 ++ 11 files changed, 321 insertions(+), 5 deletions(-) diff --git a/banban-mini/src/pages/device/index.scss b/banban-mini/src/pages/device/index.scss index 0545c35..39c6d4d 100644 --- a/banban-mini/src/pages/device/index.scss +++ b/banban-mini/src/pages/device/index.scss @@ -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; } diff --git a/banban-mini/src/pages/device/index.tsx b/banban-mini/src/pages/device/index.tsx index 71397ef..a8919f0 100644 --- a/banban-mini/src/pages/device/index.tsx +++ b/banban-mini/src/pages/device/index.tsx @@ -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(null) @@ -425,6 +451,13 @@ export default function Device() { 上报类型 {getAlarmTypeLabel(latestAlarm.source_msg_id)} + + 告警位置 + + {getAlarmLocationLabel(latestAlarm)} + {getAlarmLocationHint(latestAlarm)} + + @@ -446,6 +479,10 @@ export default function Device() { {alarm.device_id} + {getAlarmLocationLabel(alarm)} + {alarm.location_stale ? ( + 位置可能不是告警发生时的位置 + ) : null} )) ) : ( diff --git a/banban-mini/src/services/device.ts b/banban-mini/src/services/device.ts index 36468a3..327237b 100644 --- a/banban-mini/src/services/device.ts +++ b/banban-mini/src/services/device.ts @@ -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 } diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql index 73afe7d..1182dba 100644 --- a/database/talkingq_shared_schema.sql +++ b/database/talkingq_shared_schema.sql @@ -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`), diff --git a/talkingq-url/banban/dao/device_alarm.py b/talkingq-url/banban/dao/device_alarm.py index 02b3a69..a0adcb2 100644 --- a/talkingq-url/banban/dao/device_alarm.py +++ b/talkingq-url/banban/dao/device_alarm.py @@ -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 diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 522e7e2..f7752b6 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -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, diff --git a/talkingq-url/banban/service/device_alarm.py b/talkingq-url/banban/service/device_alarm.py index c6e4f0c..88d9591 100644 --- a/talkingq-url/banban/service/device_alarm.py +++ b/talkingq-url/banban/service/device_alarm.py @@ -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() diff --git a/talkingq-url/config.py b/talkingq-url/config.py index e5d5329..b768042 100644 --- a/talkingq-url/config.py +++ b/talkingq-url/config.py @@ -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") diff --git a/talkingq-url/database/init_db.py b/talkingq-url/database/init_db.py index 7294b6c..115e28f 100644 --- a/talkingq-url/database/init_db.py +++ b/talkingq-url/database/init_db.py @@ -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() diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 9b33ec9..9e9a65e 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -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): diff --git a/talkingq-url/mysql/init/02-init.sql b/talkingq-url/mysql/init/02-init.sql index 73afe7d..1182dba 100644 --- a/talkingq-url/mysql/init/02-init.sql +++ b/talkingq-url/mysql/init/02-init.sql @@ -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`),