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:
@@ -185,3 +185,20 @@ async def device_attendance(
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{attendance_id}",
|
||||
response_model=APIResponse[AttendanceRecordResponse],
|
||||
summary="获取考勤记录详情 / Get attendance record",
|
||||
)
|
||||
async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取考勤记录详情 / Get attendance record details by ID."""
|
||||
result = await db.execute(
|
||||
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
||||
|
||||
@@ -70,7 +70,6 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
|
||||
beacon = await beacon_service.create_beacon(db, body)
|
||||
await db.commit()
|
||||
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@@ -85,7 +84,6 @@ async def update_beacon(
|
||||
beacon = await beacon_service.update_beacon(db, beacon_id, body)
|
||||
if beacon is None:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
await db.commit()
|
||||
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@@ -98,5 +96,4 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
success = await beacon_service.delete_beacon(db, beacon_id)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
await db.commit()
|
||||
return APIResponse(message="Beacon deleted")
|
||||
|
||||
@@ -88,15 +88,14 @@ async def device_bluetooth_records(
|
||||
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取指定设备的蓝牙数据记录。
|
||||
Get Bluetooth records for a specific device.
|
||||
"""
|
||||
# Verify device exists
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
@@ -133,3 +132,20 @@ async def device_bluetooth_records(
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=APIResponse[BluetoothRecordResponse],
|
||||
summary="获取蓝牙记录详情 / Get bluetooth record",
|
||||
)
|
||||
async def get_bluetooth_record(record_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取蓝牙记录详情 / Get bluetooth record details by ID."""
|
||||
result = await db.execute(
|
||||
select(BluetoothRecord).where(BluetoothRecord.id == record_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Bluetooth record {record_id} not found")
|
||||
return APIResponse(data=BluetoothRecordResponse.model_validate(record))
|
||||
|
||||
@@ -3,19 +3,26 @@ Commands Router - 指令管理接口
|
||||
API endpoints for sending commands / messages to devices and viewing command history.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import settings
|
||||
from app.extensions import limiter
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchCommandRequest,
|
||||
BatchCommandResponse,
|
||||
BatchCommandResult,
|
||||
CommandResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import command_service, device_service
|
||||
from app.services import tcp_command_service
|
||||
|
||||
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
|
||||
|
||||
@@ -29,8 +36,8 @@ class SendCommandRequest(BaseModel):
|
||||
"""Request body for sending a command to a device."""
|
||||
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
||||
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
||||
command_type: str = Field(..., max_length=30, description="指令类型 / Command type")
|
||||
command_content: str = Field(..., description="指令内容 / Command content")
|
||||
command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
|
||||
command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
@@ -48,7 +55,7 @@ class SendTTSRequest(BaseModel):
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helper
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@@ -78,6 +85,57 @@ async def _resolve_device(
|
||||
return device
|
||||
|
||||
|
||||
async def _send_to_device(
|
||||
db: AsyncSession,
|
||||
device,
|
||||
command_type: str,
|
||||
command_content: str,
|
||||
executor,
|
||||
success_msg: str,
|
||||
fail_msg: str,
|
||||
):
|
||||
"""Common logic for sending command/message/tts to a device via TCP.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
executor : async callable
|
||||
The actual send function, e.g. tcp_command_service.send_command(...)
|
||||
"""
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||
)
|
||||
|
||||
command_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
command_type=command_type,
|
||||
command_content=command_content,
|
||||
)
|
||||
|
||||
try:
|
||||
await executor()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("Command send failed: %s", e)
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=fail_msg,
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
return APIResponse(
|
||||
message=success_msg,
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -126,46 +184,15 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
|
||||
Requires the device to be online.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
|
||||
# Import tcp_manager lazily to avoid circular imports
|
||||
from app.tcp_server import tcp_manager
|
||||
|
||||
# Check if device is connected
|
||||
if device.imei not in tcp_manager.connections:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||
)
|
||||
|
||||
# Create command log entry
|
||||
command_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type=body.command_type,
|
||||
command_content=body.command_content,
|
||||
)
|
||||
|
||||
# Send command via TCP
|
||||
try:
|
||||
await tcp_manager.send_command(
|
||||
executor=lambda: tcp_command_service.send_command(
|
||||
device.imei, body.command_type, body.command_content
|
||||
)
|
||||
except Exception as e:
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send command / 指令发送失败: {str(e)}",
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
return APIResponse(
|
||||
message="Command sent successfully / 指令发送成功",
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
),
|
||||
success_msg="Command sent successfully / 指令发送成功",
|
||||
fail_msg="Failed to send command / 指令发送失败",
|
||||
)
|
||||
|
||||
|
||||
@@ -181,41 +208,13 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
|
||||
Send a text message to a device using protocol 0x82.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
|
||||
from app.tcp_server import tcp_manager
|
||||
|
||||
if device.imei not in tcp_manager.connections:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||
)
|
||||
|
||||
# Create command log for the message
|
||||
command_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type="message",
|
||||
command_content=body.message,
|
||||
)
|
||||
|
||||
try:
|
||||
await tcp_manager.send_message(device.imei, body.message)
|
||||
except Exception as e:
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send message / 留言发送失败: {str(e)}",
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
return APIResponse(
|
||||
message="Message sent successfully / 留言发送成功",
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
executor=lambda: tcp_command_service.send_message(device.imei, body.message),
|
||||
success_msg="Message sent successfully / 留言发送成功",
|
||||
fail_msg="Failed to send message / 留言发送失败",
|
||||
)
|
||||
|
||||
|
||||
@@ -232,43 +231,77 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
|
||||
The device will use its built-in TTS engine to speak the text aloud.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
|
||||
from app.tcp_server import tcp_manager
|
||||
|
||||
if device.imei not in tcp_manager.connections:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||
)
|
||||
|
||||
tts_command = f"TTS,{body.text}"
|
||||
|
||||
# Create command log entry
|
||||
command_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type="tts",
|
||||
command_content=tts_command,
|
||||
executor=lambda: tcp_command_service.send_command(
|
||||
device.imei, "tts", tts_command
|
||||
),
|
||||
success_msg="TTS sent successfully / 语音下发成功",
|
||||
fail_msg="Failed to send TTS / 语音下发失败",
|
||||
)
|
||||
|
||||
try:
|
||||
await tcp_manager.send_command(device.imei, "tts", tts_command)
|
||||
except Exception as e:
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
@router.post(
|
||||
"/batch",
|
||||
response_model=APIResponse[BatchCommandResponse],
|
||||
status_code=201,
|
||||
summary="批量发送指令 / Batch send command to multiple devices",
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
向多台设备批量发送同一指令,最多100台。
|
||||
Send the same command to multiple devices (up to 100). Skips offline devices.
|
||||
"""
|
||||
# Resolve devices in single query (mutual exclusion validated by schema)
|
||||
if body.device_ids:
|
||||
devices = await device_service.get_devices_by_ids(db, body.device_ids)
|
||||
else:
|
||||
devices = await device_service.get_devices_by_imeis(db, body.imeis)
|
||||
|
||||
results = []
|
||||
for device in devices:
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=False, error="Device offline",
|
||||
))
|
||||
continue
|
||||
|
||||
try:
|
||||
cmd_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
command_type=body.command_type,
|
||||
command_content=body.command_content,
|
||||
)
|
||||
await tcp_command_service.send_command(
|
||||
device.imei, body.command_type, body.command_content
|
||||
)
|
||||
cmd_log.status = "sent"
|
||||
await db.flush()
|
||||
await db.refresh(cmd_log)
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=True, command_id=cmd_log.id,
|
||||
))
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("Batch cmd failed for %s: %s", device.imei, e)
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=False, error="Send failed",
|
||||
))
|
||||
|
||||
sent = sum(1 for r in results if r.success)
|
||||
failed = len(results) - sent
|
||||
return APIResponse(
|
||||
message="TTS sent successfully / 语音下发成功",
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
message=f"Batch command: {sent} sent, {failed} failed",
|
||||
data=BatchCommandResponse(
|
||||
total=len(results), sent=sent, failed=failed, results=results,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -5,17 +5,24 @@ API endpoints for device CRUD operations and statistics.
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchDeviceCreateRequest,
|
||||
BatchDeviceCreateResponse,
|
||||
BatchDeviceCreateResult,
|
||||
BatchDeviceDeleteRequest,
|
||||
BatchDeviceUpdateRequest,
|
||||
DeviceCreate,
|
||||
DeviceResponse,
|
||||
DeviceUpdate,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.extensions import limiter
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
|
||||
@@ -81,6 +88,76 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
response_model=APIResponse[BatchDeviceCreateResponse],
|
||||
status_code=201,
|
||||
summary="批量创建设备 / Batch create devices",
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
批量注册设备(最多500台),跳过IMEI重复的设备。
|
||||
Batch register devices (up to 500). Skips devices with duplicate IMEIs.
|
||||
"""
|
||||
results = await device_service.batch_create_devices(db, body.devices)
|
||||
created = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - created
|
||||
return APIResponse(
|
||||
message=f"Batch create: {created} created, {failed} failed",
|
||||
data=BatchDeviceCreateResponse(
|
||||
total=len(results),
|
||||
created=created,
|
||||
failed=failed,
|
||||
results=[BatchDeviceCreateResult(**r) for r in results],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/batch",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量更新设备 / Batch update devices",
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
批量更新设备信息(名称、状态等),最多500台。
|
||||
Batch update device fields (name, status, etc.) for up to 500 devices.
|
||||
"""
|
||||
results = await device_service.batch_update_devices(db, body.device_ids, body.update)
|
||||
updated = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - updated
|
||||
return APIResponse(
|
||||
message=f"Batch update: {updated} updated, {failed} failed",
|
||||
data={"total": len(results), "updated": updated, "failed": failed, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除设备 / Batch delete devices",
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_delete_devices(
|
||||
request: Request,
|
||||
body: BatchDeviceDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量删除设备(最多100台)。通过 POST body 传递 device_ids 列表。
|
||||
Batch delete devices (up to 100). Pass device_ids in request body.
|
||||
"""
|
||||
results = await device_service.batch_delete_devices(db, body.device_ids)
|
||||
deleted = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - deleted
|
||||
return APIResponse(
|
||||
message=f"Batch delete: {deleted} deleted, {failed} failed",
|
||||
data={"total": len(results), "deleted": deleted, "failed": failed, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
|
||||
92
app/routers/heartbeats.py
Normal file
92
app/routers/heartbeats.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Heartbeats Router - 心跳数据接口
|
||||
API endpoints for querying device heartbeat records.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import HeartbeatRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
HeartbeatRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/heartbeats", tags=["Heartbeats / 心跳数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[HeartbeatRecordResponse]],
|
||||
summary="获取心跳记录列表 / List heartbeat records",
|
||||
)
|
||||
async def list_heartbeats(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取心跳记录列表,支持按设备和时间范围过滤。
|
||||
List heartbeat records with optional device and time range filters.
|
||||
"""
|
||||
query = select(HeartbeatRecord)
|
||||
count_query = select(func.count(HeartbeatRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(HeartbeatRecord.device_id == device_id)
|
||||
count_query = count_query.where(HeartbeatRecord.device_id == device_id)
|
||||
|
||||
if start_time:
|
||||
query = query.where(HeartbeatRecord.created_at >= start_time)
|
||||
count_query = count_query.where(HeartbeatRecord.created_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(HeartbeatRecord.created_at <= end_time)
|
||||
count_query = count_query.where(HeartbeatRecord.created_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(HeartbeatRecord.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[HeartbeatRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{heartbeat_id}",
|
||||
response_model=APIResponse[HeartbeatRecordResponse],
|
||||
summary="获取心跳详情 / Get heartbeat details",
|
||||
)
|
||||
async def get_heartbeat(heartbeat_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取心跳记录详情。
|
||||
Get heartbeat record details by ID.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(HeartbeatRecord).where(HeartbeatRecord.id == heartbeat_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Heartbeat {heartbeat_id} not found")
|
||||
return APIResponse(data=HeartbeatRecordResponse.model_validate(record))
|
||||
@@ -7,9 +7,11 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import LocationRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
LocationRecordResponse,
|
||||
@@ -92,6 +94,7 @@ async def device_track(
|
||||
device_id: int,
|
||||
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
|
||||
max_points: int = Query(default=10000, ge=1, le=50000, description="最大轨迹点数 / Max track points"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
@@ -109,7 +112,23 @@ async def device_track(
|
||||
detail="start_time must be before end_time / 开始时间必须早于结束时间",
|
||||
)
|
||||
|
||||
records = await location_service.get_device_track(db, device_id, start_time, end_time)
|
||||
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points)
|
||||
return APIResponse(
|
||||
data=[LocationRecordResponse.model_validate(r) for r in records]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse[LocationRecordResponse],
|
||||
summary="获取位置记录详情 / Get location record",
|
||||
)
|
||||
async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取位置记录详情 / Get location record details by ID."""
|
||||
result = await db.execute(
|
||||
select(LocationRecord).where(LocationRecord.id == location_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
|
||||
Reference in New Issue
Block a user