diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..1a05bcd --- /dev/null +++ b/.env.example @@ -0,0 +1,43 @@ +# KKS Badge Management System - Environment Configuration +# Copy to .env and customize values as needed + +# Database (default: SQLite in project root) +# DATABASE_URL=sqlite+aiosqlite:///path/to/badge_admin.db +# DATABASE_URL=postgresql+asyncpg://user:password@localhost/badge_admin + +# Server ports +# TCP_PORT=5000 +# API_PORT=8088 + +# Debug mode (default: false) +# DEBUG=true + +# API authentication (uncomment to enable, all /api/ endpoints require X-API-Key header) +# API_KEY=your-secret-api-key-here + +# CORS origins (comma-separated, * = allow all) +# CORS_ORIGINS=https://example.com,https://admin.example.com + +# Rate limiting (format: "count/period", period = second/minute/hour/day) +# RATE_LIMIT_DEFAULT=60/minute +# RATE_LIMIT_WRITE=30/minute + +# Track query max points (default: 10000) +# TRACK_MAX_POINTS=10000 + +# 天地图 API key (reverse geocoding, free 10k/day) +# Sign up: https://lbs.tianditu.gov.cn/ +# TIANDITU_API_KEY=your_tianditu_key + +# Google Geolocation API (optional, for cell/WiFi geocoding) +# GOOGLE_API_KEY=your_google_key + +# Unwired Labs API (optional, for cell/WiFi geocoding) +# UNWIRED_API_TOKEN=your_unwired_token + +# 高德地图 API (optional, requires enterprise auth for IoT positioning) +# AMAP_KEY=your_amap_key +# AMAP_SECRET=your_amap_secret + +# Geocoding cache size +# GEOCODING_CACHE_SIZE=10000 diff --git a/.gitignore b/.gitignore index f860994..f250e7c 100644 --- a/.gitignore +++ b/.gitignore @@ -3,4 +3,5 @@ __pycache__/ *.db *.log nohup.out +.env .claude/ diff --git a/CLAUDE.md b/CLAUDE.md index 4fe04e2..144e190 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -10,6 +10,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T ``` /home/gpsystem/ ├── run.py # 启动脚本 (uvicorn) +├── .env.example # 环境变量模板 (复制为 .env 使用) ├── requirements.txt # Python 依赖 ├── frpc.toml # FRP 客户端配置 (TCP隧道) ├── badge_admin.db # SQLite 数据库 @@ -17,8 +18,10 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ ├── app/ │ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器 -│ ├── config.py # 配置 (端口8088 HTTP, 5000 TCP) +│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流) │ ├── database.py # SQLAlchemy async 数据库连接 +│ ├── dependencies.py # FastAPI 依赖 (API Key 认证) +│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取) │ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog) │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化 @@ -31,18 +34,20 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408) │ │ │ ├── services/ -│ │ ├── device_service.py # 设备 CRUD -│ │ ├── command_service.py # 指令日志 CRUD -│ │ ├── location_service.py # 位置记录查询 -│ │ └── beacon_service.py # 蓝牙信标 CRUD +│ │ ├── device_service.py # 设备 CRUD +│ │ ├── command_service.py # 指令日志 CRUD +│ │ ├── location_service.py # 位置记录查询 +│ │ ├── beacon_service.py # 蓝牙信标 CRUD +│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server) │ │ │ ├── routers/ -│ │ ├── devices.py # /api/devices (含 /stats 统计接口) -│ │ ├── commands.py # /api/commands (含 /send, /message, /tts) -│ │ ├── locations.py # /api/locations (含 /latest, /track) +│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete) +│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch) +│ │ ├── locations.py # /api/locations (含 /latest, /track, /{id}) │ │ ├── alarms.py # /api/alarms (含 acknowledge) -│ │ ├── attendance.py # /api/attendance -│ │ ├── bluetooth.py # /api/bluetooth +│ │ ├── attendance.py # /api/attendance (含 /stats, /{id}) +│ │ ├── bluetooth.py # /api/bluetooth (含 /{id}) +│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询) │ │ └── beacons.py # /api/beacons (信标管理 CRUD) │ │ │ └── static/ @@ -75,11 +80,18 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T - **7000**: FRP 服务端管理端口 - **7500**: FRP Dashboard (admin/PassWord0325) +### 配置管理 +- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值 +- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用 +- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录) +- 所有 API 密钥 (天地图/Google/Unwired/高德) 集中在 `config.py`,`geocoding.py` 从 `settings` 导入 +- 端口号有 pydantic 校验 (ge=1, le=65535) + ## 启动服务 ```bash # 启动服务 -cd /tmp/badge-admin +cd /home/gpsystem nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 & # 启动 FRP 客户端 (TCP隧道) @@ -192,6 +204,19 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` - **配额**: 免费 10,000 次/天 - **注意**: postStr 参数需使用双引号JSON并URL编码 +### API 认证与限流 +- **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头 +- **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`) +- **真实 IP**: 从 `X-Forwarded-For` → `CF-Connecting-IP` → `request.client.host` 提取 +- **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials` + +### 批量操作 API +- `POST /api/devices/batch` — 批量创建 (最多500),输入去重 + DB去重,结果按输入顺序 +- `PUT /api/devices/batch` — 批量更新,单次 WHERE IN 查询 + 单次 flush +- `POST /api/devices/batch-delete` — 批量删除 (最多100),通过 body 传 device_ids +- `POST /api/commands/batch` — 批量发送指令 (最多100),`model_validator` 互斥校验 +- 所有批量操作使用 WHERE IN 批量查询,避免 N+1 + ### API 分页 - page_size 最大限制为 100 (schema 层校验) - 前端设备选择器使用 page_size=100 (不能超过限制) @@ -209,6 +234,7 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` - TTS: 通过 0x80 发送 `TTS,<文本>` 格式 - 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描 - 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#` +- **架构**: `tcp_command_service.py` 作为抽象层解耦 routers↔tcp_server 的循环导入,通过 lazy import 访问 tcp_manager - **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理 - **重要**: 服务器启动时自动将所有设备标记为 offline,等待设备重新登录 @@ -219,7 +245,9 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` - **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置 - **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描 - **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功 -- **当前状态**: 设备接受BT指令但尚未上报0xB2/0xB3数据,可能需要信标UUID匹配或通过Tracksolid平台配置 +- **当前状态**: ✅ 0xB2 蓝牙打卡已验证可用 (2026-03-18),设备成功检测信标并上报数据 +- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641 +- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空 - **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列 ### 0x94 General Info 子协议 @@ -289,6 +317,18 @@ remotePort = 5001 3. 注意返回坐标为 GCJ-02,需转换为 WGS-84 用于 Leaflet 地图 4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数 +## 百度地图 API + +### Key +- **服务端 AK**: `nZ4AyCm7FTn85HbFuQjw0ItSYkgxEuhA` + +### 已接入服务 +- **✅ 逆地理编码**: `api.map.baidu.com/reverse_geocoding/v3` — 经纬度 → 地址 (coordtype=wgs84ll, 无需坐标转换) + - 优先级: 百度 > 天地图 (fallback) + - 配额: 5,000次/日 (个人开发者) +- **注意**: 百度内部使用 BD-09 坐标系,但逆地理编码接口支持 `coordtype=wgs84ll` 直接传入 WGS-84 坐标 +- 百度**无服务端基站/WiFi定位API**,基站定位仍用 Mylnikov + ## 已知限制 1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备 @@ -296,7 +336,7 @@ remotePort = 5001 3. **SQLite 单写** - 高并发场景需切换 PostgreSQL 4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询 5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km,待接入高德智能硬件定位后可达 ~30m -6. **天地图逆地理编码使用 HTTP** - API不支持HTTPS,Key在URL中明文传输 (低风险: 免费Key) +6. **天地图逆地理编码使用 HTTP** - API不支持HTTPS,Key在URL中明文传输 (低风险: 免费Key, 已降级为备选) ## 已修复的问题 (Bug Fix 记录) @@ -361,6 +401,36 @@ remotePort = 5001 42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项 43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档 +### 架构优化 (2026-03-17) +44. **配置集中化** - API密钥从 geocoding.py 硬编码移至 config.py (pydantic-settings),支持 .env 覆盖 +45. **数据库绝对路径** - DATABASE_URL 从相对路径改为基于 `__file__` 的绝对路径,避免 CWD 依赖 +46. **tcp_command_service 抽象层** - 新建 services/tcp_command_service.py,通过 lazy import 解耦 routers↔tcp_server 循环依赖 +47. **commands.py 去重** - send_command/send_message/send_tts 提取 `_send_to_device()` 通用函数 +48. **协议常量扩展** - constants.py 新增 DEFAULT_DEVICE_TYPE, SERVER_FLAG_BYTES, ATTENDANCE_TYPES, COURSE_BIT_*, MCC_MNC2_FLAG, VOLTAGE_LEVELS +49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备 +50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数,toggleSidePanel 仅在 locations 页调用 invalidateSize + +### 百度地图接入 & 连接修复 (2026-03-19) +51. **百度逆地理编码** - 接入百度地图 reverse_geocoding/v3 API (coordtype=wgs84ll),优先于天地图 +52. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError,补充 import +53. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections +54. **高德地图瓦片** - 替换天地图瓦片为高德 (GCJ-02, 标准Mercator),添加 WGS84→GCJ02 坐标转换,可切换 provider (`MAP_PROVIDER` 变量) + +### API 安全加固 & 批量管理 (2026-03-20) +55. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击 +56. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min) +57. **限流器真实 IP** - `extensions.py` 从 `X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP +58. **全局异常处理** - 拦截未处理异常返回 500,不泄露堆栈;放行 HTTPException/ValidationError +59. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern +60. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数 +61. **批量设备创建** - `POST /api/devices/batch` (最多500台),WHERE IN 单次查询去重,输入列表内 IMEI 去重 +62. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush +63. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制 +64. **批量指令发送** - `POST /api/commands/batch` (最多100台),`model_validator` 互斥校验 device_ids/imeis +65. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取 +66. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突 +67. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh,依赖 service 层 + ### 0x94 子协议 0x04 - 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286` - 在设备重连/重启后上报 @@ -368,9 +438,9 @@ remotePort = 5001 ## 待完成功能 1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov,大幅提升 WiFi/基站定位精度 -2. **天地图瓦片底图** - 使用浏览器端 Key 替换 OpenStreetMap 瓦片 (中国地区显示更准确) +2. ~~**地图瓦片**~~ - ✅ 已切换为高德瓦片 (GCJ-02),支持 MAP_PROVIDER 切换 ('gaode'|'tianditu') 3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 -4. **蓝牙信标调试** - P241 接受 BTON/BTSCAN 但未上报BLE数据,需确认信标iBeacon广播格式及UUID匹配 +4. ~~**蓝牙信标调试**~~ - ✅ 已完成 (2026-03-18),0xB2打卡数据正常上报,信标匹配成功 ## 调试技巧 diff --git a/app/config.py b/app/config.py index 7450bfe..303ffdc 100644 --- a/app/config.py +++ b/app/config.py @@ -1,14 +1,49 @@ +from pathlib import Path +from typing import Literal + +from pydantic import Field from pydantic_settings import BaseSettings +# Project root directory (where config.py lives → parent = app/ → parent = project root) +_PROJECT_ROOT = Path(__file__).resolve().parent.parent +_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db" + class Settings(BaseSettings): APP_NAME: str = "KKS Badge Management System" - DATABASE_URL: str = "sqlite+aiosqlite:///./badge_admin.db" + DATABASE_URL: str = Field( + default=f"sqlite+aiosqlite:///{_DEFAULT_DB_PATH}", + description="Database connection URL (absolute path for SQLite)", + ) TCP_HOST: str = "0.0.0.0" - TCP_PORT: int = 5000 + TCP_PORT: int = Field(default=5000, ge=1, le=65535) API_HOST: str = "0.0.0.0" - API_PORT: int = 8088 - DEBUG: bool = True + API_PORT: int = Field(default=8088, ge=1, le=65535) + DEBUG: bool = Field(default=False, description="Enable debug mode (SQL echo, verbose errors)") + + # API authentication + API_KEY: str | None = Field(default=None, description="API key for authentication (None=disabled)") + CORS_ORIGINS: str = Field(default="*", description="Comma-separated allowed CORS origins") + + # Rate limiting + RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit") + RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations") + + # Geocoding API keys + TIANDITU_API_KEY: str | None = Field(default=None, description="天地图 API key for reverse geocoding") + GOOGLE_API_KEY: str | None = Field(default=None, description="Google Geolocation API key") + UNWIRED_API_TOKEN: str | None = Field(default=None, description="Unwired Labs API token") + AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key") + AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥") + BAIDU_MAP_AK: str | None = Field(default=None, description="百度地图服务端 AK") + + # Geocoding cache + GEOCODING_CACHE_SIZE: int = Field(default=10000, description="Max geocoding cache entries") + + # Track query limit + TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint") + + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} settings = Settings() diff --git a/app/dependencies.py b/app/dependencies.py new file mode 100644 index 0000000..88d69ef --- /dev/null +++ b/app/dependencies.py @@ -0,0 +1,20 @@ +""" +Shared FastAPI dependencies. +""" + +import secrets + +from fastapi import HTTPException, Security +from fastapi.security import APIKeyHeader + +from app.config import settings + +_api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) + + +async def verify_api_key(api_key: str | None = Security(_api_key_header)): + """Verify API key if authentication is enabled.""" + if settings.API_KEY is None: + return # Auth disabled + if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY): + raise HTTPException(status_code=401, detail="Invalid or missing API key") diff --git a/app/extensions.py b/app/extensions.py new file mode 100644 index 0000000..ec512bd --- /dev/null +++ b/app/extensions.py @@ -0,0 +1,22 @@ +""" +Shared extension instances (rate limiter, etc.) to avoid circular imports. +""" + +from starlette.requests import Request +from slowapi import Limiter + +from app.config import settings + + +def _get_real_client_ip(request: Request) -> str: + """Extract real client IP from X-Forwarded-For (behind Cloudflare/nginx) or fallback.""" + forwarded = request.headers.get("X-Forwarded-For") + if forwarded: + return forwarded.split(",")[0].strip() + cf_ip = request.headers.get("CF-Connecting-IP") + if cf_ip: + return cf_ip.strip() + return request.client.host if request.client else "127.0.0.1" + + +limiter = Limiter(key_func=_get_real_client_ip, default_limits=[settings.RATE_LIMIT_DEFAULT]) diff --git a/app/geocoding.py b/app/geocoding.py index 1a712ae..1897598 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -10,7 +10,6 @@ Uses free APIs: import json import logging -import os from collections import OrderedDict from typing import Optional from urllib.parse import quote @@ -19,19 +18,16 @@ import aiohttp logger = logging.getLogger(__name__) -# Google Geolocation API key (set to enable Google geocoding) -GOOGLE_API_KEY: Optional[str] = None +# Import keys from centralized config (no more hardcoded values here) +from app.config import settings as _settings -# Unwired Labs API token (free tier: 100 requests/day) -# Sign up at https://unwiredlabs.com/ -UNWIRED_API_TOKEN: Optional[str] = None +GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY +UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN +TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY +BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK -# 天地图 API key (free tier: 10000 requests/day) -# Sign up at https://lbs.tianditu.gov.cn/ -TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc") - -# Maximum cache entries (LRU eviction) -_CACHE_MAX_SIZE = 10000 +# Maximum cache entries (LRU eviction) — configurable via settings +_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE class LRUCache(OrderedDict): @@ -315,7 +311,8 @@ async def reverse_geocode( """ Convert lat/lon to a human-readable address. - Tries 天地图 (Tianditu) first, which uses WGS84 natively. + Priority: Baidu Map > Tianditu (fallback). + Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll). Returns None if no reverse geocoding service is available. """ # Round to ~100m for cache key to reduce API calls @@ -324,6 +321,14 @@ async def reverse_geocode( if cached is not None: return cached + # Try Baidu Map first (higher quality addresses for China) + if BAIDU_MAP_AK: + result = await _reverse_geocode_baidu(lat, lon) + if result: + _address_cache.put(cache_key, result) + return result + + # Fallback to Tianditu if TIANDITU_API_KEY: result = await _reverse_geocode_tianditu(lat, lon) if result: @@ -333,6 +338,55 @@ async def reverse_geocode( return None +async def _reverse_geocode_baidu( + lat: float, lon: float +) -> Optional[str]: + """ + Use Baidu Map reverse geocoding API. + + API docs: https://lbsyun.baidu.com/faq/api?title=webapi/guide/webservice-geocoding + Input coordtype: wgs84ll (WGS-84, same as GPS data, no conversion needed). + Free tier: 5,000 requests/day (personal developer). + """ + url = ( + f"https://api.map.baidu.com/reverse_geocoding/v3/" + f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll" + f"&location={lat},{lon}" + ) + try: + async with aiohttp.ClientSession() as session: + async with session.get( + url, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + if data.get("status") == 0: + result = data.get("result", {}) + formatted = result.get("formatted_address", "") + if formatted: + # Add sematic_description for more context + sematic = result.get("sematic_description", "") + address = formatted + if sematic and sematic not in formatted: + address = f"{formatted} ({sematic})" + logger.info( + "Baidu reverse geocode: %.6f,%.6f -> %s", + lat, lon, address, + ) + return address + else: + logger.warning( + "Baidu reverse geocode error: status=%s, msg=%s", + data.get("status"), data.get("message", ""), + ) + else: + logger.warning("Baidu reverse geocode HTTP %d", resp.status) + except Exception as e: + logger.warning("Baidu reverse geocode error: %s", e) + + return None + + async def _reverse_geocode_tianditu( lat: float, lon: float ) -> Optional[str]: diff --git a/app/main.py b/app/main.py index 262f1a2..537542f 100644 --- a/app/main.py +++ b/app/main.py @@ -1,19 +1,31 @@ from pathlib import Path -from fastapi import FastAPI -from fastapi.responses import HTMLResponse +from fastapi import FastAPI, Request +from fastapi.middleware.cors import CORSMiddleware +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from contextlib import asynccontextmanager -from app.database import init_db, async_session +from slowapi import Limiter, _rate_limit_exceeded_handler +from slowapi.middleware import SlowAPIMiddleware +from slowapi.util import get_remote_address +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 +from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats +from app.dependencies import verify_api_key + import asyncio import logging logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) +# Rate limiter (shared instance via app.extensions for router access) +from app.extensions import limiter + + @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -40,7 +52,7 @@ async def lifespan(app: FastAPI): app = FastAPI( title="KKS Badge Management System / KKS工牌管理系统", description=""" -## KKS P240 & P241 蓝牙工牌管理后台 +## KKS P240 & P241 蓝牙工牌管理后台 API ### 功能模块 / Features: - **设备管理 / Device Management** - 设备注册、状态监控 @@ -50,6 +62,10 @@ app = FastAPI( - **指令管理 / Commands** - 远程指令下发与留言 - **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据 - **信标管理 / Beacons** - 蓝牙信标注册与位置配置 +- **心跳数据 / Heartbeats** - 设备心跳记录查询 + +### 认证 / Authentication: +设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。 ### 通讯协议 / Protocol: - TCP端口: {tcp_port} (设备连接) @@ -61,14 +77,48 @@ app = FastAPI( lifespan=lifespan, ) -# Include routers -app.include_router(devices.router) -app.include_router(locations.router) -app.include_router(alarms.router) -app.include_router(attendance.router) -app.include_router(commands.router) -app.include_router(bluetooth.router) -app.include_router(beacons.router) +# Rate limiter — SlowAPIMiddleware applies default_limits to all routes +app.state.limiter = limiter +app.add_middleware(SlowAPIMiddleware) +app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler) + +# CORS +_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()] +app.add_middleware( + CORSMiddleware, + allow_origins=_origins, + allow_credentials="*" not in _origins, + allow_methods=["*"], + allow_headers=["*"], +) + + +# Global exception handler — prevent stack trace leaks +@app.exception_handler(Exception) +async def global_exception_handler(request: Request, exc: Exception): + from fastapi.exceptions import RequestValidationError + from starlette.exceptions import HTTPException as StarletteHTTPException + # Let FastAPI handle its own exceptions + if isinstance(exc, (StarletteHTTPException, RequestValidationError)): + raise exc + logger.exception("Unhandled exception on %s %s", request.method, request.url.path) + return JSONResponse( + status_code=500, + content={"code": 500, "message": "Internal server error", "data": None}, + ) + + +# Include API routers (all under /api/ prefix, protected by API key if configured) +_api_deps = [verify_api_key] if settings.API_KEY else [] + +app.include_router(devices.router, dependencies=[*_api_deps]) +app.include_router(locations.router, dependencies=[*_api_deps]) +app.include_router(alarms.router, dependencies=[*_api_deps]) +app.include_router(attendance.router, dependencies=[*_api_deps]) +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]) _STATIC_DIR = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") @@ -90,11 +140,25 @@ async def root(): "redoc": "/redoc", "admin": "/admin", "tcp_port": settings.TCP_PORT, + "auth_enabled": settings.API_KEY is not None, } + @app.get("/health", tags=["Root"]) async def health(): + """Health check with database connectivity test.""" + db_ok = False + try: + from sqlalchemy import text + async with engine.connect() as conn: + await conn.execute(text("SELECT 1")) + db_ok = True + except Exception: + logger.warning("Health check: database unreachable") + + status = "healthy" if db_ok else "degraded" return { - "status": "healthy", + "status": status, + "database": "ok" if db_ok else "error", "connected_devices": len(tcp_manager.connections), } diff --git a/app/protocol/constants.py b/app/protocol/constants.py index 91e3b01..e83858a 100644 --- a/app/protocol/constants.py +++ b/app/protocol/constants.py @@ -136,6 +136,57 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({ # Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec }) +# --------------------------------------------------------------------------- +# Device Defaults +# --------------------------------------------------------------------------- +DEFAULT_DEVICE_TYPE: str = "P240" + +# --------------------------------------------------------------------------- +# Language Codes (used in 0x80 / 0x82 packets) +# --------------------------------------------------------------------------- +LANG_CHINESE: int = 0x0001 +LANG_ENGLISH: int = 0x0002 +DEFAULT_LANGUAGE: int = LANG_CHINESE +DEFAULT_LANGUAGE_BYTES: bytes = b"\x00\x01" + +# Server flag placeholder (4 bytes, used in command/message packets) +SERVER_FLAG_BYTES: bytes = b"\x00\x00\x00\x00" + +# --------------------------------------------------------------------------- +# Attendance Status (from terminal_info byte, bits[5:2]) +# --------------------------------------------------------------------------- +ATTENDANCE_STATUS_SHIFT: int = 2 +ATTENDANCE_STATUS_MASK: int = 0x0F +ATTENDANCE_TYPES: Dict[int, str] = { + 0b0001: "clock_in", + 0b0010: "clock_out", +} + +# --------------------------------------------------------------------------- +# GPS Course/Status Bit Fields +# --------------------------------------------------------------------------- +COURSE_BIT_REALTIME: int = 0x2000 # bit 13 +COURSE_BIT_POSITIONED: int = 0x1000 # bit 12 +COURSE_BIT_EAST: int = 0x0800 # bit 11 +COURSE_BIT_NORTH: int = 0x0400 # bit 10 +COURSE_MASK: int = 0x03FF # bits 9-0 + +# MCC high-bit flag: if set, MNC is 2 bytes instead of 1 +MCC_MNC2_FLAG: int = 0x8000 + +# --------------------------------------------------------------------------- +# Voltage Levels (0x00-0x06) +# --------------------------------------------------------------------------- +VOLTAGE_LEVELS: Dict[int, str] = { + 0x00: "shutdown", + 0x01: "very_low", + 0x02: "low", + 0x03: "medium", + 0x04: "good", + 0x05: "high", + 0x06: "full", +} + # --------------------------------------------------------------------------- # Protocol Number -> Human-Readable Name # --------------------------------------------------------------------------- diff --git a/app/routers/attendance.py b/app/routers/attendance.py index 886b339..f259369 100644 --- a/app/routers/attendance.py +++ b/app/routers/attendance.py @@ -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)) diff --git a/app/routers/beacons.py b/app/routers/beacons.py index 8b16e93..6273292 100644 --- a/app/routers/beacons.py +++ b/app/routers/beacons.py @@ -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") diff --git a/app/routers/bluetooth.py b/app/routers/bluetooth.py index 652987a..509598b 100644 --- a/app/routers/bluetooth.py +++ b/app/routers/bluetooth.py @@ -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)) diff --git a/app/routers/commands.py b/app/routers/commands.py index 119a6d4..fb910ef 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -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, + ), ) diff --git a/app/routers/devices.py b/app/routers/devices.py index aa9af31..19e97a8 100644 --- a/app/routers/devices.py +++ b/app/routers/devices.py @@ -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], diff --git a/app/routers/heartbeats.py b/app/routers/heartbeats.py new file mode 100644 index 0000000..43a0429 --- /dev/null +++ b/app/routers/heartbeats.py @@ -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)) diff --git a/app/routers/locations.py b/app/routers/locations.py index 568c02b..bd01f95 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -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)) diff --git a/app/schemas.py b/app/schemas.py index 26e42a4..0fa6c0d 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -1,7 +1,7 @@ from datetime import datetime -from typing import Any, Generic, TypeVar +from typing import Any, Generic, Literal, TypeVar -from pydantic import BaseModel, ConfigDict, Field +from pydantic import BaseModel, ConfigDict, Field, model_validator T = TypeVar("T") @@ -42,8 +42,8 @@ class PaginatedList(BaseModel, Generic[T]): class DeviceBase(BaseModel): - imei: str = Field(..., min_length=15, max_length=20, description="IMEI number") - device_type: str = Field(..., max_length=10, description="Device type code") + imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number (digits only)") + device_type: str = Field(..., max_length=10, description="Device type code (e.g. P240, P241)") name: str | None = Field(None, max_length=100, description="Friendly name") timezone: str = Field(default="+8", max_length=30) language: str = Field(default="cn", max_length=10) @@ -55,9 +55,7 @@ class DeviceCreate(DeviceBase): class DeviceUpdate(BaseModel): name: str | None = Field(None, max_length=100) - status: str | None = Field(None, max_length=20) - battery_level: int | None = None - gsm_signal: int | None = None + status: Literal["online", "offline"] | None = Field(None, description="Device status") iccid: str | None = Field(None, max_length=30) imsi: str | None = Field(None, max_length=20) timezone: str | None = Field(None, max_length=30) @@ -95,8 +93,8 @@ class DeviceSingleResponse(APIResponse[DeviceResponse]): class LocationRecordBase(BaseModel): device_id: int location_type: str = Field(..., max_length=10) - latitude: float | None = None - longitude: float | None = None + latitude: float | None = Field(None, ge=-90, le=90) + longitude: float | None = Field(None, ge=-180, le=180) speed: float | None = None course: float | None = None gps_satellites: int | None = None @@ -148,8 +146,8 @@ class AlarmRecordBase(BaseModel): alarm_type: str = Field(..., max_length=30) alarm_source: str | None = Field(None, max_length=10) protocol_number: int - latitude: float | None = None - longitude: float | None = None + latitude: float | None = Field(None, ge=-90, le=90) + longitude: float | None = Field(None, ge=-180, le=180) speed: float | None = None course: float | None = None mcc: int | None = None @@ -231,8 +229,8 @@ class AttendanceRecordBase(BaseModel): attendance_type: str = Field(..., max_length=20) protocol_number: int gps_positioned: bool = False - latitude: float | None = None - longitude: float | None = None + latitude: float | None = Field(None, ge=-90, le=90) + longitude: float | None = Field(None, ge=-180, le=180) speed: float | None = None course: float | None = None gps_satellites: int | None = None @@ -288,8 +286,8 @@ class BluetoothRecordBase(BaseModel): beacon_battery_unit: str | None = None attendance_type: str | None = None bluetooth_data: dict[str, Any] | None = None - latitude: float | None = None - longitude: float | None = None + latitude: float | None = Field(None, ge=-90, le=90) + longitude: float | None = Field(None, ge=-180, le=180) recorded_at: datetime @@ -321,17 +319,17 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]]) class BeaconConfigBase(BaseModel): - beacon_mac: str = Field(..., max_length=20, description="信标MAC地址") - beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID") - beacon_major: int | None = Field(None, description="iBeacon Major") - beacon_minor: int | None = Field(None, description="iBeacon Minor") + beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)") + beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID") + beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major") + beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor") name: str = Field(..., max_length=100, description="信标名称") floor: str | None = Field(None, max_length=20, description="楼层") area: str | None = Field(None, max_length=100, description="区域") - latitude: float | None = Field(None, description="纬度") - longitude: float | None = Field(None, description="经度") + latitude: float | None = Field(None, ge=-90, le=90, description="纬度") + longitude: float | None = Field(None, ge=-180, le=180, description="经度") address: str | None = Field(None, description="详细地址") - status: str = Field(default="active", max_length=20, description="状态") + status: Literal["active", "inactive"] = Field(default="active", description="状态") class BeaconConfigCreate(BeaconConfigBase): @@ -339,22 +337,34 @@ class BeaconConfigCreate(BeaconConfigBase): class BeaconConfigUpdate(BaseModel): - beacon_uuid: str | None = Field(None, max_length=36) - beacon_major: int | None = None - beacon_minor: int | None = None + beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$") + beacon_major: int | None = Field(None, ge=0, le=65535) + beacon_minor: int | None = Field(None, ge=0, le=65535) name: str | None = Field(None, max_length=100) floor: str | None = Field(None, max_length=20) area: str | None = Field(None, max_length=100) - latitude: float | None = None - longitude: float | None = None + latitude: float | None = Field(None, ge=-90, le=90) + longitude: float | None = Field(None, ge=-180, le=180) address: str | None = None - status: str | None = Field(None, max_length=20) + status: Literal["active", "inactive"] | None = None -class BeaconConfigResponse(BeaconConfigBase): +class BeaconConfigResponse(BaseModel): + """Response model — no pattern validation on output (existing data may not conform).""" model_config = ConfigDict(from_attributes=True) id: int + beacon_mac: str + beacon_uuid: str | None = None + beacon_major: int | None = None + beacon_minor: int | None = None + name: str + floor: str | None = None + area: str | None = None + latitude: float | None = None + longitude: float | None = None + address: str | None = None + status: str created_at: datetime updated_at: datetime | None = None @@ -367,10 +377,87 @@ class BeaconConfigResponse(BeaconConfigBase): class CommandCreate(BaseModel): device_id: int command_type: str = Field(..., max_length=30) - command_content: str + command_content: str = Field(..., max_length=500) server_flag: str = Field(..., max_length=20) +# --------------------------------------------------------------------------- +# Batch operation schemas +# --------------------------------------------------------------------------- + + +class BatchDeviceCreateItem(BaseModel): + """Single device in a batch create request.""" + imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number") + device_type: str = Field(default="P241", max_length=10, description="Device type (P240/P241)") + name: str | None = Field(None, max_length=100, description="Friendly name") + + +class BatchDeviceCreateRequest(BaseModel): + """Batch create devices.""" + devices: list[BatchDeviceCreateItem] = Field(..., min_length=1, max_length=500, description="List of devices to create") + + +class BatchDeviceCreateResult(BaseModel): + """Result of a single device in batch create.""" + imei: str + success: bool + device_id: int | None = None + error: str | None = None + + +class BatchDeviceCreateResponse(BaseModel): + """Summary of batch create operation.""" + total: int + created: int + failed: int + results: list[BatchDeviceCreateResult] + + +class BatchCommandRequest(BaseModel): + """Send the same command to multiple devices.""" + device_ids: list[int] | None = Field(default=None, min_length=1, max_length=100, description="Device IDs (provide device_ids or imeis)") + imeis: list[str] | None = Field(default=None, min_length=1, max_length=100, description="IMEI list (alternative to device_ids)") + command_type: str = Field(..., max_length=30, description="Command type (e.g. online_cmd)") + command_content: str = Field(..., max_length=500, description="Command content") + + @model_validator(mode="after") + def check_device_ids_or_imeis(self): + if not self.device_ids and not self.imeis: + raise ValueError("Must provide device_ids or imeis / 必须提供 device_ids 或 imeis") + if self.device_ids and self.imeis: + raise ValueError("Provide device_ids or imeis, not both / 不能同时提供 device_ids 和 imeis") + return self + + +class BatchCommandResult(BaseModel): + """Result of a single command in batch send.""" + device_id: int + imei: str + success: bool + command_id: int | None = None + error: str | None = None + + +class BatchCommandResponse(BaseModel): + """Summary of batch command operation.""" + total: int + sent: int + failed: int + results: list[BatchCommandResult] + + +class BatchDeviceDeleteRequest(BaseModel): + """Batch delete devices.""" + device_ids: list[int] = Field(..., min_length=1, max_length=100, description="Device IDs to delete") + + +class BatchDeviceUpdateRequest(BaseModel): + """Batch update devices with the same settings.""" + device_ids: list[int] = Field(..., min_length=1, max_length=500, description="Device IDs to update") + update: DeviceUpdate = Field(..., description="Fields to update") + + class CommandUpdate(BaseModel): response_content: str | None = None status: str | None = Field(None, max_length=20) diff --git a/app/services/device_service.py b/app/services/device_service.py index ae099e5..e42e98b 100644 --- a/app/services/device_service.py +++ b/app/services/device_service.py @@ -9,7 +9,7 @@ from sqlalchemy import func, select, or_ from sqlalchemy.ext.asyncio import AsyncSession from app.models import Device -from app.schemas import DeviceCreate, DeviceUpdate +from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem async def get_devices( @@ -189,6 +189,103 @@ async def delete_device(db: AsyncSession, device_id: int) -> bool: return True +async def get_devices_by_ids(db: AsyncSession, device_ids: list[int]) -> list[Device]: + """Fetch multiple devices by IDs in a single query.""" + if not device_ids: + return [] + result = await db.execute(select(Device).where(Device.id.in_(device_ids))) + return list(result.scalars().all()) + + +async def get_devices_by_imeis(db: AsyncSession, imeis: list[str]) -> list[Device]: + """Fetch multiple devices by IMEIs in a single query.""" + if not imeis: + return [] + result = await db.execute(select(Device).where(Device.imei.in_(imeis))) + return list(result.scalars().all()) + + +async def batch_create_devices( + db: AsyncSession, items: list[BatchDeviceCreateItem] +) -> list[dict]: + """Batch create devices, skipping duplicates. Uses single query to check existing.""" + # One query to find all existing IMEIs + imeis = [item.imei for item in items] + existing_devices = await get_devices_by_imeis(db, imeis) + existing_imeis = {d.imei for d in existing_devices} + + results: list[dict] = [{} for _ in range(len(items))] # preserve input order + new_device_indices: list[tuple[int, Device]] = [] + seen_imeis: set[str] = set() + for i, item in enumerate(items): + if item.imei in existing_imeis: + results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": f"IMEI {item.imei} already exists"} + continue + if item.imei in seen_imeis: + results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": "Duplicate IMEI in request"} + continue + seen_imeis.add(item.imei) + device = Device(imei=item.imei, device_type=item.device_type, name=item.name) + db.add(device) + new_device_indices.append((i, device)) + + if new_device_indices: + await db.flush() + for idx, device in new_device_indices: + await db.refresh(device) + results[idx] = {"imei": device.imei, "success": True, "device_id": device.id, "error": None} + + return results + + +async def batch_update_devices( + db: AsyncSession, device_ids: list[int], update_data: DeviceUpdate +) -> list[dict]: + """Batch update devices with the same settings. Uses single query to fetch all.""" + devices = await get_devices_by_ids(db, device_ids) + found_map = {d.id: d for d in devices} + update_fields = update_data.model_dump(exclude_unset=True) + now = datetime.now(timezone.utc) + + results = [] + for device_id in device_ids: + device = found_map.get(device_id) + if device is None: + results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"}) + continue + for field, value in update_fields.items(): + setattr(device, field, value) + device.updated_at = now + results.append({"device_id": device_id, "success": True, "error": None}) + + if any(r["success"] for r in results): + await db.flush() + + return results + + +async def batch_delete_devices( + db: AsyncSession, device_ids: list[int] +) -> list[dict]: + """Batch delete devices. Uses single query to fetch all.""" + devices = await get_devices_by_ids(db, device_ids) + found_map = {d.id: d for d in devices} + + results = [] + for device_id in device_ids: + device = found_map.get(device_id) + if device is None: + results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"}) + continue + await db.delete(device) + results.append({"device_id": device_id, "success": True, "error": None}) + + if any(r["success"] for r in results): + await db.flush() + + return results + + async def get_device_stats(db: AsyncSession) -> dict: """ 获取设备统计信息 / Get device statistics. diff --git a/app/services/location_service.py b/app/services/location_service.py index 963d45f..b50ef0c 100644 --- a/app/services/location_service.py +++ b/app/services/location_service.py @@ -106,6 +106,7 @@ async def get_device_track( device_id: int, start_time: datetime, end_time: datetime, + max_points: int = 10000, ) -> list[LocationRecord]: """ 获取设备轨迹 / Get device movement track within a time range. @@ -134,5 +135,6 @@ async def get_device_track( LocationRecord.recorded_at <= end_time, ) .order_by(LocationRecord.recorded_at.asc()) + .limit(max_points) ) return list(result.scalars().all()) diff --git a/app/services/tcp_command_service.py b/app/services/tcp_command_service.py new file mode 100644 index 0000000..17e8f14 --- /dev/null +++ b/app/services/tcp_command_service.py @@ -0,0 +1,31 @@ +""" +TCP Command Service — Abstraction layer for sending commands to devices via TCP. + +Breaks the circular import between routers/commands.py and tcp_server.py +by lazily importing tcp_manager only when needed. +""" + +import logging + +logger = logging.getLogger(__name__) + + +def _get_tcp_manager(): + """Lazily import tcp_manager to avoid circular imports.""" + from app.tcp_server import tcp_manager + return tcp_manager + + +def is_device_online(imei: str) -> bool: + """Check if a device is currently connected via TCP.""" + return imei in _get_tcp_manager().connections + + +async def send_command(imei: str, command_type: str, command_content: str) -> bool: + """Send an online command (0x80) to a connected device.""" + return await _get_tcp_manager().send_command(imei, command_type, command_content) + + +async def send_message(imei: str, message: str) -> bool: + """Send a text message (0x82) to a connected device.""" + return await _get_tcp_manager().send_message(imei, message) diff --git a/app/static/admin.html b/app/static/admin.html index 6a14c8d..7b9c96f 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -26,9 +26,9 @@ .page.active { display: block; } .stat-card { background: #1f2937; border-radius: 12px; padding: 24px; border: 1px solid #374151; transition: transform 0.2s, box-shadow 0.2s; } .stat-card:hover { transform: translateY(-2px); box-shadow: 0 8px 25px rgba(0,0,0,0.3); } - .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 50; display: flex; align-items: center; justify-content: center; } + .modal-backdrop { position: fixed; inset: 0; background: rgba(0,0,0,0.6); z-index: 1000; display: flex; align-items: center; justify-content: center; } .modal-content { background: #1f2937; border-radius: 12px; padding: 24px; max-width: 600px; width: 90%; max-height: 85vh; overflow-y: auto; border: 1px solid #374151; } - .toast-container { position: fixed; top: 20px; right: 20px; z-index: 100; display: flex; flex-direction: column; gap: 8px; } + .toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; } .toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; } .toast.success { background: #059669; } .toast.error { background: #dc2626; } @@ -99,6 +99,42 @@ .guide-tips { margin-top: 10px; padding: 10px 14px; background: rgba(59,130,246,0.06); border-radius: 8px; border-left: 3px solid #3b82f6; } .guide-tips p { font-size: 12px; color: #64748b; line-height: 1.6; } .guide-tips p i { color: #3b82f6; margin-right: 4px; } + /* === Side Panel === */ + .page-with-panel { display: flex; gap: 16px; height: calc(100vh - 140px); } + .side-panel { width: 280px; min-width: 280px; background: #1f2937; border: 1px solid #374151; border-radius: 12px; display: flex; flex-direction: column; overflow: hidden; transition: width 0.3s, min-width 0.3s, opacity 0.3s; } + .side-panel.collapsed { width: 0; min-width: 0; border: none; opacity: 0; } + .page-main-content { flex: 1; min-width: 0; overflow-y: auto; } + .panel-header { padding: 12px 14px; border-bottom: 1px solid #374151; display: flex; align-items: center; gap: 8px; flex-shrink: 0; } + .panel-header .panel-title { font-size: 14px; font-weight: 600; color: #e5e7eb; flex: 1; } + .panel-toggle-btn { background: none; border: none; color: #9ca3af; cursor: pointer; padding: 4px; font-size: 14px; } + .panel-toggle-btn:hover { color: #e5e7eb; } + .panel-search { padding: 8px 12px; border-bottom: 1px solid #374151; flex-shrink: 0; } + .panel-search-wrap { position: relative; } + .panel-search-wrap i { position: absolute; left: 10px; top: 50%; transform: translateY(-50%); color: #6b7280; font-size: 12px; } + .panel-search-wrap input { padding-left: 30px; font-size: 13px; } + .panel-list { flex: 1; overflow-y: auto; padding: 8px; } + .panel-item { padding: 10px 12px; border-radius: 8px; cursor: pointer; border: 1px solid transparent; margin-bottom: 6px; transition: all 0.15s; position: relative; } + .panel-item:hover { background: #374151; border-color: #4b5563; } + .panel-item.active { background: #1e3a5f; border-color: #2563eb; } + .panel-item-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 4px; } + .panel-item-name { font-size: 13px; font-weight: 600; color: #e5e7eb; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; max-width: 170px; } + .panel-item-status { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; } + .panel-item-status.online { background: #34d399; } + .panel-item-status.offline { background: #f87171; } + .panel-item-status.active { background: #34d399; } + .panel-item-status.inactive { background: #f87171; } + .panel-item-sub { font-size: 11px; color: #9ca3af; font-family: monospace; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; } + .panel-item-meta { display: flex; align-items: center; gap: 8px; margin-top: 6px; font-size: 11px; color: #6b7280; } + .battery-bar { width: 28px; height: 10px; background: #374151; border-radius: 2px; border: 1px solid #4b5563; overflow: hidden; display: inline-block; } + .battery-bar-fill { height: 100%; border-radius: 1px; } + .panel-item-actions { position: absolute; right: 8px; top: 8px; display: none; gap: 4px; } + .panel-item:hover .panel-item-actions { display: flex; } + .panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } + .panel-action-btn:hover { background: #2563eb; } + .panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; } + .panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; } + .side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; } + @media (max-width: 768px) { .page-with-panel { flex-direction: column; height: auto; } .side-panel { width: 100%; min-width: 100%; max-height: 300px; } .side-panel.collapsed { max-height: 0; } } @@ -366,50 +402,70 @@

轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点

-
- - - - - - - -
-
-
-
-
- -
- - - - - - - - - - - - - - - - -
设备ID类型纬度经度地址速度卫星数时间
请选择设备并查询
+
+ +
+
+ + 设备列表 + + +
+ +
+

加载设备...

+
+ +
+ +
+ +
+ + + + + + + +
+
+
+
+
+ +
+ + + + + + + + + + + + + + + + +
设备ID类型纬度经度地址速度卫星数时间
请选择设备并查询
+
+ +
-
@@ -660,43 +716,60 @@
- -
- - - - -
- -
- - -
- -
- - - - - - - - - - - - - - - - -
MAC 地址名称UUID / Major / Minor楼层 / 区域坐标状态更新时间操作
加载中...
+
+ +
+
+ + 信标列表 + + +
+ +
+

加载信标...

+
+ +
+ +
+ +
+ + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + +
MAC 地址名称UUID / Major / Minor楼层 / 区域坐标状态更新时间操作
加载中...
+
+ +
-
@@ -827,6 +900,12 @@ let dashAlarmChart = null; let alarmTypeChart = null; + // Side panel state + let panelDevices = []; + let panelBeacons = []; + let selectedPanelDeviceId = null; + let selectedPanelBeaconId = null; + // Pagination state const pageState = { devices: { page: 1, pageSize: 20 }, @@ -1028,6 +1107,9 @@ document.getElementById('pageTitle').textContent = pageTitles[page] || page; if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; } + // Clean up panel state when leaving panel pages + if (page !== 'locations') { selectedPanelDeviceId = null; panelDevices = []; } + if (page !== 'beacons') { selectedPanelBeaconId = null; panelBeacons = []; } switch (page) { case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break; @@ -1041,6 +1123,166 @@ } } + // ==================== SIDE PANEL ==================== + // Panel element ID config (centralized, easy to extend for new panel pages) + const PANEL_IDS = { + locations: { panel: 'locSidePanel', list: 'locPanelList', count: 'locPanelCount', footer: 'locPanelFooter', search: 'locPanelSearch' }, + beacons: { panel: 'beaconSidePanel', list: 'beaconPanelList', count: 'beaconPanelCount', footer: 'beaconPanelFooter', search: 'beaconPanelSearch' }, + }; + const PANEL_TRANSITION_MS = 300; // must match CSS transition duration + + function toggleSidePanel(panelId) { + const panel = document.getElementById(panelId); + if (!panel) return; + panel.classList.toggle('collapsed'); + // Only invalidate map size when on locations page + if (currentPage === 'locations' && locationMap) { + setTimeout(() => locationMap.invalidateSize(), PANEL_TRANSITION_MS + 50); + } + } + + function formatTimeAgo(t) { + if (!t) return '-'; + try { + const diff = Math.floor((Date.now() - new Date(t).getTime()) / 1000); + if (isNaN(diff) || diff < 0) return '-'; + if (diff < 60) return '刚刚'; + if (diff < 3600) return Math.floor(diff / 60) + '分钟前'; + if (diff < 86400) return Math.floor(diff / 3600) + '小时前'; + if (diff < 2592000) return Math.floor(diff / 86400) + '天前'; + return formatTime(t); + } catch { return '-'; } + } + + /** Generic panel render helper — sets count & footer text, returns container or null */ + function _initPanelRender(ids, items, statusField, statusValue, emptyText, countFmt, footerFmt) { + const container = document.getElementById(ids.list); + const countEl = document.getElementById(ids.count); + const footerEl = document.getElementById(ids.footer); + if (!container) return null; + const activeCount = items.filter(it => it[statusField] === statusValue).length; + if (countEl) countEl.textContent = countFmt(activeCount, items.length); + if (footerEl) footerEl.textContent = footerFmt(activeCount, items.length); + if (items.length === 0) { + container.innerHTML = `
${emptyText}
`; + return null; + } + return container; + } + + function renderDevicePanel(devices) { + panelDevices = devices; + const container = _initPanelRender( + PANEL_IDS.locations, devices, 'status', 'online', '暂无设备', + (a, t) => `${a}/${t}`, (a, t) => `共 ${t} 台设备,${a} 台在线` + ); + if (!container) return; + container.innerHTML = devices.map(d => { + const isActive = (d.id || d.device_id) == selectedPanelDeviceId; + const statusClass = d.status === 'online' ? 'online' : 'offline'; + const deviceId = d.id || d.device_id || ''; + const imeiShort = d.imei ? '...' + d.imei.slice(-8) : '-'; + const bp = d.battery_level; + const bColor = bp != null ? (bp < 20 ? '#f87171' : bp < 50 ? '#fbbf24' : '#34d399') : '#4b5563'; + const lastActive = d.last_heartbeat || d.last_login; + const timeAgo = lastActive ? formatTimeAgo(lastActive) : '无活动'; + return `
+
+ + +
+
+ ${escapeHtml(d.name || d.imei || deviceId)} +
+
+
${escapeHtml(imeiShort)}
+
+ ${bp != null ? `${bp}%` : ''} + ${d.gsm_signal != null ? ` ${d.gsm_signal}` : ''} + ${timeAgo} +
+
`; + }).join(''); + } + + function renderBeaconPanel(beacons) { + panelBeacons = beacons; + const container = _initPanelRender( + PANEL_IDS.beacons, beacons, 'status', 'active', '暂无信标', + (a, t) => `${a}/${t}`, (a, t) => `共 ${t} 个信标,${a} 个启用` + ); + if (!container) return; + container.innerHTML = beacons.map(b => { + const isActive = b.id == selectedPanelBeaconId; + const statusClass = b.status === 'active' ? 'active' : 'inactive'; + const floorArea = [b.floor, b.area].filter(Boolean).join(' / ') || '未设置'; + const macShort = b.beacon_mac ? b.beacon_mac.slice(-8) : '-'; + return `
+
+ +
+
+ ${escapeHtml(b.name || b.beacon_mac)} +
+
+
${escapeHtml(macShort)}
+
+ ${escapeHtml(floorArea)} + ${b.latitude && b.longitude ? ' 已定位' : '未定位'} +
+
`; + }).join(''); + } + + function filterPanelItems(type) { + const ids = PANEL_IDS[type]; + if (!ids) return; + const inputEl = document.getElementById(ids.search); + const listEl = document.getElementById(ids.list); + if (!inputEl || !listEl) return; + const keyword = (inputEl.value || '').toLowerCase().trim(); + listEl.querySelectorAll('.panel-item').forEach(item => { + item.style.display = (!keyword || (item.dataset.searchText || '').includes(keyword)) ? '' : 'none'; + }); + } + + function selectPanelDevice(deviceId, autoLocate = true) { + selectedPanelDeviceId = deviceId; + document.querySelectorAll('#locPanelList .panel-item').forEach(el => { + el.classList.toggle('active', el.dataset.deviceId == deviceId); + }); + const select = document.getElementById('locDeviceSelect'); + if (select) select.value = deviceId; + const activeCard = document.querySelector(`#locPanelList .panel-item[data-device-id="${deviceId}"]`); + if (activeCard) activeCard.scrollIntoView({ behavior: 'smooth', block: 'nearest' }); + if (autoLocate && deviceId) loadLatestPosition(); + } + + function selectPanelBeacon(beaconId) { + selectedPanelBeaconId = beaconId; + document.querySelectorAll('#beaconPanelList .panel-item').forEach(el => { + el.classList.toggle('active', el.dataset.beaconId == beaconId); + }); + } + + function sortDevicesByActivity(devices) { + return [...devices].sort((a, b) => { + if (a.status === 'online' && b.status !== 'online') return -1; + if (a.status !== 'online' && b.status === 'online') return 1; + const tA = new Date(a.last_heartbeat || a.last_login || 0).getTime(); + const tB = new Date(b.last_heartbeat || b.last_login || 0).getTime(); + return tB - tA; + }); + } + + function autoSelectActiveDevice(devices) { + if (!devices || devices.length === 0) return; + const sorted = sortDevicesByActivity(devices); + const best = sorted[0]; + const bestId = best.id || best.device_id; + if (bestId) selectPanelDevice(bestId, true); + } + // ==================== DEVICE SELECTOR HELPER ==================== let cachedDevices = null; @@ -1064,6 +1306,12 @@ }); if (currentVal) sel.value = currentVal; }); + // Render device panel on locations page + if (currentPage === 'locations' && document.getElementById('locPanelList')) { + const sorted = sortDevicesByActivity(devices); + renderDevicePanel(sorted); + if (!selectedPanelDeviceId) autoSelectActiveDevice(sorted); + } } catch (err) { console.error('Failed to load device selectors:', err); } @@ -1264,31 +1512,159 @@ } } + function _locTypeLabel(t) { + const map = { gps: 'GPS', gps_4g: 'GPS 4G', lbs: 'LBS 基站', lbs_4g: 'LBS 4G', wifi: 'WiFi', wifi_4g: 'WiFi 4G', bluetooth: '蓝牙' }; + return map[t] || t || '-'; + } + function _locModeBadges(locType) { + const modes = [ + { label: 'GPS', match: ['gps','gps_4g'] }, + { label: 'LBS', match: ['lbs','lbs_4g'] }, + { label: 'WiFi', match: ['wifi','wifi_4g'] }, + { label: '蓝牙', match: ['bluetooth'] }, + ]; + return modes.map(m => { + const active = m.match.includes(locType); + const color = active ? '#10b981' : '#4b5563'; + const bg = active ? 'rgba(16,185,129,0.15)' : 'rgba(75,85,99,0.2)'; + return `${active?'●':'○'} ${m.label}`; + }).join(''); + } + + // --- Quick command sender for device detail panel --- + async function _quickCmd(deviceId, cmd, btnEl) { + if (btnEl) { btnEl.disabled = true; btnEl.style.opacity = '0.5'; } + try { + const res = await apiCall(`${API_BASE}/commands/send`, { + method: 'POST', + body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }), + }); + showToast(`已发送: ${cmd}`); + // Poll for response (device replies via 0x81) + const cmdId = res && res.id; + if (cmdId) _pollCmdResponse(cmdId, cmd); + } catch (err) { + showToast(`发送失败: ${err.message}`, 'error'); + } finally { + if (btnEl) { btnEl.disabled = false; btnEl.style.opacity = '1'; } + } + } + async function _pollCmdResponse(cmdId, cmdName) { + for (let i = 0; i < 6; i++) { + await new Promise(r => setTimeout(r, 1500)); + try { + const cmd = await apiCall(`${API_BASE}/commands/${cmdId}`); + if (cmd.response_content) { + const el = document.getElementById('detailCmdResult'); + if (el) el.innerHTML = `${escapeHtml(cmdName)}: ${escapeHtml(cmd.response_content)}`; + return; + } + } catch (_) {} + } + } + async function _quickTts(deviceId) { + const input = document.getElementById('detailTtsInput'); + if (!input || !input.value.trim()) { showToast('请输入语音内容', 'error'); return; } + try { + await apiCall(`${API_BASE}/commands/tts`, { + method: 'POST', + body: JSON.stringify({ device_id: parseInt(deviceId), text: input.value.trim() }), + }); + showToast('TTS 已发送'); + input.value = ''; + } catch (err) { + showToast(`TTS失败: ${err.message}`, 'error'); + } + } + async function showDeviceDetail(id) { if (!id) return; try { - const device = await apiCall(`${API_BASE}/devices/${id}`); + const [device, latestLoc] = await Promise.all([ + apiCall(`${API_BASE}/devices/${id}`), + apiCall(`${API_BASE}/locations/latest/${id}`).catch(() => null), + ]); const d = device; + const did = d.id || d.device_id; + const loc = latestLoc; + const locType = loc ? loc.location_type : null; + const locTime = loc ? formatTime(loc.recorded_at) : '-'; + const locAddr = loc && loc.address ? escapeHtml(loc.address) : '-'; + const locCoord = loc && loc.latitude ? `${loc.latitude.toFixed(6)}, ${loc.longitude.toFixed(6)}` : '-'; + const online = d.status === 'online'; + const disabledAttr = online ? '' : 'disabled style="opacity:0.4;cursor:not-allowed"'; + const _btn = (icon, label, cmd, color) => + ``; + showModal(` -

设备详情

-
-
ID:
${escapeHtml(d.id || d.device_id)}
+

设备详情 — ${escapeHtml(d.name || d.imei)}

+ +
IMEI:
${escapeHtml(d.imei)}
-
名称:
${escapeHtml(d.name || '-')}
-
类型:
${escapeHtml(d.device_type || '-')}
+
型号:
${escapeHtml(d.device_type || '-')}
状态:
${statusBadge(d.status)}
-
电量:
${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}
-
信号强度:
${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}
-
固件版本:
${escapeHtml(d.firmware_version || '-')}
-
时区:
${escapeHtml(d.timezone || '-')}
-
语言:
${escapeHtml(d.language || '-')}
+
电量:
${d.battery_level != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? ' |  信号: ' + d.gsm_signal : ''}
+
ICCID:
${escapeHtml(d.iccid || '-')}
+
时区/语言:
${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}
最后登录:
${formatTime(d.last_login)}
最后心跳:
${formatTime(d.last_heartbeat)}
-
创建时间:
${formatTime(d.created_at)}
-
- - + + +
+
定位信息
+
+
当前模式:
${_locTypeLabel(locType)}
+
定位时间:
${locTime}
+
地址:
${locAddr}
+
+
${_locModeBadges(locType)}
+
+ + +
+
功能开关${online ? '' : ' (设备离线,无法操作)'}
+
+ ${_btn('satellite-dish', 'GPS 开启', 'GPSON#', '#0d9488')} + ${_btn('bluetooth-b', '蓝牙开启', 'BTON#', '#2563eb')} + ${_btn('broadcast-tower', 'BLE扫描', 'BTSCAN,1#', '#7c3aed')} +
+
工作模式:
+
+ ${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')} + ${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')} +
+
信息查询:
+
+ ${_btn('info-circle', '设备状态', 'STATUS#', '#374151')} + ${_btn('cog', '参数查询', 'PARAM#', '#374151')} + ${_btn('code-branch', '固件版本', 'VERSION#', '#374151')} + ${_btn('stopwatch', '定时器', 'TIMER#', '#374151')} + ${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')} +
+
+
+ + +
+
语音播报 (TTS)
+
+ + +
+
+ + +
+
系统操作
+
+ +
+
+ +
+ +
`); @@ -1364,15 +1740,69 @@ } } + // ==================== MAP PROVIDER ==================== + // Switch tile provider: 'gaode' | 'tianditu' + // 高德: GCJ-02 tiles, need coordinate conversion; 天地图: WGS-84 native + const MAP_PROVIDER = 'gaode'; + + // WGS-84 → GCJ-02 coordinate conversion (for 高德 tiles) + const _gcj_a = 6378245.0, _gcj_ee = 0.00669342162296594; + function _outOfChina(lat, lng) { return lng < 72.004 || lng > 137.8347 || lat < 0.8293 || lat > 55.8271; } + function _transformLat(x, y) { + let r = -100.0 + 2.0*x + 3.0*y + 0.2*y*y + 0.1*x*y + 0.2*Math.sqrt(Math.abs(x)); + r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0; + r += (20.0*Math.sin(y*Math.PI) + 40.0*Math.sin(y/3.0*Math.PI)) * 2.0/3.0; + r += (160.0*Math.sin(y/12.0*Math.PI) + 320.0*Math.sin(y*Math.PI/30.0)) * 2.0/3.0; + return r; + } + function _transformLng(x, y) { + let r = 300.0 + x + 2.0*y + 0.1*x*x + 0.1*x*y + 0.1*Math.sqrt(Math.abs(x)); + r += (20.0*Math.sin(6.0*x*Math.PI) + 20.0*Math.sin(2.0*x*Math.PI)) * 2.0/3.0; + r += (20.0*Math.sin(x*Math.PI) + 40.0*Math.sin(x/3.0*Math.PI)) * 2.0/3.0; + r += (150.0*Math.sin(x/12.0*Math.PI) + 300.0*Math.sin(x/30.0*Math.PI)) * 2.0/3.0; + return r; + } + function wgs84ToGcj02(lat, lng) { + if (_outOfChina(lat, lng)) return [lat, lng]; + let dLat = _transformLat(lng - 105.0, lat - 35.0); + let dLng = _transformLng(lng - 105.0, lat - 35.0); + const radLat = lat / 180.0 * Math.PI; + let magic = Math.sin(radLat); + magic = 1 - _gcj_ee * magic * magic; + const sqrtMagic = Math.sqrt(magic); + dLat = (dLat * 180.0) / ((_gcj_a * (1 - _gcj_ee)) / (magic * sqrtMagic) * Math.PI); + dLng = (dLng * 180.0) / (_gcj_a / sqrtMagic * Math.cos(radLat) * Math.PI); + return [lat + dLat, lng + dLng]; + } + + // Convert WGS-84 coords for current map provider + function toMapCoord(lat, lng) { + if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng); + return [lat, lng]; // tianditu uses WGS-84 natively + } + // ==================== LOCATIONS ==================== function initLocationMap() { if (locationMap) return; setTimeout(() => { - locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10); - L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { - attribution: '© OpenStreetMap contributors', - maxZoom: 19, - }).addTo(locationMap); + locationMap = L.map('locationMap').setView(toMapCoord(39.9042, 116.4074), 10); + if (MAP_PROVIDER === 'gaode') { + // 高德矢量底图 (GCJ-02, standard Mercator, no API key needed) + L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', { + subdomains: '1234', maxZoom: 18, + attribution: '© 高德地图', + }).addTo(locationMap); + } else { + // 天地图矢量底图 + 中文注记 (WGS-84) + const TDT_KEY = '1918548e81a5ae3ff0cb985537341146'; + L.tileLayer('https://t{s}.tianditu.gov.cn/vec_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=vec&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, { + subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18, + }).addTo(locationMap); + L.tileLayer('https://t{s}.tianditu.gov.cn/cva_w/wmts?SERVICE=WMTS&REQUEST=GetTile&VERSION=1.0.0&LAYER=cva&STYLE=default&TILEMATRIXSET=w&FORMAT=tiles&TILECOL={x}&TILEROW={y}&TILEMATRIX={z}&tk=' + TDT_KEY, { + subdomains: ['0','1','2','3','4','5','6','7'], maxZoom: 18, + attribution: '© 天地图', + }).addTo(locationMap); + } locationMap.invalidateSize(); }, 100); } @@ -1420,10 +1850,11 @@ const lat = loc.latitude || loc.lat; const lng = loc.longitude || loc.lng || loc.lon; if (lat && lng) { - latlngs.push([lat, lng]); + const [mLat, mLng] = toMapCoord(lat, lng); + latlngs.push([mLat, mLng]); const isFirst = i === 0; const isLast = i === locations.length - 1; - const marker = L.circleMarker([lat, lng], { + const marker = L.circleMarker([mLat, mLng], { radius: isFirst || isLast ? 8 : 4, fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6', color: '#fff', weight: 1, fillOpacity: 0.9, @@ -1466,7 +1897,8 @@ const lng = loc.longitude || loc.lng || loc.lon; if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; } - const marker = L.marker([lat, lng]).addTo(locationMap); + const [mLat, mLng] = toMapCoord(lat, lng); + const marker = L.marker([mLat, mLng]).addTo(locationMap); marker.bindPopup(` 最新位置
类型: ${loc.location_type || '-'}
@@ -1476,7 +1908,7 @@ 时间: ${formatTime(loc.recorded_at || loc.created_at)} `).openPopup(); mapMarkers.push(marker); - locationMap.setView([lat, lng], 15); + locationMap.setView([mLat, mLng], 15); showToast('已显示最新位置'); } catch (err) { showToast('获取最新位置失败: ' + err.message, 'error'); @@ -1783,6 +2215,8 @@ }).join(''); } renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons'); + // Render beacon side panel + renderBeaconPanel(items); } catch (err) { showToast('加载信标列表失败: ' + err.message, 'error'); document.getElementById('beaconsTableBody').innerHTML = '加载失败'; diff --git a/app/tcp_server.py b/app/tcp_server.py index fb6107f..4c25141 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -60,6 +60,7 @@ from app.protocol.constants import ( PROTO_ONLINE_CMD_REPLY, PROTO_TIME_SYNC, PROTO_TIME_SYNC_2, + PROTO_ADDRESS_REPLY_EN, PROTO_WIFI, PROTO_WIFI_4G, START_MARKER_LONG, @@ -718,6 +719,11 @@ class TCPManager: logger.warning("Heartbeat received before login") return + # Ensure device is tracked in active connections (e.g. after server restart) + if imei not in self.connections: + self.connections[imei] = (reader, writer, conn_info) + logger.info("Device IMEI=%s re-registered via heartbeat", imei) + terminal_info: int = 0 battery_level: Optional[int] = None gsm_signal: Optional[int] = None @@ -752,11 +758,12 @@ class TCPManager: logger.warning("Heartbeat for unknown IMEI=%s", imei) return - # Update device record + # Update device record (also ensure status=online if heartbeat is coming in) await session.execute( update(Device) .where(Device.id == device_id) .values( + status="online", battery_level=battery_level, gsm_signal=gsm_signal, last_heartbeat=now, diff --git a/requirements.txt b/requirements.txt index f309d3d..b664a68 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,3 +4,4 @@ sqlalchemy>=2.0.0 aiosqlite>=0.19.0 pydantic>=2.0.0 pydantic-settings>=2.0.0 +slowapi