Add WebSocket, multi API key, geocoding proxy, beacon map picker, and comprehensive bug fixes

- Multi API Key + permission system (read/write/admin) with SHA-256 hash
- WebSocket real-time push (location, alarm, device_status, attendance, bluetooth)
- Geocoding proxy endpoints for Amap POI search and reverse geocode
- Beacon modal map-based location picker with search and click-to-select
- GCJ-02 ↔ WGS-84 bidirectional coordinate conversion
- Data cleanup scheduler (configurable retention days)
- Fix GPS longitude sign inversion (course_status bit 11: 0=East, 1=West)
- Fix 2G CellID 2→3 bytes across all protocols (0x28, 0x2C, parser.py)
- Fix parser loop guards, alarm_source field length, CommandLog.sent_at
- Fix geocoding IMEI parameterization, require_admin import
- Improve API error messages for 422 validation errors
- Remove beacon floor/area fields (consolidated into name)

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-24 05:10:05 +00:00
parent 7d6040af41
commit 11281e5be2
24 changed files with 1636 additions and 730 deletions

View File

@@ -5,11 +5,14 @@ API endpoints for alarm record queries, acknowledgement, and statistics.
import math
from datetime import datetime, timezone
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.models import AlarmRecord
from app.schemas import (
@@ -30,16 +33,18 @@ router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
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 alarm records with filters for device, alarm type, acknowledged status, and time range.
获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
@@ -52,6 +57,10 @@ async def list_alarms(
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
@@ -68,7 +77,8 @@ async def list_alarms(
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size)
order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
@@ -144,6 +154,7 @@ async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
)
async def acknowledge_alarm(
alarm_id: int,

142
app/routers/api_keys.py Normal file
View File

@@ -0,0 +1,142 @@
"""
API Keys Router - API密钥管理接口
Endpoints for creating, listing, updating, and deactivating API keys.
Admin permission required.
"""
import secrets
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.dependencies import require_admin, _hash_key
from app.models import ApiKey
from app.schemas import (
APIResponse,
ApiKeyCreate,
ApiKeyCreateResponse,
ApiKeyResponse,
ApiKeyUpdate,
PaginatedList,
)
import math
router = APIRouter(prefix="/api/keys", tags=["API Keys / 密钥管理"])
def _generate_key() -> str:
"""Generate a random 32-char hex API key."""
return secrets.token_hex(16)
@router.get(
"",
response_model=APIResponse[PaginatedList[ApiKeyResponse]],
summary="列出API密钥 / List API keys",
dependencies=[Depends(require_admin)],
)
async def list_keys(
page: int = Query(default=1, ge=1),
page_size: int = Query(default=20, ge=1, le=100),
db: AsyncSession = Depends(get_db),
):
"""列出所有API密钥不返回密钥值/ List all API keys (key values are never shown)."""
count_result = await db.execute(select(func.count(ApiKey.id)))
total = count_result.scalar() or 0
offset = (page - 1) * page_size
result = await db.execute(
select(ApiKey).order_by(ApiKey.created_at.desc()).offset(offset).limit(page_size)
)
keys = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[ApiKeyResponse.model_validate(k) for k in keys],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@router.post(
"",
response_model=APIResponse[ApiKeyCreateResponse],
status_code=201,
summary="创建API密钥 / Create API key",
dependencies=[Depends(require_admin)],
)
async def create_key(body: ApiKeyCreate, db: AsyncSession = Depends(get_db)):
"""创建新的API密钥。明文密钥仅在创建时返回一次。
Create a new API key. The plaintext key is returned only once."""
raw_key = _generate_key()
key_hash = _hash_key(raw_key)
db_key = ApiKey(
key_hash=key_hash,
name=body.name,
permissions=body.permissions,
)
db.add(db_key)
await db.flush()
await db.refresh(db_key)
# Build response with plaintext key included (shown once)
base_data = ApiKeyResponse.model_validate(db_key).model_dump()
base_data["key"] = raw_key
response_data = ApiKeyCreateResponse(**base_data)
return APIResponse(
message="API key created. Store the key securely — it won't be shown again. / API密钥已创建请妥善保管",
data=response_data,
)
@router.put(
"/{key_id}",
response_model=APIResponse[ApiKeyResponse],
summary="更新API密钥 / Update API key",
dependencies=[Depends(require_admin)],
)
async def update_key(
key_id: int, body: ApiKeyUpdate, db: AsyncSession = Depends(get_db)
):
"""更新API密钥的名称、权限或激活状态 / Update key name, permissions, or active status."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
update_data = body.model_dump(exclude_unset=True)
for field, value in update_data.items():
setattr(db_key, field, value)
await db.flush()
await db.refresh(db_key)
return APIResponse(
message="API key updated / 密钥已更新",
data=ApiKeyResponse.model_validate(db_key),
)
@router.delete(
"/{key_id}",
response_model=APIResponse,
summary="停用API密钥 / Deactivate API key",
dependencies=[Depends(require_admin)],
)
async def deactivate_key(key_id: int, db: AsyncSession = Depends(get_db)):
"""停用API密钥软删除/ Deactivate an API key (soft delete)."""
result = await db.execute(select(ApiKey).where(ApiKey.id == key_id))
db_key = result.scalar_one_or_none()
if db_key is None:
raise HTTPException(status_code=404, detail="API key not found / 未找到密钥")
db_key.is_active = False
await db.flush()
return APIResponse(message="API key deactivated / 密钥已停用")

View File

@@ -8,6 +8,8 @@ import math
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy.ext.asyncio import AsyncSession
from app.dependencies import require_write
from app.database import get_db
from app.schemas import (
APIResponse,
@@ -64,6 +66,7 @@ async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BeaconConfigResponse],
status_code=201,
summary="添加信标 / Create beacon",
dependencies=[Depends(require_write)],
)
async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)):
existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac)
@@ -77,6 +80,7 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
summary="更新信标 / Update beacon",
dependencies=[Depends(require_write)],
)
async def update_beacon(
beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db)
@@ -91,6 +95,7 @@ async def update_beacon(
"/{beacon_id}",
response_model=APIResponse,
summary="删除信标 / Delete beacon",
dependencies=[Depends(require_write)],
)
async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
success = await beacon_service.delete_beacon(db, beacon_id)

View File

@@ -5,6 +5,7 @@ API endpoints for querying Bluetooth punch and location records.
import math
from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import func, select
@@ -30,15 +31,17 @@ router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
async def list_bluetooth_records(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
beacon_mac: str | None = Query(default=None, description="信标MAC / Beacon MAC filter"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
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 Bluetooth records with filters for device, record type, and time range.
获取蓝牙数据记录列表,支持按设备、记录类型、信标MAC、时间范围过滤。
List Bluetooth records with filters for device, record type, beacon MAC, and time range.
"""
query = select(BluetoothRecord)
count_query = select(func.count(BluetoothRecord.id))
@@ -51,6 +54,10 @@ async def list_bluetooth_records(
query = query.where(BluetoothRecord.record_type == record_type)
count_query = count_query.where(BluetoothRecord.record_type == record_type)
if beacon_mac:
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
count_query = count_query.where(BluetoothRecord.beacon_mac == beacon_mac)
if start_time:
query = query.where(BluetoothRecord.recorded_at >= start_time)
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
@@ -63,7 +70,8 @@ async def list_bluetooth_records(
total = total_result.scalar() or 0
offset = (page - 1) * page_size
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
order = BluetoothRecord.recorded_at.asc() if sort_order == "asc" else BluetoothRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
records = list(result.scalars().all())

View File

@@ -5,6 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
import logging
import math
from datetime import datetime, timezone
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field
@@ -21,6 +22,7 @@ from app.schemas import (
CommandResponse,
PaginatedList,
)
from app.dependencies import require_write
from app.services import command_service, device_service
from app.services import tcp_command_service
@@ -127,6 +129,7 @@ async def _send_to_device(
)
command_log.status = "sent"
command_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(command_log)
@@ -148,17 +151,18 @@ async def _send_to_device(
)
async def list_commands(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"),
status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"),
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 command history with optional device and status filters.
获取指令历史记录,支持按设备、指令类型和状态过滤。
List command history with optional device, command type and status filters.
"""
commands, total = await command_service.get_commands(
db, device_id=device_id, status=status, page=page, page_size=page_size
db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size
)
return APIResponse(
data=PaginatedList(
@@ -176,6 +180,7 @@ async def list_commands(
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送指令 / Send command to device",
dependencies=[Depends(require_write)],
)
async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -201,6 +206,7 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse],
status_code=201,
summary="发送留言 / Send message to device (0x82)",
dependencies=[Depends(require_write)],
)
async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -223,6 +229,7 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
response_model=APIResponse[CommandResponse],
status_code=201,
summary="语音下发 / Send TTS voice broadcast to device",
dependencies=[Depends(require_write)],
)
async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
"""
@@ -249,6 +256,7 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[BatchCommandResponse],
status_code=201,
summary="批量发送指令 / Batch send command to multiple devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)):
@@ -282,6 +290,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
cmd_log.sent_at = datetime.now(timezone.utc)
await db.flush()
await db.refresh(cmd_log)
results.append(BatchCommandResult(

View File

@@ -22,8 +22,10 @@ from app.schemas import (
PaginatedList,
)
from app.config import settings
from app.dependencies import require_write
from app.extensions import limiter
from app.services import device_service
from app.schemas import LocationRecordResponse
from app.services import device_service, location_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
@@ -88,11 +90,26 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
return APIResponse(data=DeviceResponse.model_validate(device))
@router.get(
"/all-latest-locations",
response_model=APIResponse[list[LocationRecordResponse]],
summary="获取所有在线设备位置 / Get all online device locations",
)
async def all_latest_locations(db: AsyncSession = Depends(get_db)):
"""
获取所有在线设备的最新位置,用于地图总览。
Get latest location for all online devices, for map overview.
"""
records = await location_service.get_all_online_latest_locations(db)
return APIResponse(data=[LocationRecordResponse.model_validate(r) for r in records])
@router.post(
"/batch",
response_model=APIResponse[BatchDeviceCreateResponse],
status_code=201,
summary="批量创建设备 / Batch create devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)):
@@ -118,6 +135,7 @@ async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest,
"/batch",
response_model=APIResponse[dict],
summary="批量更新设备 / Batch update devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
@@ -138,6 +156,7 @@ async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest,
"/batch-delete",
response_model=APIResponse[dict],
summary="批量删除设备 / Batch delete devices",
dependencies=[Depends(require_write)],
)
@limiter.limit(settings.RATE_LIMIT_WRITE)
async def batch_delete_devices(
@@ -179,6 +198,7 @@ async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
response_model=APIResponse[DeviceResponse],
status_code=201,
summary="创建设备 / Create device",
dependencies=[Depends(require_write)],
)
async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)):
"""
@@ -201,6 +221,7 @@ async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(ge
"/{device_id}",
response_model=APIResponse[DeviceResponse],
summary="更新设备信息 / Update device",
dependencies=[Depends(require_write)],
)
async def update_device(
device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db)
@@ -219,6 +240,7 @@ async def update_device(
"/{device_id}",
response_model=APIResponse,
summary="删除设备 / Delete device",
dependencies=[Depends(require_write)],
)
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
"""

55
app/routers/geocoding.py Normal file
View File

@@ -0,0 +1,55 @@
"""Geocoding proxy endpoints — keeps AMAP_KEY server-side."""
from fastapi import APIRouter, Query
import httpx
from app.config import settings
from app.geocoding import reverse_geocode, _amap_sign, AMAP_KEY
router = APIRouter(prefix="/api/geocode", tags=["geocoding"])
@router.get("/search")
async def search_location(
keyword: str = Query(..., min_length=1, max_length=100),
city: str = Query(default="", max_length=50),
):
"""Proxy for Amap POI text search. Returns GCJ-02 coordinates."""
if not AMAP_KEY:
return {"results": []}
params = {
"key": AMAP_KEY,
"keywords": keyword,
"output": "json",
"offset": "10",
"page": "1",
}
if city:
params["city"] = city
sig = _amap_sign(params)
if sig:
params["sig"] = sig
async with httpx.AsyncClient(timeout=5.0) as client:
resp = await client.get("https://restapi.amap.com/v3/place/text", params=params)
data = resp.json()
if data.get("status") != "1":
return {"results": []}
results = []
for poi in data.get("pois", [])[:10]:
if poi.get("location"):
results.append({
"name": poi.get("name", ""),
"address": poi.get("address", ""),
"location": poi["location"], # "lng,lat" in GCJ-02
})
return {"results": results}
@router.get("/reverse")
async def reverse_geocode_endpoint(
lat: float = Query(..., ge=-90, le=90),
lon: float = Query(..., ge=-180, le=180),
):
"""Reverse geocode WGS-84 coords to address via Amap."""
address = await reverse_geocode(lat, lon)
return {"address": address or ""}

View File

@@ -6,7 +6,7 @@ API endpoints for querying location records and device tracks.
import math
from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
@@ -85,6 +85,27 @@ async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
return APIResponse(data=LocationRecordResponse.model_validate(record))
@router.post(
"/batch-latest",
response_model=APIResponse[list[LocationRecordResponse | None]],
summary="批量获取设备最新位置 / Batch get latest locations",
)
async def batch_latest_locations(
device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True),
db: AsyncSession = Depends(get_db),
):
"""
传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。
Pass device_ids list, returns latest location per device in input order.
"""
records = await location_service.get_batch_latest_locations(db, device_ids)
result_map = {r.device_id: r for r in records}
return APIResponse(data=[
LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None
for did in device_ids
])
@router.get(
"/track/{device_id}",
response_model=APIResponse[list[LocationRecordResponse]],

81
app/routers/ws.py Normal file
View File

@@ -0,0 +1,81 @@
"""
WebSocket Router - WebSocket 实时推送接口
Real-time data push via WebSocket with topic subscriptions.
"""
import logging
import secrets
from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect
from app.config import settings
from app.websocket_manager import ws_manager, VALID_TOPICS
logger = logging.getLogger(__name__)
router = APIRouter(tags=["WebSocket / 实时推送"])
@router.websocket("/ws")
async def websocket_endpoint(
websocket: WebSocket,
api_key: str | None = Query(default=None, alias="api_key"),
topics: str | None = Query(default=None, description="Comma-separated topics"),
):
"""
WebSocket endpoint for real-time data push.
Connect: ws://host/ws?api_key=xxx&topics=location,alarm
Topics: location, alarm, device_status, attendance, bluetooth
If no topics specified, subscribes to all.
"""
# Authenticate
if settings.API_KEY is not None:
if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY):
# For DB keys, do a simple hash check
if api_key is not None:
from app.dependencies import _hash_key
from app.database import async_session
from sqlalchemy import select
from app.models import ApiKey
try:
async with async_session() as session:
key_hash = _hash_key(api_key)
result = await session.execute(
select(ApiKey.id).where(
ApiKey.key_hash == key_hash,
ApiKey.is_active == True, # noqa: E712
)
)
if result.scalar_one_or_none() is None:
await websocket.close(code=4001, reason="Invalid API key")
return
except Exception:
await websocket.close(code=4001, reason="Auth error")
return
else:
await websocket.close(code=4001, reason="Missing API key")
return
# Parse topics
requested_topics = set()
if topics:
requested_topics = {t.strip() for t in topics.split(",") if t.strip() in VALID_TOPICS}
if not await ws_manager.connect(websocket, requested_topics):
return
try:
# Keep connection alive, handle pings
while True:
data = await websocket.receive_text()
# Client can send "ping" to keep alive
if data.strip().lower() == "ping":
await websocket.send_text("pong")
except WebSocketDisconnect:
pass
except Exception:
logger.debug("WebSocket connection error", exc_info=True)
finally:
ws_manager.disconnect(websocket)