Add batch management APIs, API security, rate limiting, and optimizations

- Batch device CRUD: POST /api/devices/batch (create 500), PUT /api/devices/batch (update 500),
  POST /api/devices/batch-delete (delete 100) with WHERE IN bulk queries
- Batch command: POST /api/commands/batch with model_validator mutual exclusion
- API key auth (X-API-Key header, secrets.compare_digest timing-safe)
- Rate limiting via SlowAPIMiddleware (60/min default, 30/min writes)
- Real client IP extraction (X-Forwarded-For / CF-Connecting-IP)
- Global exception handler (no stack trace leaks, passes HTTPException through)
- CORS with auto-disable credentials on wildcard origins
- Schema validation: IMEI pattern, lat/lon ranges, Literal enums, MAC/UUID patterns
- Heartbeats router, per-ID endpoints for locations/attendance/bluetooth
- Input dedup in batch create, result ordering preserved
- Baidu reverse geocoding, Gaode map tiles with WGS84→GCJ02 conversion
- Device detail panel with feature toggles and command controls
- Side panel for location/beacon pages with auto-select active device

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-03-20 09:18:43 +00:00
parent 1bdbe4fa19
commit 7d6040af41
23 changed files with 1564 additions and 294 deletions

43
.env.example Normal file
View File

@@ -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

1
.gitignore vendored
View File

@@ -3,4 +3,5 @@ __pycache__/
*.db *.db
*.log *.log
nohup.out nohup.out
.env
.claude/ .claude/

View File

@@ -10,6 +10,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
``` ```
/home/gpsystem/ /home/gpsystem/
├── run.py # 启动脚本 (uvicorn) ├── run.py # 启动脚本 (uvicorn)
├── .env.example # 环境变量模板 (复制为 .env 使用)
├── requirements.txt # Python 依赖 ├── requirements.txt # Python 依赖
├── frpc.toml # FRP 客户端配置 (TCP隧道) ├── frpc.toml # FRP 客户端配置 (TCP隧道)
├── badge_admin.db # SQLite 数据库 ├── badge_admin.db # SQLite 数据库
@@ -17,8 +18,10 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
├── app/ ├── app/
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器 │ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
│ ├── config.py # 配置 (端口8088 HTTP, 5000 TCP) │ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
│ ├── database.py # SQLAlchemy async 数据库连接 │ ├── 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) │ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog)
│ ├── schemas.py # Pydantic 请求/响应模型 │ ├── schemas.py # Pydantic 请求/响应模型
│ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化 │ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化
@@ -34,15 +37,17 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
│ │ ├── device_service.py # 设备 CRUD │ │ ├── device_service.py # 设备 CRUD
│ │ ├── command_service.py # 指令日志 CRUD │ │ ├── command_service.py # 指令日志 CRUD
│ │ ├── location_service.py # 位置记录查询 │ │ ├── location_service.py # 位置记录查询
│ │ ── beacon_service.py # 蓝牙信标 CRUD │ │ ── beacon_service.py # 蓝牙信标 CRUD
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
│ │ │ │
│ ├── routers/ │ ├── routers/
│ │ ├── devices.py # /api/devices (含 /stats 统计接口) │ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete)
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts) │ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
│ │ ├── locations.py # /api/locations (含 /latest, /track) │ │ ├── locations.py # /api/locations (含 /latest, /track, /{id})
│ │ ├── alarms.py # /api/alarms (含 acknowledge) │ │ ├── alarms.py # /api/alarms (含 acknowledge)
│ │ ├── attendance.py # /api/attendance │ │ ├── attendance.py # /api/attendance (含 /stats, /{id})
│ │ ├── bluetooth.py # /api/bluetooth │ │ ├── bluetooth.py # /api/bluetooth (含 /{id})
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
│ │ └── beacons.py # /api/beacons (信标管理 CRUD) │ │ └── beacons.py # /api/beacons (信标管理 CRUD)
│ │ │ │
│ └── static/ │ └── static/
@@ -75,11 +80,18 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
- **7000**: FRP 服务端管理端口 - **7000**: FRP 服务端管理端口
- **7500**: FRP Dashboard (admin/PassWord0325) - **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 ```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 & nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
# 启动 FRP 客户端 (TCP隧道) # 启动 FRP 客户端 (TCP隧道)
@@ -192,6 +204,19 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
- **配额**: 免费 10,000 次/天 - **配额**: 免费 10,000 次/天
- **注意**: postStr 参数需使用双引号JSON并URL编码 - **注意**: 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 分页 ### API 分页
- page_size 最大限制为 100 (schema 层校验) - page_size 最大限制为 100 (schema 层校验)
- 前端设备选择器使用 page_size=100 (不能超过限制) - 前端设备选择器使用 page_size=100 (不能超过限制)
@@ -209,6 +234,7 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式 - TTS: 通过 0x80 发送 `TTS,<文本>` 格式
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描 - 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
- 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#` - 已验证可用指令: `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) 管理 - **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理
- **重要**: 服务器启动时自动将所有设备标记为 offline等待设备重新登录 - **重要**: 服务器启动时自动将所有设备标记为 offline等待设备重新登录
@@ -219,7 +245,9 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置 - **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描 - **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(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 智能电子工牌系列 - **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn)P240/P241 智能电子工牌系列
### 0x94 General Info 子协议 ### 0x94 General Info 子协议
@@ -289,6 +317,18 @@ remotePort = 5001
3. 注意返回坐标为 GCJ-02需转换为 WGS-84 用于 Leaflet 地图 3. 注意返回坐标为 GCJ-02需转换为 WGS-84 用于 Leaflet 地图
4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数 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 连接配置设备 1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
@@ -296,7 +336,7 @@ remotePort = 5001
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL 3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询 4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km待接入高德智能硬件定位后可达 ~30m 5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km待接入高德智能硬件定位后可达 ~30m
6. **天地图逆地理编码使用 HTTP** - API不支持HTTPSKey在URL中明文传输 (低风险: 免费Key) 6. **天地图逆地理编码使用 HTTP** - API不支持HTTPSKey在URL中明文传输 (低风险: 免费Key, 已降级为备选)
## 已修复的问题 (Bug Fix 记录) ## 已修复的问题 (Bug Fix 记录)
@@ -361,6 +401,36 @@ remotePort = 5001
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项 42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档 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 ### 0x94 子协议 0x04
- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286` - 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286`
- 在设备重连/重启后上报 - 在设备重连/重启后上报
@@ -368,9 +438,9 @@ remotePort = 5001
## 待完成功能 ## 待完成功能
1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov大幅提升 WiFi/基站定位精度 1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov大幅提升 WiFi/基站定位精度
2. **地图瓦片底图** - 使用浏览器端 Key 替换 OpenStreetMap 瓦片 (中国地区显示更准确) 2. ~~**地图瓦片**~~ - ✅ 已切换为高德瓦片 (GCJ-02),支持 MAP_PROVIDER 切换 ('gaode'|'tianditu')
3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
4. **蓝牙信标调试** - P241 接受 BTON/BTSCAN 但未上报BLE数据需确认信标iBeacon广播格式及UUID匹配 4. ~~**蓝牙信标调试**~~ - ✅ 已完成 (2026-03-18)0xB2打卡数据正常上报信标匹配成功
## 调试技巧 ## 调试技巧

View File

@@ -1,14 +1,49 @@
from pathlib import Path
from typing import Literal
from pydantic import Field
from pydantic_settings import BaseSettings 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): class Settings(BaseSettings):
APP_NAME: str = "KKS Badge Management System" 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_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_HOST: str = "0.0.0.0"
API_PORT: int = 8088 API_PORT: int = Field(default=8088, ge=1, le=65535)
DEBUG: bool = True 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() settings = Settings()

20
app/dependencies.py Normal file
View File

@@ -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")

22
app/extensions.py Normal file
View File

@@ -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])

View File

@@ -10,7 +10,6 @@ Uses free APIs:
import json import json
import logging import logging
import os
from collections import OrderedDict from collections import OrderedDict
from typing import Optional from typing import Optional
from urllib.parse import quote from urllib.parse import quote
@@ -19,19 +18,16 @@ import aiohttp
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Google Geolocation API key (set to enable Google geocoding) # Import keys from centralized config (no more hardcoded values here)
GOOGLE_API_KEY: Optional[str] = None from app.config import settings as _settings
# Unwired Labs API token (free tier: 100 requests/day) GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY
# Sign up at https://unwiredlabs.com/ UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN
UNWIRED_API_TOKEN: Optional[str] = None 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) # Maximum cache entries (LRU eviction) — configurable via settings
# Sign up at https://lbs.tianditu.gov.cn/ _CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
TIANDITU_API_KEY: Optional[str] = os.environ.get("TIANDITU_API_KEY", "439fca3920a6f31584014424f89c3ecc")
# Maximum cache entries (LRU eviction)
_CACHE_MAX_SIZE = 10000
class LRUCache(OrderedDict): class LRUCache(OrderedDict):
@@ -315,7 +311,8 @@ async def reverse_geocode(
""" """
Convert lat/lon to a human-readable address. 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. Returns None if no reverse geocoding service is available.
""" """
# Round to ~100m for cache key to reduce API calls # Round to ~100m for cache key to reduce API calls
@@ -324,6 +321,14 @@ async def reverse_geocode(
if cached is not None: if cached is not None:
return cached 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: if TIANDITU_API_KEY:
result = await _reverse_geocode_tianditu(lat, lon) result = await _reverse_geocode_tianditu(lat, lon)
if result: if result:
@@ -333,6 +338,55 @@ async def reverse_geocode(
return None 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( async def _reverse_geocode_tianditu(
lat: float, lon: float lat: float, lon: float
) -> Optional[str]: ) -> Optional[str]:

View File

@@ -1,19 +1,31 @@
from pathlib import Path from pathlib import Path
from fastapi import FastAPI from fastapi import FastAPI, Request
from fastapi.responses import HTMLResponse from fastapi.middleware.cors import CORSMiddleware
from fastapi.responses import HTMLResponse, JSONResponse
from fastapi.staticfiles import StaticFiles from fastapi.staticfiles import StaticFiles
from contextlib import asynccontextmanager 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.tcp_server import tcp_manager
from app.config import settings 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 asyncio
import logging import logging
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s') logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
logger = logging.getLogger(__name__) logger = logging.getLogger(__name__)
# Rate limiter (shared instance via app.extensions for router access)
from app.extensions import limiter
@asynccontextmanager @asynccontextmanager
async def lifespan(app: FastAPI): async def lifespan(app: FastAPI):
# Startup # Startup
@@ -40,7 +52,7 @@ async def lifespan(app: FastAPI):
app = FastAPI( app = FastAPI(
title="KKS Badge Management System / KKS工牌管理系统", title="KKS Badge Management System / KKS工牌管理系统",
description=""" description="""
## KKS P240 & P241 蓝牙工牌管理后台 ## KKS P240 & P241 蓝牙工牌管理后台 API
### 功能模块 / Features: ### 功能模块 / Features:
- **设备管理 / Device Management** - 设备注册、状态监控 - **设备管理 / Device Management** - 设备注册、状态监控
@@ -50,6 +62,10 @@ app = FastAPI(
- **指令管理 / Commands** - 远程指令下发与留言 - **指令管理 / Commands** - 远程指令下发与留言
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据 - **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置 - **信标管理 / Beacons** - 蓝牙信标注册与位置配置
- **心跳数据 / Heartbeats** - 设备心跳记录查询
### 认证 / Authentication:
设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。
### 通讯协议 / Protocol: ### 通讯协议 / Protocol:
- TCP端口: {tcp_port} (设备连接) - TCP端口: {tcp_port} (设备连接)
@@ -61,14 +77,48 @@ app = FastAPI(
lifespan=lifespan, lifespan=lifespan,
) )
# Include routers # Rate limiter — SlowAPIMiddleware applies default_limits to all routes
app.include_router(devices.router) app.state.limiter = limiter
app.include_router(locations.router) app.add_middleware(SlowAPIMiddleware)
app.include_router(alarms.router) app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
app.include_router(attendance.router)
app.include_router(commands.router) # CORS
app.include_router(bluetooth.router) _origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
app.include_router(beacons.router) 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" _STATIC_DIR = Path(__file__).parent / "static"
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
@@ -90,11 +140,25 @@ async def root():
"redoc": "/redoc", "redoc": "/redoc",
"admin": "/admin", "admin": "/admin",
"tcp_port": settings.TCP_PORT, "tcp_port": settings.TCP_PORT,
"auth_enabled": settings.API_KEY is not None,
} }
@app.get("/health", tags=["Root"]) @app.get("/health", tags=["Root"])
async def health(): 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 { return {
"status": "healthy", "status": status,
"database": "ok" if db_ok else "error",
"connected_devices": len(tcp_manager.connections), "connected_devices": len(tcp_manager.connections),
} }

View File

@@ -136,6 +136,57 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec # 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 # Protocol Number -> Human-Readable Name
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View File

@@ -185,3 +185,20 @@ async def device_attendance(
total_pages=math.ceil(total / page_size) if total else 0, 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))

View File

@@ -70,7 +70,6 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get
if existing: if existing:
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists") raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
beacon = await beacon_service.create_beacon(db, body) beacon = await beacon_service.create_beacon(db, body)
await db.commit()
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon)) 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) beacon = await beacon_service.update_beacon(db, beacon_id, body)
if beacon is None: if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found") raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon)) 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) success = await beacon_service.delete_beacon(db, beacon_id)
if not success: if not success:
raise HTTPException(status_code=404, detail="Beacon not found") raise HTTPException(status_code=404, detail="Beacon not found")
await db.commit()
return APIResponse(message="Beacon deleted") return APIResponse(message="Beacon deleted")

View File

@@ -88,15 +88,14 @@ async def device_bluetooth_records(
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"), record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time"), end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
page: int = Query(default=1, ge=1, description="页码 / Page number"), page: int = Query(default=1, ge=1, description="页码"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
db: AsyncSession = Depends(get_db), db: AsyncSession = Depends(get_db),
): ):
""" """
获取指定设备的蓝牙数据记录。 获取指定设备的蓝牙数据记录。
Get Bluetooth records for a specific device. Get Bluetooth records for a specific device.
""" """
# Verify device exists
device = await device_service.get_device(db, device_id) device = await device_service.get_device(db, device_id)
if device is None: if device is None:
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}") 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, 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))

View File

@@ -3,19 +3,26 @@ Commands Router - 指令管理接口
API endpoints for sending commands / messages to devices and viewing command history. API endpoints for sending commands / messages to devices and viewing command history.
""" """
import logging
import math import math
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from pydantic import BaseModel, Field from pydantic import BaseModel, Field
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.config import settings
from app.extensions import limiter
from app.schemas import ( from app.schemas import (
APIResponse, APIResponse,
BatchCommandRequest,
BatchCommandResponse,
BatchCommandResult,
CommandResponse, CommandResponse,
PaginatedList, PaginatedList,
) )
from app.services import command_service, device_service from app.services import command_service, device_service
from app.services import tcp_command_service
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"]) router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
@@ -29,8 +36,8 @@ class SendCommandRequest(BaseModel):
"""Request body for sending a command to a device.""" """Request body for sending a command to a device."""
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)") 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)") 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_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
command_content: str = Field(..., description="指令内容 / Command content") command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
class SendMessageRequest(BaseModel): class SendMessageRequest(BaseModel):
@@ -48,7 +55,7 @@ class SendTTSRequest(BaseModel):
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
# Helper # Helpers
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -78,6 +85,57 @@ async def _resolve_device(
return 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 # Endpoints
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------
@@ -126,46 +184,15 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_
Requires the device to be online. Requires the device to be online.
""" """
device = await _resolve_device(db, body.device_id, body.imei) device = await _resolve_device(db, body.device_id, body.imei)
return await _send_to_device(
# Import tcp_manager lazily to avoid circular imports db, device,
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,
command_type=body.command_type, command_type=body.command_type,
command_content=body.command_content, command_content=body.command_content,
) executor=lambda: tcp_command_service.send_command(
# Send command via TCP
try:
await tcp_manager.send_command(
device.imei, body.command_type, body.command_content device.imei, body.command_type, body.command_content
) ),
except Exception as e: success_msg="Command sent successfully / 指令发送成功",
command_log.status = "failed" fail_msg="Failed to send command / 指令发送失败",
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),
) )
@@ -181,41 +208,13 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_
Send a text message to a device using protocol 0x82. Send a text message to a device using protocol 0x82.
""" """
device = await _resolve_device(db, body.device_id, body.imei) device = await _resolve_device(db, body.device_id, body.imei)
return await _send_to_device(
from app.tcp_server import tcp_manager db, device,
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,
command_type="message", command_type="message",
command_content=body.message, command_content=body.message,
) executor=lambda: tcp_command_service.send_message(device.imei, body.message),
success_msg="Message sent successfully / 留言发送成功",
try: fail_msg="Failed to send message / 留言发送失败",
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),
) )
@@ -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. The device will use its built-in TTS engine to speak the text aloud.
""" """
device = await _resolve_device(db, body.device_id, body.imei) 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}" tts_command = f"TTS,{body.text}"
return await _send_to_device(
# Create command log entry db, device,
command_log = await command_service.create_command(
db,
device_id=device.id,
command_type="tts", command_type="tts",
command_content=tts_command, 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 / 语音下发失败",
) )
@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: try:
await tcp_manager.send_command(device.imei, "tts", tts_command) cmd_log = await command_service.create_command(
except Exception as e: db,
command_log.status = "failed" device_id=device.id,
await db.flush() command_type=body.command_type,
await db.refresh(command_log) command_content=body.command_content,
raise HTTPException(
status_code=500,
detail=f"Failed to send TTS / 语音下发失败: {str(e)}",
) )
await tcp_command_service.send_command(
command_log.status = "sent" device.imei, body.command_type, body.command_content
)
cmd_log.status = "sent"
await db.flush() await db.flush()
await db.refresh(command_log) 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( return APIResponse(
message="TTS sent successfully / 语音下发成功", message=f"Batch command: {sent} sent, {failed} failed",
data=CommandResponse.model_validate(command_log), data=BatchCommandResponse(
total=len(results), sent=sent, failed=failed, results=results,
),
) )

View File

@@ -5,17 +5,24 @@ API endpoints for device CRUD operations and statistics.
import math import math
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query, Request
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.schemas import ( from app.schemas import (
APIResponse, APIResponse,
BatchDeviceCreateRequest,
BatchDeviceCreateResponse,
BatchDeviceCreateResult,
BatchDeviceDeleteRequest,
BatchDeviceUpdateRequest,
DeviceCreate, DeviceCreate,
DeviceResponse, DeviceResponse,
DeviceUpdate, DeviceUpdate,
PaginatedList, PaginatedList,
) )
from app.config import settings
from app.extensions import limiter
from app.services import device_service from app.services import device_service
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"]) 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)) 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( @router.get(
"/{device_id}", "/{device_id}",
response_model=APIResponse[DeviceResponse], response_model=APIResponse[DeviceResponse],

92
app/routers/heartbeats.py Normal file
View File

@@ -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))

View File

@@ -7,9 +7,11 @@ import math
from datetime import datetime from datetime import datetime
from fastapi import APIRouter, Depends, HTTPException, Query from fastapi import APIRouter, Depends, HTTPException, Query
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db from app.database import get_db
from app.models import LocationRecord
from app.schemas import ( from app.schemas import (
APIResponse, APIResponse,
LocationRecordResponse, LocationRecordResponse,
@@ -92,6 +94,7 @@ async def device_track(
device_id: int, device_id: int,
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"), start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
end_time: datetime = Query(..., description="结束时间 / End 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), db: AsyncSession = Depends(get_db),
): ):
""" """
@@ -109,7 +112,23 @@ async def device_track(
detail="start_time must be before end_time / 开始时间必须早于结束时间", 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( return APIResponse(
data=[LocationRecordResponse.model_validate(r) for r in records] 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))

View File

@@ -1,7 +1,7 @@
from datetime import datetime 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") T = TypeVar("T")
@@ -42,8 +42,8 @@ class PaginatedList(BaseModel, Generic[T]):
class DeviceBase(BaseModel): class DeviceBase(BaseModel):
imei: str = Field(..., min_length=15, max_length=20, description="IMEI number") 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") 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") name: str | None = Field(None, max_length=100, description="Friendly name")
timezone: str = Field(default="+8", max_length=30) timezone: str = Field(default="+8", max_length=30)
language: str = Field(default="cn", max_length=10) language: str = Field(default="cn", max_length=10)
@@ -55,9 +55,7 @@ class DeviceCreate(DeviceBase):
class DeviceUpdate(BaseModel): class DeviceUpdate(BaseModel):
name: str | None = Field(None, max_length=100) name: str | None = Field(None, max_length=100)
status: str | None = Field(None, max_length=20) status: Literal["online", "offline"] | None = Field(None, description="Device status")
battery_level: int | None = None
gsm_signal: int | None = None
iccid: str | None = Field(None, max_length=30) iccid: str | None = Field(None, max_length=30)
imsi: str | None = Field(None, max_length=20) imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30) timezone: str | None = Field(None, max_length=30)
@@ -95,8 +93,8 @@ class DeviceSingleResponse(APIResponse[DeviceResponse]):
class LocationRecordBase(BaseModel): class LocationRecordBase(BaseModel):
device_id: int device_id: int
location_type: str = Field(..., max_length=10) location_type: str = Field(..., max_length=10)
latitude: float | None = None latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = None longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None speed: float | None = None
course: float | None = None course: float | None = None
gps_satellites: int | None = None gps_satellites: int | None = None
@@ -148,8 +146,8 @@ class AlarmRecordBase(BaseModel):
alarm_type: str = Field(..., max_length=30) alarm_type: str = Field(..., max_length=30)
alarm_source: str | None = Field(None, max_length=10) alarm_source: str | None = Field(None, max_length=10)
protocol_number: int protocol_number: int
latitude: float | None = None latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = None longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None speed: float | None = None
course: float | None = None course: float | None = None
mcc: int | None = None mcc: int | None = None
@@ -231,8 +229,8 @@ class AttendanceRecordBase(BaseModel):
attendance_type: str = Field(..., max_length=20) attendance_type: str = Field(..., max_length=20)
protocol_number: int protocol_number: int
gps_positioned: bool = False gps_positioned: bool = False
latitude: float | None = None latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = None longitude: float | None = Field(None, ge=-180, le=180)
speed: float | None = None speed: float | None = None
course: float | None = None course: float | None = None
gps_satellites: int | None = None gps_satellites: int | None = None
@@ -288,8 +286,8 @@ class BluetoothRecordBase(BaseModel):
beacon_battery_unit: str | None = None beacon_battery_unit: str | None = None
attendance_type: str | None = None attendance_type: str | None = None
bluetooth_data: dict[str, Any] | None = None bluetooth_data: dict[str, Any] | None = None
latitude: float | None = None latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = None longitude: float | None = Field(None, ge=-180, le=180)
recorded_at: datetime recorded_at: datetime
@@ -321,17 +319,17 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]])
class BeaconConfigBase(BaseModel): class BeaconConfigBase(BaseModel):
beacon_mac: str = Field(..., max_length=20, description="信标MAC地址") 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, description="iBeacon UUID") 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, description="iBeacon Major") beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
beacon_minor: int | None = Field(None, description="iBeacon Minor") beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
name: str = Field(..., max_length=100, description="信标名称") name: str = Field(..., max_length=100, description="信标名称")
floor: str | None = Field(None, max_length=20, description="楼层") floor: str | None = Field(None, max_length=20, description="楼层")
area: str | None = Field(None, max_length=100, description="区域") area: str | None = Field(None, max_length=100, description="区域")
latitude: float | None = Field(None, description="纬度") latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
longitude: float | None = Field(None, description="经度") longitude: float | None = Field(None, ge=-180, le=180, description="经度")
address: str | None = Field(None, 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): class BeaconConfigCreate(BeaconConfigBase):
@@ -339,22 +337,34 @@ class BeaconConfigCreate(BeaconConfigBase):
class BeaconConfigUpdate(BaseModel): class BeaconConfigUpdate(BaseModel):
beacon_uuid: str | None = Field(None, max_length=36) 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 = None beacon_major: int | None = Field(None, ge=0, le=65535)
beacon_minor: int | None = None beacon_minor: int | None = Field(None, ge=0, le=65535)
name: str | None = Field(None, max_length=100) name: str | None = Field(None, max_length=100)
floor: str | None = Field(None, max_length=20) floor: str | None = Field(None, max_length=20)
area: str | None = Field(None, max_length=100) area: str | None = Field(None, max_length=100)
latitude: float | None = None latitude: float | None = Field(None, ge=-90, le=90)
longitude: float | None = None longitude: float | None = Field(None, ge=-180, le=180)
address: str | None = None 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) model_config = ConfigDict(from_attributes=True)
id: int 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 created_at: datetime
updated_at: datetime | None = None updated_at: datetime | None = None
@@ -367,10 +377,87 @@ class BeaconConfigResponse(BeaconConfigBase):
class CommandCreate(BaseModel): class CommandCreate(BaseModel):
device_id: int device_id: int
command_type: str = Field(..., max_length=30) command_type: str = Field(..., max_length=30)
command_content: str command_content: str = Field(..., max_length=500)
server_flag: str = Field(..., max_length=20) 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): class CommandUpdate(BaseModel):
response_content: str | None = None response_content: str | None = None
status: str | None = Field(None, max_length=20) status: str | None = Field(None, max_length=20)

View File

@@ -9,7 +9,7 @@ from sqlalchemy import func, select, or_
from sqlalchemy.ext.asyncio import AsyncSession from sqlalchemy.ext.asyncio import AsyncSession
from app.models import Device from app.models import Device
from app.schemas import DeviceCreate, DeviceUpdate from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem
async def get_devices( async def get_devices(
@@ -189,6 +189,103 @@ async def delete_device(db: AsyncSession, device_id: int) -> bool:
return True 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: async def get_device_stats(db: AsyncSession) -> dict:
""" """
获取设备统计信息 / Get device statistics. 获取设备统计信息 / Get device statistics.

View File

@@ -106,6 +106,7 @@ async def get_device_track(
device_id: int, device_id: int,
start_time: datetime, start_time: datetime,
end_time: datetime, end_time: datetime,
max_points: int = 10000,
) -> list[LocationRecord]: ) -> list[LocationRecord]:
""" """
获取设备轨迹 / Get device movement track within a time range. 获取设备轨迹 / Get device movement track within a time range.
@@ -134,5 +135,6 @@ async def get_device_track(
LocationRecord.recorded_at <= end_time, LocationRecord.recorded_at <= end_time,
) )
.order_by(LocationRecord.recorded_at.asc()) .order_by(LocationRecord.recorded_at.asc())
.limit(max_points)
) )
return list(result.scalars().all()) return list(result.scalars().all())

View File

@@ -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)

View File

@@ -26,9 +26,9 @@
.page.active { display: block; } .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 { 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); } .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; } .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 { 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.success { background: #059669; }
.toast.error { background: #dc2626; } .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 { 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 { font-size: 12px; color: #64748b; line-height: 1.6; }
.guide-tips p i { color: #3b82f6; margin-right: 4px; } .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; } }
</style> </style>
</head> </head>
<body> <body>
@@ -366,6 +402,24 @@
<div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div> <div class="guide-tips"><p><i class="fas fa-map-marked-alt"></i> 轨迹以蓝色折线显示,绿色标记为起点,红色标记为终点</p></div>
</div> </div>
</div> </div>
<div class="page-with-panel">
<!-- Left: Device Panel -->
<div class="side-panel" id="locSidePanel">
<div class="panel-header">
<i class="fas fa-microchip text-blue-400" style="font-size:14px"></i>
<span class="panel-title">设备列表</span>
<span id="locPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('locSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="locPanelSearch" placeholder="搜索设备..." oninput="filterPanelItems('locations')"></div></div>
<div class="panel-list" id="locPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载设备...</p></div>
</div>
<div class="panel-footer" id="locPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<div class="page-main-content" style="position:relative">
<button class="panel-expand-btn" onclick="toggleSidePanel('locSidePanel')" title="展开设备面板"><i class="fas fa-chevron-right"></i></button>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<select id="locDeviceSelect" style="width:200px"> <select id="locDeviceSelect" style="width:200px">
<option value="">选择设备...</option> <option value="">选择设备...</option>
@@ -412,6 +466,8 @@
<div id="locationsPagination" class="pagination p-4"></div> <div id="locationsPagination" class="pagination p-4"></div>
</div> </div>
</div> </div>
</div>
</div>
<!-- ==================== ALARMS PAGE ==================== --> <!-- ==================== ALARMS PAGE ==================== -->
<div id="page-alarms" class="page"> <div id="page-alarms" class="page">
@@ -660,7 +716,24 @@
</div> </div>
</div> </div>
<!-- Filters & Controls --> <div class="page-with-panel">
<!-- Left: Beacon Panel -->
<div class="side-panel" id="beaconSidePanel">
<div class="panel-header">
<i class="fas fa-broadcast-tower text-green-400" style="font-size:14px"></i>
<span class="panel-title">信标列表</span>
<span id="beaconPanelCount" style="font-size:11px;color:#6b7280"></span>
<button class="panel-toggle-btn" onclick="toggleSidePanel('beaconSidePanel')" title="收起面板"><i class="fas fa-chevron-left"></i></button>
</div>
<div class="panel-search"><div class="panel-search-wrap"><i class="fas fa-search"></i><input type="text" id="beaconPanelSearch" placeholder="搜索信标..." oninput="filterPanelItems('beacons')"></div></div>
<div class="panel-list" id="beaconPanelList">
<div style="text-align:center;padding:20px;color:#6b7280"><div class="spinner"></div><p style="margin-top:8px;font-size:12px">加载信标...</p></div>
</div>
<div class="panel-footer" id="beaconPanelFooter">加载中...</div>
</div>
<!-- Right: Main Content -->
<div class="page-main-content" style="position:relative">
<button class="panel-expand-btn" onclick="toggleSidePanel('beaconSidePanel')" title="展开信标面板"><i class="fas fa-chevron-right"></i></button>
<div class="flex flex-wrap items-center gap-3 mb-4"> <div class="flex flex-wrap items-center gap-3 mb-4">
<input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px"> <input type="text" id="beaconSearch" placeholder="搜索 MAC / 名称 / 区域" style="width:220px">
<select id="beaconStatusFilter" style="width:150px"> <select id="beaconStatusFilter" style="width:150px">
@@ -673,8 +746,6 @@
<div style="flex:1"></div> <div style="flex:1"></div>
<button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button> <button class="btn btn-primary" onclick="showAddBeaconModal()"><i class="fas fa-plus"></i> 添加信标</button>
</div> </div>
<!-- Table -->
<div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative"> <div class="bg-gray-800 rounded-xl border border-gray-700 overflow-hidden relative">
<div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div> <div id="beaconsLoading" class="loading-overlay" style="display:none"><div class="spinner"></div></div>
<div class="overflow-x-auto"> <div class="overflow-x-auto">
@@ -699,6 +770,8 @@
<div id="beaconsPagination" class="pagination p-4"></div> <div id="beaconsPagination" class="pagination p-4"></div>
</div> </div>
</div> </div>
</div>
</div>
<!-- ==================== COMMANDS PAGE ==================== --> <!-- ==================== COMMANDS PAGE ==================== -->
<div id="page-commands" class="page"> <div id="page-commands" class="page">
@@ -827,6 +900,12 @@
let dashAlarmChart = null; let dashAlarmChart = null;
let alarmTypeChart = null; let alarmTypeChart = null;
// Side panel state
let panelDevices = [];
let panelBeacons = [];
let selectedPanelDeviceId = null;
let selectedPanelBeaconId = null;
// Pagination state // Pagination state
const pageState = { const pageState = {
devices: { page: 1, pageSize: 20 }, devices: { page: 1, pageSize: 20 },
@@ -1028,6 +1107,9 @@
document.getElementById('pageTitle').textContent = pageTitles[page] || page; document.getElementById('pageTitle').textContent = pageTitles[page] || page;
if (dashboardInterval) { clearInterval(dashboardInterval); dashboardInterval = null; } 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) { switch (page) {
case 'dashboard': loadDashboard(); dashboardInterval = setInterval(loadDashboard, 30000); break; 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 = `<div style="text-align:center;padding:20px;color:#6b7280;font-size:13px">${emptyText}</div>`;
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 `<div class="panel-item ${isActive ? 'active' : ''}" data-device-id="${deviceId}" data-search-text="${(d.name||'').toLowerCase()} ${(d.imei||'').toLowerCase()}" onclick="selectPanelDevice('${deviceId}')">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();selectPanelDevice('${deviceId}')" title="定位"><i class="fas fa-crosshairs"></i></button>
<button class="panel-action-btn" onclick="event.stopPropagation();showDeviceDetail('${deviceId}')" title="详情"><i class="fas fa-info-circle"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(d.name || d.imei || deviceId)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(imeiShort)}</div>
<div class="panel-item-meta">
${bp != null ? `<span style="display:flex;align-items:center;gap:3px"><span class="battery-bar"><span class="battery-bar-fill" style="width:${bp}%;background:${bColor}"></span></span><span>${bp}%</span></span>` : ''}
${d.gsm_signal != null ? `<span><i class="fas fa-signal" style="font-size:10px"></i> ${d.gsm_signal}</span>` : ''}
<span style="margin-left:auto"><i class="far fa-clock" style="font-size:10px"></i> ${timeAgo}</span>
</div>
</div>`;
}).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 `<div class="panel-item ${isActive ? 'active' : ''}" data-beacon-id="${b.id}" data-search-text="${(b.name||'').toLowerCase()} ${(b.beacon_mac||'').toLowerCase()} ${(b.area||'').toLowerCase()}" onclick="selectPanelBeacon(${b.id})">
<div class="panel-item-actions">
<button class="panel-action-btn" onclick="event.stopPropagation();showEditBeaconModal(${b.id})" title="编辑"><i class="fas fa-edit"></i></button>
</div>
<div class="panel-item-header">
<span class="panel-item-name">${escapeHtml(b.name || b.beacon_mac)}</span>
<div class="panel-item-status ${statusClass}"></div>
</div>
<div class="panel-item-sub">${escapeHtml(macShort)}</div>
<div class="panel-item-meta">
<span><i class="fas fa-layer-group" style="font-size:10px"></i> ${escapeHtml(floorArea)}</span>
${b.latitude && b.longitude ? '<span style="color:#34d399"><i class="fas fa-map-pin" style="font-size:10px"></i> 已定位</span>' : '<span style="color:#6b7280">未定位</span>'}
</div>
</div>`;
}).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 ==================== // ==================== DEVICE SELECTOR HELPER ====================
let cachedDevices = null; let cachedDevices = null;
@@ -1064,6 +1306,12 @@
}); });
if (currentVal) sel.value = currentVal; 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) { } catch (err) {
console.error('Failed to load device selectors:', 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 `<span style="display:inline-flex;align-items:center;gap:4px;padding:3px 10px;border-radius:12px;font-size:12px;border:1px solid ${color};background:${bg};color:${color};margin:2px">${active?'●':'○'} ${m.label}</span>`;
}).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 = `<span class="text-gray-400" style="font-size:11px">${escapeHtml(cmdName)}:</span> <span style="font-size:12px;color:#d1d5db">${escapeHtml(cmd.response_content)}</span>`;
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) { async function showDeviceDetail(id) {
if (!id) return; if (!id) return;
try { 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 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) =>
`<button class="btn" style="font-size:12px;padding:5px 10px;background:${color};border:none;color:#fff;border-radius:6px;cursor:pointer" ${disabledAttr} onclick="_quickCmd('${did}','${cmd}',this)"><i class="fas fa-${icon}"></i> ${label}</button>`;
showModal(` showModal(`
<h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情</h3> <h3 class="text-lg font-semibold mb-4"><i class="fas fa-microchip mr-2 text-blue-400"></i>设备详情${escapeHtml(d.name || d.imei)}</h3>
<div class="grid grid-cols-2 gap-4 text-sm"> <!-- 基本信息 -->
<div><span class="text-gray-400">ID:</span><br><span class="font-mono">${escapeHtml(d.id || d.device_id)}</span></div> <div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div> <div><span class="text-gray-400">IMEI:</span><br><span class="font-mono">${escapeHtml(d.imei)}</span></div>
<div><span class="text-gray-400">名称:</span><br>${escapeHtml(d.name || '-')}</div> <div><span class="text-gray-400">型号:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">类型:</span><br>${escapeHtml(d.device_type || '-')}</div>
<div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div> <div><span class="text-gray-400">状态:</span><br>${statusBadge(d.status)}</div>
<div><span class="text-gray-400">电量:</span><br>${d.battery_level !== undefined && d.battery_level !== null ? d.battery_level + '%' : '-'}</div> <div><span class="text-gray-400">电量:</span><br>${d.battery_level != null ? d.battery_level + '%' : '-'} ${d.gsm_signal != null ? '&nbsp;|&nbsp; 信号: ' + d.gsm_signal : ''}</div>
<div><span class="text-gray-400">信号强度:</span><br>${d.signal_strength !== undefined && d.signal_strength !== null ? d.signal_strength : '-'}</div> <div><span class="text-gray-400">ICCID:</span><br><span class="font-mono" style="font-size:11px">${escapeHtml(d.iccid || '-')}</span></div>
<div><span class="text-gray-400">固件版本:</span><br>${escapeHtml(d.firmware_version || '-')}</div> <div><span class="text-gray-400">时区/语言:</span><br>${escapeHtml(d.timezone || '-')} / ${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">时区:</span><br>${escapeHtml(d.timezone || '-')}</div>
<div><span class="text-gray-400">语言:</span><br>${escapeHtml(d.language || '-')}</div>
<div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div> <div><span class="text-gray-400">最后登录:</span><br>${formatTime(d.last_login)}</div>
<div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div> <div><span class="text-gray-400">最后心跳:</span><br>${formatTime(d.last_heartbeat)}</div>
<div class="col-span-2"><span class="text-gray-400">创建时间:</span><br>${formatTime(d.created_at)}</div>
</div> </div>
<div class="flex gap-3 mt-6">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${d.id || d.device_id}')"><i class="fas fa-edit"></i> 编辑</button> <!-- 定位信息 -->
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${d.id || d.device_id}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button> <div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-map-marker-alt mr-1 text-green-400"></i>定位信息</div>
<div class="grid grid-cols-2 gap-3 text-sm">
<div><span class="text-gray-400">当前模式:</span><br><span style="color:#10b981;font-weight:600">${_locTypeLabel(locType)}</span></div>
<div><span class="text-gray-400">定位时间:</span><br>${locTime}</div>
<div class="col-span-2"><span class="text-gray-400">地址:</span><br>${locAddr}</div>
</div>
<div style="margin-top:8px">${_locModeBadges(locType)}</div>
</div>
<!-- 功能开关 -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:10px"><i class="fas fa-sliders-h mr-1 text-yellow-400"></i>功能开关${online ? '' : ' <span style="color:#ef4444;font-weight:400;font-size:11px">(设备离线,无法操作)</span>'}</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('satellite-dish', 'GPS 开启', 'GPSON#', '#0d9488')}
${_btn('bluetooth-b', '蓝牙开启', 'BTON#', '#2563eb')}
${_btn('broadcast-tower', 'BLE扫描', 'BTSCAN,1#', '#7c3aed')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:10px">工作模式:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px;margin-bottom:10px">
${_btn('clock', '定时定位', 'MODE,1#', '#4b5563')}
${_btn('brain', '智能模式', 'MODE,3#', '#4b5563')}
</div>
<div style="font-size:11px;color:#6b7280;margin-bottom:6px">信息查询:</div>
<div style="display:flex;flex-wrap:wrap;gap:6px">
${_btn('info-circle', '设备状态', 'STATUS#', '#374151')}
${_btn('cog', '参数查询', 'PARAM#', '#374151')}
${_btn('code-branch', '固件版本', 'VERSION#', '#374151')}
${_btn('stopwatch', '定时器', 'TIMER#', '#374151')}
${_btn('clipboard-check', '完整信息', 'CHECK#', '#374151')}
</div>
<div id="detailCmdResult" style="margin-top:10px;padding:8px;background:#0d1117;border-radius:6px;min-height:20px;font-family:monospace;word-break:break-all;display:${online?'block':'none'}"></div>
</div>
<!-- TTS -->
<div style="margin-top:14px;padding:12px;background:#111827;border-radius:8px;border:1px solid #374151${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#9ca3af;margin-bottom:8px"><i class="fas fa-volume-up mr-1 text-purple-400"></i>语音播报 (TTS)</div>
<div style="display:flex;gap:8px">
<input id="detailTtsInput" type="text" placeholder="输入语音内容 (最多200字)" maxlength="200" style="flex:1;background:#1f2937;border:1px solid #374151;border-radius:6px;padding:6px 10px;color:#e5e7eb;font-size:13px" ${online?'':'disabled'}>
<button class="btn" style="font-size:12px;padding:6px 14px;background:#7c3aed;border:none;color:#fff;border-radius:6px;white-space:nowrap" ${disabledAttr} onclick="_quickTts('${did}')"><i class="fas fa-play"></i> 播报</button>
</div>
</div>
<!-- 危险操作 -->
<div style="margin-top:14px;padding:12px;background:rgba(127,29,29,0.15);border-radius:8px;border:1px solid #7f1d1d${online?'':'display:none'}">
<div style="font-size:13px;font-weight:600;color:#fca5a5;margin-bottom:8px"><i class="fas fa-exclamation-triangle mr-1"></i>系统操作</div>
<div style="display:flex;gap:8px;flex-wrap:wrap">
<button class="btn" style="font-size:12px;padding:5px 10px;background:#991b1b;border:none;color:#fff;border-radius:6px" ${disabledAttr} onclick="if(confirm('确定重启设备?'))_quickCmd('${did}','RESET#',this)"><i class="fas fa-power-off"></i> 重启设备</button>
</div>
</div>
<div class="flex gap-3 mt-5">
<button class="btn btn-primary flex-1" onclick="showEditDeviceModal('${did}')"><i class="fas fa-edit"></i> 编辑</button>
<button class="btn btn-danger flex-1" onclick="confirmDeleteDevice('${did}', '${escapeHtml(d.name || d.imei)}')"><i class="fas fa-trash"></i> 删除</button>
<button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 关闭</button> <button class="btn btn-secondary flex-1" onclick="closeModal()"><i class="fas fa-times"></i> 关闭</button>
</div> </div>
`); `);
@@ -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 ==================== // ==================== LOCATIONS ====================
function initLocationMap() { function initLocationMap() {
if (locationMap) return; if (locationMap) return;
setTimeout(() => { setTimeout(() => {
locationMap = L.map('locationMap').setView([39.9042, 116.4074], 10); locationMap = L.map('locationMap').setView(toMapCoord(39.9042, 116.4074), 10);
L.tileLayer('https://{s}.tile.openstreetmap.org/{z}/{x}/{y}.png', { if (MAP_PROVIDER === 'gaode') {
attribution: '&copy; OpenStreetMap contributors', // 高德矢量底图 (GCJ-02, standard Mercator, no API key needed)
maxZoom: 19, 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: '&copy; 高德地图',
}).addTo(locationMap); }).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: '&copy; 天地图',
}).addTo(locationMap);
}
locationMap.invalidateSize(); locationMap.invalidateSize();
}, 100); }, 100);
} }
@@ -1420,10 +1850,11 @@
const lat = loc.latitude || loc.lat; const lat = loc.latitude || loc.lat;
const lng = loc.longitude || loc.lng || loc.lon; const lng = loc.longitude || loc.lng || loc.lon;
if (lat && lng) { if (lat && lng) {
latlngs.push([lat, lng]); const [mLat, mLng] = toMapCoord(lat, lng);
latlngs.push([mLat, mLng]);
const isFirst = i === 0; const isFirst = i === 0;
const isLast = i === locations.length - 1; const isLast = i === locations.length - 1;
const marker = L.circleMarker([lat, lng], { const marker = L.circleMarker([mLat, mLng], {
radius: isFirst || isLast ? 8 : 4, radius: isFirst || isLast ? 8 : 4,
fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6', fillColor: isFirst ? '#22c55e' : isLast ? '#ef4444' : '#3b82f6',
color: '#fff', weight: 1, fillOpacity: 0.9, color: '#fff', weight: 1, fillOpacity: 0.9,
@@ -1466,7 +1897,8 @@
const lng = loc.longitude || loc.lng || loc.lon; const lng = loc.longitude || loc.lng || loc.lon;
if (!lat || !lng) { showToast('没有有效坐标数据', 'info'); return; } 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(` marker.bindPopup(`
<b>最新位置</b><br> <b>最新位置</b><br>
类型: ${loc.location_type || '-'}<br> 类型: ${loc.location_type || '-'}<br>
@@ -1476,7 +1908,7 @@
时间: ${formatTime(loc.recorded_at || loc.created_at)} 时间: ${formatTime(loc.recorded_at || loc.created_at)}
`).openPopup(); `).openPopup();
mapMarkers.push(marker); mapMarkers.push(marker);
locationMap.setView([lat, lng], 15); locationMap.setView([mLat, mLng], 15);
showToast('已显示最新位置'); showToast('已显示最新位置');
} catch (err) { } catch (err) {
showToast('获取最新位置失败: ' + err.message, 'error'); showToast('获取最新位置失败: ' + err.message, 'error');
@@ -1783,6 +2215,8 @@
}).join(''); }).join('');
} }
renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons'); renderPagination('beaconsPagination', data.total || 0, data.page || p, data.page_size || ps, 'loadBeacons');
// Render beacon side panel
renderBeaconPanel(items);
} catch (err) { } catch (err) {
showToast('加载信标列表失败: ' + err.message, 'error'); showToast('加载信标列表失败: ' + err.message, 'error');
document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>'; document.getElementById('beaconsTableBody').innerHTML = '<tr><td colspan="8" class="text-center text-red-400 py-8">加载失败</td></tr>';

View File

@@ -60,6 +60,7 @@ from app.protocol.constants import (
PROTO_ONLINE_CMD_REPLY, PROTO_ONLINE_CMD_REPLY,
PROTO_TIME_SYNC, PROTO_TIME_SYNC,
PROTO_TIME_SYNC_2, PROTO_TIME_SYNC_2,
PROTO_ADDRESS_REPLY_EN,
PROTO_WIFI, PROTO_WIFI,
PROTO_WIFI_4G, PROTO_WIFI_4G,
START_MARKER_LONG, START_MARKER_LONG,
@@ -718,6 +719,11 @@ class TCPManager:
logger.warning("Heartbeat received before login") logger.warning("Heartbeat received before login")
return 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 terminal_info: int = 0
battery_level: Optional[int] = None battery_level: Optional[int] = None
gsm_signal: Optional[int] = None gsm_signal: Optional[int] = None
@@ -752,11 +758,12 @@ class TCPManager:
logger.warning("Heartbeat for unknown IMEI=%s", imei) logger.warning("Heartbeat for unknown IMEI=%s", imei)
return return
# Update device record # Update device record (also ensure status=online if heartbeat is coming in)
await session.execute( await session.execute(
update(Device) update(Device)
.where(Device.id == device_id) .where(Device.id == device_id)
.values( .values(
status="online",
battery_level=battery_level, battery_level=battery_level,
gsm_signal=gsm_signal, gsm_signal=gsm_signal,
last_heartbeat=now, last_heartbeat=now,

View File

@@ -4,3 +4,4 @@ sqlalchemy>=2.0.0
aiosqlite>=0.19.0 aiosqlite>=0.19.0
pydantic>=2.0.0 pydantic>=2.0.0
pydantic-settings>=2.0.0 pydantic-settings>=2.0.0
slowapi