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:
147
app/schemas.py
147
app/schemas.py
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user