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:
@@ -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
142
app/routers/api_keys.py
Normal 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 / 密钥已停用")
|
||||
@@ -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)
|
||||
|
||||
@@ -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())
|
||||
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
55
app/routers/geocoding.py
Normal 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 ""}
|
||||
@@ -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
81
app/routers/ws.py
Normal 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)
|
||||
Reference in New Issue
Block a user