Add batch management APIs, API security, rate limiting, and optimizations

- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500),
  POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries
- Batch command: POST /api/commands/batch with model_validator mutual exclusion
- API key auth (X-API-Key header, secrets.compare_digest timing-safe)
- Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes)
- Real client IP extraction (X-Forwarded-For / CF-Connecting-IP)
- Global exception handler (no stack trace leaks, passes HTTPException through)
- CORS with auto-disable credentials on wildcard origins
- Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns
- Heartbeats router, per-ID endpoints for locations/attendance/bluetooth
- Input dedup in batch create, result ordering preserved
- Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion
- Device detail panel with feature toggles and command controls
- Side panel for location/beacon pages with auto-select active device

via [HAPI](https://hapi.run)

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-20 09:18:43 +00:00
parent 1bdbe4fa19
commit 7d6040af41
23 changed files with 1564 additions and 294 deletions

View File

@@ -1,7 +1,7 @@
from datetime import datetime
from typing import Any, Generic, TypeVar
from typing import Any, Generic, Literal, TypeVar
from pydantic import BaseModel, ConfigDict, Field
from pydantic import BaseModel, ConfigDict, Field, model_validator
T = TypeVar("T")
@@ -42,8 +42,8 @@ class PaginatedList(BaseModel, Generic[T]):
class DeviceBase(BaseModel):
imei: str = Field(..., min_length=15, max_length=20, description="IMEI number")
device_type: str = Field(..., max_length=10, description="Device type code")
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number (digits only)")
device_type: str = Field(..., max_length=10, description="Device type code (e.g. P240, P241)")
name: str | None = Field(None, max_length=100, description="Friendly name")
timezone: str = Field(default="+8", max_length=30)
language: str = Field(default="cn", max_length=10)
@@ -55,9 +55,7 @@ class DeviceCreate(DeviceBase):
class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100)
status: str | None = Field(None, max_length=20)
battery_level: int | None = None
gsm_signal: int | None = None
status: Literal["online", "offline"] | None = Field(None, description="Device status")
iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30)
@@ -95,8 +93,8 @@ class DeviceSingleResponse(APIResponse[DeviceResponse]):
class LocationRecordBase(BaseModel):
device_id: int
location_type: str = Field(..., max_length=10)
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
@@ -148,8 +146,8 @@ class AlarmRecordBase(BaseModel):
alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=10)
protocol_number: int
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
mcc: int | None = None
@@ -231,8 +229,8 @@ class AttendanceRecordBase(BaseModel):
attendance_type: str = Field(..., max_length=20)
protocol_number: int
gps_positioned: bool = False
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None
course: float | None = None
gps_satellites: int | None = None
@@ -288,8 +286,8 @@ class BluetoothRecordBase(BaseModel):
beacon_battery_unit: str | None = None
attendance_type: str | None = None
bluetooth_data: dict[str, Any] | None = None
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
recorded_at: datetime
@@ -321,17 +319,17 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, description="信标MAC地址")
beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID")
beacon_major: int | None = Field(None, description="iBeacon Major")
beacon_minor: int | None = Field(None, description="iBeacon Minor")
beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)")
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID")
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称")
floor: str | None = Field(None, max_length=20, description="楼层")
area: str | None = Field(None, max_length=100, description="区域")
latitude: float | None = Field(None, description="纬度")
longitude: float | None = Field(None, description="经度")
latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
longitude: float | None = Field(None, ge=-180, le=180, description="经度")
address: str | None = Field(None, description="详细地址")
status: str = Field(default="active", max_length=20, description="状态")
status: Literal["active", "inactive"] = Field(default="active", description="状态")
class BeaconConfigCreate(BeaconConfigBase):
@@ -339,22 +337,34 @@ class BeaconConfigCreate(BeaconConfigBase):
class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36)
beacon_major: int | None = None
beacon_minor: int | None = None
beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$")
beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100)
floor: str | None = Field(None, max_length=20)
area: str | None = Field(None, max_length=100)
latitude: float | None = None
longitude: float | None = None
latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = Field(None, ge=-180, le=180)
address: str | None = None
status: str | None = Field(None, max_length=20)
status: Literal["active", "inactive"] | None = None
class BeaconConfigResponse(BeaconConfigBase):
class BeaconConfigResponse(BaseModel):
"""Response model — no pattern validation on output (existing data may not conform)."""
model_config = ConfigDict(from_attributes=True)
id: int
beacon_mac: str
beacon_uuid: str | None = None
beacon_major: int | None = None
beacon_minor: int | None = None
name: str
floor: str | None = None
area: str | None = None
latitude: float | None = None
longitude: float | None = None
address: str | None = None
status: str
created_at: datetime
updated_at: datetime | None = None
@@ -367,10 +377,87 @@ class BeaconConfigResponse(BeaconConfigBase):
class CommandCreate(BaseModel):
device_id: int
command_type: str = Field(..., max_length=30)
command_content: str
command_content: str = Field(..., max_length=500)
server_flag: str = Field(..., max_length=20)
# ---------------------------------------------------------------------------
# Batch operation schemas
# ---------------------------------------------------------------------------
class BatchDeviceCreateItem(BaseModel):
"""Single device in a batch create request."""
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number")
device_type: str = Field(default="P241", max_length=10, description="Device type (P240/P241)")
name: str | None = Field(None, max_length=100, description="Friendly name")
class BatchDeviceCreateRequest(BaseModel):
"""Batch create devices."""
devices: list[BatchDeviceCreateItem] = Field(..., min_length=1, max_length=500, description="List of devices to create")
class BatchDeviceCreateResult(BaseModel):
"""Result of a single device in batch create."""
imei: str
success: bool
device_id: int | None = None
error: str | None = None
class BatchDeviceCreateResponse(BaseModel):
"""Summary of batch create operation."""
total: int
created: int
failed: int
results: list[BatchDeviceCreateResult]
class BatchCommandRequest(BaseModel):
"""Send the same command to multiple devices."""
device_ids: list[int] | None = Field(default=None, min_length=1, max_length=100, description="Device IDs (provide device_ids or imeis)")
imeis: list[str] | None = Field(default=None, min_length=1, max_length=100, description="IMEI list (alternative to device_ids)")
command_type: str = Field(..., max_length=30, description="Command type (e.g. online_cmd)")
command_content: str = Field(..., max_length=500, description="Command content")
@model_validator(mode="after")
def check_device_ids_or_imeis(self):
if not self.device_ids and not self.imeis:
raise ValueError("Must provide device_ids or imeis / 必须提供 device_ids 或 imeis")
if self.device_ids and self.imeis:
raise ValueError("Provide device_ids or imeis, not both / 不能同时提供 device_ids 和 imeis")
return self
class BatchCommandResult(BaseModel):
"""Result of a single command in batch send."""
device_id: int
imei: str
success: bool
command_id: int | None = None
error: str | None = None
class BatchCommandResponse(BaseModel):
"""Summary of batch command operation."""
total: int
sent: int
failed: int
results: list[BatchCommandResult]
class BatchDeviceDeleteRequest(BaseModel):
"""Batch delete devices."""
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="Device IDs to delete")
class BatchDeviceUpdateRequest(BaseModel):
"""Batch update devices with the same settings."""
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="Device IDs to update")
update: DeviceUpdate = Field(..., description="Fields to update")
class CommandUpdate(BaseModel):
response_content: str | None = None
status: str | None = Field(None, max_length=20)