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:
80
app/main.py
80
app/main.py
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user