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

@@ -1,6 +1,6 @@
from pathlib import Path
from fastapi import FastAPI, Request
from fastapi import Depends, FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles
@@ -13,8 +13,8 @@ from slowapi.errors import RateLimitExceeded
from app.database import init_db, async_session, engine
from app.tcp_server import tcp_manager
from app.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats
from app.dependencies import verify_api_key
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding
from app.dependencies import verify_api_key, require_write, require_admin
import asyncio
import logging
@@ -26,6 +26,46 @@ logger = logging.getLogger(__name__)
from app.extensions import limiter
async def run_data_cleanup():
"""Delete records older than DATA_RETENTION_DAYS."""
from datetime import datetime, timezone, timedelta
from sqlalchemy import delete
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS)
total_deleted = 0
async with async_session() as session:
async with session.begin():
for model, time_col in [
(LocationRecord, LocationRecord.created_at),
(HeartbeatRecord, HeartbeatRecord.created_at),
(AlarmRecord, AlarmRecord.created_at),
(AttendanceRecord, AttendanceRecord.created_at),
(BluetoothRecord, BluetoothRecord.created_at),
]:
result = await session.execute(
delete(model).where(time_col < cutoff)
)
if result.rowcount:
total_deleted += result.rowcount
logger.info("Cleanup: deleted %d old %s records", result.rowcount, model.__tablename__)
return total_deleted
async def _data_cleanup_loop():
"""Background task that runs cleanup periodically."""
while True:
try:
await asyncio.sleep(settings.DATA_CLEANUP_INTERVAL_HOURS * 3600)
deleted = await run_data_cleanup()
if deleted:
logger.info("Data cleanup completed: %d records removed", deleted)
except asyncio.CancelledError:
break
except Exception:
logger.exception("Data cleanup error")
@asynccontextmanager
async def lifespan(app: FastAPI):
# Startup
@@ -41,10 +81,28 @@ async def lifespan(app: FastAPI):
logger.info("All devices reset to offline on startup")
except Exception:
logger.exception("Failed to reset device statuses on startup")
# Create missing indexes (safe for existing databases)
try:
from sqlalchemy import text as sa_text
async with engine.begin() as conn:
for stmt in [
"CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)",
"CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)",
"CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)",
"CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)",
"CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)",
]:
await conn.execute(sa_text(stmt))
logger.info("Database indexes verified/created")
except Exception:
logger.exception("Failed to create indexes")
logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT)
tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT))
cleanup_task = asyncio.create_task(_data_cleanup_loop())
yield
# Shutdown
cleanup_task.cancel()
logger.info("Shutting down TCP server...")
await tcp_manager.stop()
tcp_task.cancel()
@@ -119,6 +177,9 @@ app.include_router(commands.router, dependencies=[*_api_deps])
app.include_router(bluetooth.router, dependencies=[*_api_deps])
app.include_router(beacons.router, dependencies=[*_api_deps])
app.include_router(heartbeats.router, dependencies=[*_api_deps])
app.include_router(api_keys.router, dependencies=[*_api_deps])
app.include_router(ws.router) # WebSocket handles auth internally
app.include_router(geocoding.router, dependencies=[*_api_deps])
_STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -156,9 +217,22 @@ async def health():
except Exception:
logger.warning("Health check: database unreachable")
from app.websocket_manager import ws_manager
status = "healthy" if db_ok else "degraded"
return {
"status": status,
"database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections),
"websocket_connections": ws_manager.connection_count,
}
@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else [])
async def manual_cleanup():
"""手动触发数据清理 / Manually trigger data cleanup (admin only)."""
try:
deleted = await run_data_cleanup()
return {"code": 0, "message": f"Cleanup completed: {deleted} records removed", "data": {"deleted": deleted}}
except Exception as e:
logger.exception("Manual cleanup failed")
return {"code": 500, "message": f"Cleanup failed: {str(e)}", "data": None}