From f825c60176c2c21fbed4deb5bc1eafbb56c36800 Mon Sep 17 00:00:00 2001 From: stu2not Date: Thu, 7 May 2026 16:05:31 +0800 Subject: [PATCH] =?UTF-8?q?=E8=A1=A5=E5=85=85=E8=AE=BE=E5=A4=87=E5=91=8A?= =?UTF-8?q?=E8=AD=A6=E4=B8=8E=E4=BC=91=E7=9C=A0=E8=AE=BE=E7=BD=AE=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=E8=83=BD=E5=8A=9B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- database/talkingq_shared_schema.sql | 14 ++ talkingq-url/banban/dao/device.py | 4 + talkingq-url/banban/dao/device_alarm.py | 86 +++++++++ talkingq-url/banban/routers/devices.py | 179 +++++++++++++++++- talkingq-url/banban/service/device.py | 49 ++++- talkingq-url/banban/service/device_alarm.py | 47 +++++ talkingq-url/banban/service/device_setting.py | 28 +++ talkingq-url/database/models.py | 16 ++ talkingq-url/handlers/mqtt_handler.py | 25 +++ talkingq-url/mysql/init/02-init.sql | 72 ++++--- 10 files changed, 489 insertions(+), 31 deletions(-) create mode 100644 talkingq-url/banban/dao/device_alarm.py create mode 100644 talkingq-url/banban/service/device_alarm.py diff --git a/database/talkingq_shared_schema.sql b/database/talkingq_shared_schema.sql index 1003fd3..47989fd 100644 --- a/database/talkingq_shared_schema.sql +++ b/database/talkingq_shared_schema.sql @@ -367,6 +367,20 @@ CREATE TABLE IF NOT EXISTS `device_settings` ( REFERENCES `device_auth` (`device_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Device alarm events are reported by the device side through MQTT msg_id=010. +CREATE TABLE IF NOT EXISTS `device_alarm_events` ( + `alarm_id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NULL, + `child_id` BIGINT NULL, + `source_msg_id` VARCHAR(8) NOT NULL DEFAULT '010', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`alarm_id`), + KEY `idx_device_alarm_device_created` (`device_id`, `created_at`), + KEY `idx_device_alarm_owner_created` (`owner_user_id`, `created_at`), + KEY `idx_device_alarm_child_created` (`child_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `im_conversations` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `conversation_type` TINYINT UNSIGNED NOT NULL, diff --git a/talkingq-url/banban/dao/device.py b/talkingq-url/banban/dao/device.py index 517884d..73852e1 100644 --- a/talkingq-url/banban/dao/device.py +++ b/talkingq-url/banban/dao/device.py @@ -77,6 +77,10 @@ class DeviceDAO(BaseDAO): db.device_id, db.child_id, c.child_name, + ds.sleep_mode, + ds.disable_time_start, + ds.disable_time_end, + ds.timezone, ds.power, ds.volume, ds.`signal` AS signal_strength, diff --git a/talkingq-url/banban/dao/device_alarm.py b/talkingq-url/banban/dao/device_alarm.py new file mode 100644 index 0000000..ea7faa5 --- /dev/null +++ b/talkingq-url/banban/dao/device_alarm.py @@ -0,0 +1,86 @@ +from collections.abc import Mapping +from typing import Any + +from sqlalchemy import text + +from banban.dao import BaseDAO + + +class DeviceAlarmDAO(BaseDAO): + async def create( + self, + *, + device_id: str, + owner_user_id: int | None, + child_id: int | None, + source_msg_id: str = "010", + ) -> int: + result = await self.execute( + """ + INSERT INTO device_alarm_events ( + device_id, owner_user_id, child_id, source_msg_id, created_at + ) + VALUES ( + :device_id, :owner_user_id, :child_id, :source_msg_id, CURRENT_TIMESTAMP + ) + """, + { + "device_id": device_id, + "owner_user_id": owner_user_id, + "child_id": child_id, + "source_msg_id": source_msg_id, + }, + ) + await self.commit() + return int(result.lastrowid) + + async def get_active_binding_context(self, *, device_id: str) -> Mapping[str, Any] | None: + result = await self.execute( + text( + """ + SELECT owner_user_id, child_id + FROM device_bindings + WHERE device_id = :device_id + AND status = 1 + LIMIT 1 + """ + ), + {"device_id": device_id}, + ) + return result.mappings().first() + + async def list_by_device( + self, + *, + device_id: str, + owner_user_id: int, + limit: int, + ) -> list[Mapping[str, Any]]: + result = await self.execute( + text( + """ + SELECT + dae.alarm_id, + dae.device_id, + dae.owner_user_id, + dae.child_id, + dae.source_msg_id, + dae.created_at, + c.child_name + FROM device_alarm_events AS dae + LEFT JOIN children AS c + ON c.child_id = dae.child_id + AND c.status = 1 + WHERE dae.device_id = :device_id + AND dae.owner_user_id = :owner_user_id + ORDER BY dae.created_at DESC, dae.alarm_id DESC + LIMIT :limit + """ + ), + { + "device_id": device_id, + "owner_user_id": owner_user_id, + "limit": limit, + }, + ) + return list(result.mappings().all()) diff --git a/talkingq-url/banban/routers/devices.py b/talkingq-url/banban/routers/devices.py index 0bf8fd8..95b5406 100644 --- a/talkingq-url/banban/routers/devices.py +++ b/talkingq-url/banban/routers/devices.py @@ -1,6 +1,6 @@ import logging from collections.abc import Mapping -from datetime import datetime +from datetime import datetime, time from services.connection_manager import connection_manager from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field @@ -15,6 +15,7 @@ try: DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) + from banban.service.device_alarm import device_alarm_service from banban.service.location import location_service from banban.service.device import device_service except ModuleNotFoundError: @@ -24,6 +25,7 @@ except ModuleNotFoundError: DeviceLocationTrajectoryItem, DeviceLocationTrajectoryResponse, ) + from banban.service.device_alarm import device_alarm_service from banban.service.location import location_service from banban.service.device import device_service @@ -53,6 +55,10 @@ class DeviceStatusResponse(BaseModel): device_id: str child_id: int | None = None child_name: str | None = None + sleep_mode: int | None = None + disable_time_start: str | None = None + disable_time_end: str | None = None + timezone: str | None = None power: int | None = None volume: int | None = None signal: int | None = None @@ -72,6 +78,20 @@ class DeviceStatusResponse(BaseModel): location_updated_at: datetime | None = None +class DeviceAlarmItem(BaseModel): + alarm_id: int + device_id: str + child_id: int | None = None + child_name: str | None = None + source_msg_id: str + created_at: datetime + + +class DeviceAlarmListResponse(BaseModel): + items: list[DeviceAlarmItem] + total: int + + class DeviceVolumeUpdateRequest(BaseModel): level: int = Field(ge=0, le=100) @@ -82,6 +102,31 @@ class DeviceVolumeUpdateResponse(BaseModel): msg_id: str +class DeviceSleepScheduleUpdateRequest(BaseModel): + start: str = Field(..., pattern=r"^\d{2}:\d{2}$") + end: str = Field(..., pattern=r"^\d{2}:\d{2}$") + timezone: str = Field(default="Asia/Shanghai", min_length=1, max_length=32) + + +class DeviceSleepScheduleResponse(BaseModel): + device_id: str + sleep_mode: int + start: str | None = None + end: str | None = None + timezone: str + msg_id: str | None = None + + +class DeviceRemoteSleepWakeRequest(BaseModel): + switch: str = Field(..., pattern=r"^(on|off)$") + + +class DeviceRemoteSleepWakeResponse(BaseModel): + device_id: str + switch: str + msg_id: str + + @@ -140,11 +185,31 @@ def _row_to_trajectory_item(row: Mapping, *, child_name: str | None) -> DeviceLo ) +def _format_time_value(value: object) -> str | None: + if value is None: + return None + if isinstance(value, time): + return value.strftime("%H:%M") + text_value = str(value) + return text_value[:5] if len(text_value) >= 5 else text_value + + +def _parse_time_value(value: str, field_name: str) -> time: + try: + return datetime.strptime(value, "%H:%M").time() + except ValueError as exc: + raise HTTPException(status_code=422, detail=f"{field_name} must use HH:MM format") from exc + + def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: return DeviceStatusResponse( 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"), + sleep_mode=row.get("sleep_mode"), + disable_time_start=_format_time_value(row.get("disable_time_start")), + disable_time_end=_format_time_value(row.get("disable_time_end")), + timezone=row.get("timezone"), power=row["power"], volume=row["volume"], signal=row["signal_strength"], @@ -165,6 +230,17 @@ def _row_to_device_status_response(row: Mapping) -> DeviceStatusResponse: ) +def _row_to_alarm_item(row: Mapping) -> DeviceAlarmItem: + 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"], + ) + + @router.get("/{device_id}/messages", response_model=DeviceMessageListResponse) async def list_device_messages( device_id: str, @@ -226,6 +302,36 @@ async def get_device_status( return _row_to_device_status_response(row) +@router.get("/{device_id}/alarms", response_model=DeviceAlarmListResponse) +async def list_device_alarms( + device_id: str, + request: Request, + limit: int = Query(default=20, ge=1, le=100), + current_user_id: int = Depends(get_current_user_id), +) -> DeviceAlarmListResponse: + await device_service.ensure_device_access(device_id=device_id, user_id=current_user_id) + rows = await device_alarm_service.list_device_alarms( + device_id=device_id, + owner_user_id=current_user_id, + limit=limit, + ) + + logger.info( + "device alarms fetched", + extra={ + "event": "device_alarm_list", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "returned_count": len(rows), + }, + ) + return DeviceAlarmListResponse( + items=[_row_to_alarm_item(row) for row in rows], + total=len(rows), + ) + + @router.post("/{device_id}/volume", response_model=DeviceVolumeUpdateResponse) async def set_device_volume( device_id: str, @@ -253,6 +359,77 @@ async def set_device_volume( return DeviceVolumeUpdateResponse(device_id=device_id, level=payload.level, msg_id=msg_id) +@router.post("/{device_id}/sleep-schedule", response_model=DeviceSleepScheduleResponse) +async def set_device_sleep_schedule( + device_id: str, + payload: DeviceSleepScheduleUpdateRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceSleepScheduleResponse: + start_time = _parse_time_value(payload.start, "start") + end_time = _parse_time_value(payload.end, "end") + msg_id = await device_service.set_sleep_schedule( + device_id=device_id, + user_id=current_user_id, + start=start_time, + end=end_time, + timezone=payload.timezone, + ) + + logger.info( + "device sleep schedule command sent", + extra={ + "event": "device_sleep_schedule_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "start": payload.start, + "end": payload.end, + "timezone": payload.timezone, + "msg_id": msg_id, + }, + ) + return DeviceSleepScheduleResponse( + device_id=device_id, + sleep_mode=1, + start=payload.start, + end=payload.end, + timezone=payload.timezone, + msg_id=msg_id, + ) + + +@router.post("/{device_id}/sleep-wake", response_model=DeviceRemoteSleepWakeResponse) +async def set_device_remote_sleep_wake( + device_id: str, + payload: DeviceRemoteSleepWakeRequest, + request: Request, + current_user_id: int = Depends(get_current_user_id), +) -> DeviceRemoteSleepWakeResponse: + msg_id = await device_service.set_remote_sleep_wake( + device_id=device_id, + user_id=current_user_id, + switch=payload.switch, + ) + + logger.info( + "device remote sleep wake command sent", + extra={ + "event": "device_remote_sleep_wake_set", + "request_id": getattr(request.state, "request_id", None), + "user_id": current_user_id, + "device_id": device_id, + "switch": payload.switch, + "msg_id": msg_id, + }, + ) + return DeviceRemoteSleepWakeResponse( + device_id=device_id, + switch=payload.switch, + msg_id=msg_id, + ) + + @router.get("/{device_id}/location", response_model=DeviceLocationCurrentResponse) async def get_current_device_location( diff --git a/talkingq-url/banban/service/device.py b/talkingq-url/banban/service/device.py index 8e63add..135ca0c 100644 --- a/talkingq-url/banban/service/device.py +++ b/talkingq-url/banban/service/device.py @@ -1,10 +1,12 @@ from collections.abc import Mapping -from typing import Any, List +from datetime import time +from typing import Any, List, Literal from services.database_service_base import DatabaseServiceBase from fastapi import HTTPException from banban.dao.device import DeviceDAO +from banban.service.device_setting import device_setting_service class DeviceService(DatabaseServiceBase): @@ -68,6 +70,51 @@ class DeviceService(DatabaseServiceBase): raise HTTPException(status_code=503, detail="MQTT 服务未初始化") return await service.send_volume_command(device_id, level) + async def set_sleep_schedule( + self, + *, + device_id: str, + user_id: int, + start: time, + end: time, + timezone: str, + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + + start_text = start.strftime("%H:%M") + end_text = end.strftime("%H:%M") + msg_id = await service.send_sleep_schedule_command(device_id, start_text, end_text) + await device_setting_service.upsert_sleep_schedule( + device_id=device_id, + sleep_mode=1, + disable_time_start=start, + disable_time_end=end, + timezone=timezone, + ) + return msg_id + + async def set_remote_sleep_wake( + self, + *, + device_id: str, + user_id: int, + switch: Literal["on", "off"], + ) -> str: + await self.ensure_device_access(device_id=device_id, user_id=user_id) + + from handlers.mqtt_handler import TalkingQMQTTService + + service = await TalkingQMQTTService.get_instance() + if service is None: + raise HTTPException(status_code=503, detail="MQTT 服务未初始化") + return await service.send_remote_sleep_wake_command(device_id, switch) + # 创建全局 DeviceService 实例 device_service = DeviceService() diff --git a/talkingq-url/banban/service/device_alarm.py b/talkingq-url/banban/service/device_alarm.py new file mode 100644 index 0000000..efabfdb --- /dev/null +++ b/talkingq-url/banban/service/device_alarm.py @@ -0,0 +1,47 @@ +from collections.abc import Mapping +from typing import Any + +from banban.dao.device_alarm import DeviceAlarmDAO +from services.database_service_base import DatabaseServiceBase + + +class DeviceAlarmService(DatabaseServiceBase): + def __init__(self): + super().__init__(service_name="device_alarm_service") + + async def record_alarm_event(self, *, device_id: str, source_msg_id: str = "010") -> int | None: + db_session = await self.get_session() + try: + dao = DeviceAlarmDAO(db_session) + binding = await dao.get_active_binding_context(device_id=device_id) + if not binding: + return None + return 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, + source_msg_id=source_msg_id, + ) + finally: + await db_session.close() + + async def list_device_alarms( + self, + *, + device_id: str, + owner_user_id: int, + limit: int, + ) -> list[Mapping[str, Any]]: + db_session = await self.get_session() + try: + dao = DeviceAlarmDAO(db_session) + return await dao.list_by_device( + device_id=device_id, + owner_user_id=owner_user_id, + limit=limit, + ) + finally: + await db_session.close() + + +device_alarm_service = DeviceAlarmService() diff --git a/talkingq-url/banban/service/device_setting.py b/talkingq-url/banban/service/device_setting.py index 05cbac7..ec87791 100644 --- a/talkingq-url/banban/service/device_setting.py +++ b/talkingq-url/banban/service/device_setting.py @@ -110,5 +110,33 @@ class DeviceSettingService(DatabaseServiceBase): finally: pass + async def upsert_sleep_schedule( + self, + *, + device_id: str, + sleep_mode: int, + disable_time_start: time, + disable_time_end: time, + timezone: str, + ) -> None: + current_row = await self.get_setting_by_device_id(device_id=device_id) + if current_row: + await self.update_setting( + device_id=device_id, + sleep_mode=sleep_mode, + disable_time_start=disable_time_start, + disable_time_end=disable_time_end, + timezone=timezone, + ) + return + + await self.create_setting( + device_id=device_id, + sleep_mode=sleep_mode, + disable_time_start=disable_time_start, + disable_time_end=disable_time_end, + timezone=timezone, + ) + # 创建全局 DeviceSettingService 实例 device_setting_service = DeviceSettingService() diff --git a/talkingq-url/database/models.py b/talkingq-url/database/models.py index 4147097..8b141e6 100644 --- a/talkingq-url/database/models.py +++ b/talkingq-url/database/models.py @@ -267,6 +267,22 @@ class DeviceSetting(Base): onupdate=datetime.utcnow, ) + +class DeviceAlarmEvent(Base): + __tablename__ = "device_alarm_events" + __table_args__ = ( + Index("idx_device_alarm_device_created", "device_id", "created_at"), + Index("idx_device_alarm_owner_created", "owner_user_id", "created_at"), + Index("idx_device_alarm_child_created", "child_id", "created_at"), + ) + + alarm_id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=True) + device_id: Mapped[str] = mapped_column(String(64), nullable=False) + 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'")) + created_at: Mapped[datetime] = mapped_column(DateTime, default=datetime.utcnow) + class IMConversation(Base): __tablename__ = "im_conversations" __table_args__ = ( diff --git a/talkingq-url/handlers/mqtt_handler.py b/talkingq-url/handlers/mqtt_handler.py index 48c5ec2..f8e72a4 100644 --- a/talkingq-url/handlers/mqtt_handler.py +++ b/talkingq-url/handlers/mqtt_handler.py @@ -7,6 +7,7 @@ from typing import Awaitable, Callable, Dict, Optional import aiomqtt from banban.service.binding import BindingService +from banban.service.device_alarm import device_alarm_service from banban.service.device_setting import device_setting_service from banban.service.location import location_service from config import settings @@ -240,6 +241,25 @@ class TalkingQMQTTService: status = payload.get("status") if status == "success": logger.info(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置成功") + data = payload.get("data", {}) or {} + start = data.get("start") + end = data.get("end") + timezone = data.get("timezone") or "Asia/Shanghai" + if start and end: + try: + await self._schedule_persistence( + device_id, + "sleep_schedule", + device_setting_service.upsert_sleep_schedule( + device_id=device_id, + sleep_mode=1, + disable_time_start=datetime.strptime(start, "%H:%M").time(), + disable_time_end=datetime.strptime(end, "%H:%M").time(), + timezone=timezone, + ), + ) + except ValueError: + logger.warning(device_id, "", f"[定时休眠] 无法解析设备返回的时间: {payload}") else: logger.warning(device_id, "", f"[定时休眠] 设备 {device_id} 休眠时间设置失败: {payload}") @@ -252,6 +272,11 @@ class TalkingQMQTTService: async def _handle_alarm_report(self, device_id: str, payload: dict): logger.info(device_id, "", f"[告警] 设备 {device_id} 发送紧急报警") + await self._schedule_persistence( + device_id, + "alarm_event", + device_alarm_service.record_alarm_event(device_id=device_id, source_msg_id="010"), + ) await self._publish(f"device/{device_id}/event_resp", {"msg_id": "010", "status": "success"}) async def _handle_short_press_message(self, device_id: str, payload: dict): diff --git a/talkingq-url/mysql/init/02-init.sql b/talkingq-url/mysql/init/02-init.sql index c5cbbff..47989fd 100644 --- a/talkingq-url/mysql/init/02-init.sql +++ b/talkingq-url/mysql/init/02-init.sql @@ -367,6 +367,20 @@ CREATE TABLE IF NOT EXISTS `device_settings` ( REFERENCES `device_auth` (`device_id`) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; +-- Device alarm events are reported by the device side through MQTT msg_id=010. +CREATE TABLE IF NOT EXISTS `device_alarm_events` ( + `alarm_id` BIGINT NOT NULL AUTO_INCREMENT, + `device_id` VARCHAR(64) NOT NULL, + `owner_user_id` BIGINT NULL, + `child_id` BIGINT NULL, + `source_msg_id` VARCHAR(8) NOT NULL DEFAULT '010', + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + PRIMARY KEY (`alarm_id`), + KEY `idx_device_alarm_device_created` (`device_id`, `created_at`), + KEY `idx_device_alarm_owner_created` (`owner_user_id`, `created_at`), + KEY `idx_device_alarm_child_created` (`child_id`, `created_at`) +) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; + CREATE TABLE IF NOT EXISTS `im_conversations` ( `id` BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, `conversation_type` TINYINT UNSIGNED NOT NULL, @@ -427,20 +441,20 @@ CREATE TABLE IF NOT EXISTS `im_messages` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `child_location_current` ( - `child_id` BIGINT NOT NULL COMMENT '孩子ID,当前表每个孩子仅保留一条最新定位', - `device_id` VARCHAR(64) NOT NULL COMMENT '定位上报设备ID', - `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02' COMMENT '坐标系类型,统一使用gcj02', - `lat` DECIMAL(10, 7) NOT NULL COMMENT '纬度', - `lng` DECIMAL(10, 7) NOT NULL COMMENT '经度', - `accuracy_m` INT UNSIGNED NULL COMMENT '定位精度,单位米', - `altitude_m` DECIMAL(8, 2) NULL COMMENT '海拔高度,单位米', - `speed_mps` DECIMAL(8, 2) NULL COMMENT '速度,单位米每秒', - `heading_deg` SMALLINT UNSIGNED NULL COMMENT '航向角,单位度,范围0到360', - `source` TINYINT UNSIGNED NOT NULL COMMENT '定位来源编码,由设备侧约定', - `battery_pct` TINYINT UNSIGNED NULL COMMENT '设备电量百分比,范围0到100', - `device_time` DATETIME NOT NULL COMMENT '设备采集定位时间', - `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '服务端接收并写入时间', - `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP COMMENT '当前定位记录最后更新时间', + `child_id` BIGINT NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02', + `lat` DECIMAL(10, 7) NOT NULL, + `lng` DECIMAL(10, 7) NOT NULL, + `accuracy_m` INT UNSIGNED NULL, + `altitude_m` DECIMAL(8, 2) NULL, + `speed_mps` DECIMAL(8, 2) NULL, + `heading_deg` SMALLINT UNSIGNED NULL, + `source` TINYINT UNSIGNED NOT NULL, + `battery_pct` TINYINT UNSIGNED NULL, + `device_time` DATETIME NOT NULL, + `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `updated_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP, PRIMARY KEY (`child_id`), KEY `idx_child_loc_current_device` (`device_id`), KEY `idx_child_loc_current_server_time` (`server_time`), @@ -454,21 +468,21 @@ CREATE TABLE IF NOT EXISTS `child_location_current` ( ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_unicode_ci; CREATE TABLE IF NOT EXISTS `child_location_history` ( - `id` BIGINT NOT NULL AUTO_INCREMENT COMMENT '历史定位记录ID', - `child_id` BIGINT NOT NULL COMMENT '孩子ID', - `device_id` VARCHAR(64) NOT NULL COMMENT '定位上报设备ID', - `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02' COMMENT '坐标系类型,统一使用gcj02', - `lat` DECIMAL(10, 7) NOT NULL COMMENT '纬度', - `lng` DECIMAL(10, 7) NOT NULL COMMENT '经度', - `accuracy_m` INT UNSIGNED NULL COMMENT '定位精度,单位米', - `altitude_m` DECIMAL(8, 2) NULL COMMENT '海拔高度,单位米', - `speed_mps` DECIMAL(8, 2) NULL COMMENT '速度,单位米每秒', - `heading_deg` SMALLINT UNSIGNED NULL COMMENT '航向角,单位度,范围0到360', - `source` TINYINT UNSIGNED NOT NULL COMMENT '定位来源编码,由设备侧约定', - `battery_pct` TINYINT UNSIGNED NULL COMMENT '设备电量百分比,范围0到100', - `device_time` DATETIME NOT NULL COMMENT '设备采集定位时间', - `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '服务端接收并写入时间', - `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '历史定位记录创建时间', + `id` BIGINT NOT NULL AUTO_INCREMENT, + `child_id` BIGINT NOT NULL, + `device_id` VARCHAR(64) NOT NULL, + `coord_type` VARCHAR(16) NOT NULL DEFAULT 'gcj02', + `lat` DECIMAL(10, 7) NOT NULL, + `lng` DECIMAL(10, 7) NOT NULL, + `accuracy_m` INT UNSIGNED NULL, + `altitude_m` DECIMAL(8, 2) NULL, + `speed_mps` DECIMAL(8, 2) NULL, + `heading_deg` SMALLINT UNSIGNED NULL, + `source` TINYINT UNSIGNED NOT NULL, + `battery_pct` TINYINT UNSIGNED NULL, + `device_time` DATETIME NOT NULL, + `server_time` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + `created_at` DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, PRIMARY KEY (`id`), KEY `idx_child_loc_hist_child` (`child_id`, `created_at`), KEY `idx_child_loc_hist_device` (`device_id`, `created_at`),