diff --git a/CLAUDE.md b/CLAUDE.md index 144e190..f28b667 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -20,12 +20,13 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器 │ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流) │ ├── database.py # SQLAlchemy async 数据库连接 -│ ├── dependencies.py # FastAPI 依赖 (API Key 认证) +│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin) │ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取) -│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog) +│ ├── websocket_manager.py # WebSocket 连接管理器 (topic订阅, 实时广播) +│ ├── models.py # ORM 模型 (Device, LocationRecord, AlarmRecord, HeartbeatRecord, AttendanceRecord, BluetoothRecord, BeaconConfig, CommandLog, ApiKey) │ ├── schemas.py # Pydantic 请求/响应模型 │ ├── tcp_server.py # TCP 服务器核心 (~2400行), 管理设备连接、协议处理、数据持久化 -│ ├── geocoding.py # 地理编码服务 (基站/WiFi → 经纬度) + 逆地理编码 (经纬度 → 地址) +│ ├── geocoding.py # 高德地理编码服务 (基站/WiFi → 经纬度 + 经纬度 → 地址) │ │ │ ├── protocol/ │ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等) @@ -41,14 +42,16 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server) │ │ │ ├── routers/ -│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete) +│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete, /all-latest-locations) │ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch) -│ │ ├── locations.py # /api/locations (含 /latest, /track, /{id}) -│ │ ├── alarms.py # /api/alarms (含 acknowledge) +│ │ ├── locations.py # /api/locations (含 /latest, /batch-latest, /track, /{id}) +│ │ ├── alarms.py # /api/alarms (含 acknowledge, alarm_source过滤) │ │ ├── attendance.py # /api/attendance (含 /stats, /{id}) -│ │ ├── bluetooth.py # /api/bluetooth (含 /{id}) +│ │ ├── bluetooth.py # /api/bluetooth (含 beacon_mac过滤, /{id}) │ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询) -│ │ └── beacons.py # /api/beacons (信标管理 CRUD) +│ │ ├── beacons.py # /api/beacons (信标管理 CRUD) +│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only) +│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅) │ │ │ └── static/ │ └── admin.html # 管理后台 SPA (暗色主题, 8个页面) @@ -84,7 +87,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T - `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值 - `.env.example` 提供所有可配置项模板,复制为 `.env` 使用 - DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录) -- 所有 API 密钥 (天地图/Google/Unwired/高德) 集中在 `config.py`,`geocoding.py` 从 `settings` 导入 +- 高德 API 密钥集中在 `config.py`,`geocoding.py` 从 `settings` 导入 - 端口号有 pydantic 校验 (ge=1, le=65535) ## 启动服务 @@ -186,34 +189,47 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` - GPS 定位 (0x22/0xA0): 直接包含经纬度坐标,精度最高 (~10m) - LBS 基站定位 (0x28/0xA1): 包含 MCC/MNC/LAC/CellID,需要地理编码转换为经纬度 - WiFi 定位 (0x2C/0xA2): 包含基站数据 + WiFi AP MAC地址列表,需要地理编码 +- **所有地理编码服务统一使用高德 (Amap)** - **前向地理编码** (`geocode_location`): 基站/WiFi → 经纬度 - - **高德智能硬件定位 (待接入)**: `apilocate.amap.com/position`,需企业认证,WiFi+基站混合定位精度 ~30m - - **Mylnikov.org (当前使用)**: 免费无需Key,中国基站精度较差 (~16km) - - Google Geolocation API / Unwired Labs API (备选,需配置Key) + - **高德智能硬件定位**: `apilocate.amap.com/position`,需企业认证 (认证中,个人账号返回 10012) + - WiFi+基站混合定位精度 ~30m,企业认证通过后自动生效 - **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址 - - **天地图 (已接入)**: 免费1万次/天,WGS84原生坐标系,无需坐标转换 + - **高德**: `restapi.amap.com/v3/geocode/regeo`,需 WGS84→GCJ02 坐标转换 (服务端 Python 实现) - 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key - - 地址格式: `省市区街道路门牌号 (附近POI)` -- 内置缓存机制,避免重复请求相同基站/坐标 - -### 天地图 API -- **服务端 Key**: `439fca3920a6f31584014424f89c3ecc` (用于逆地理编码) -- **浏览器端 Key**: `1918548e81a5ae3ff0cb985537341146` (用于前端地图瓦片,暂未使用) -- **API地址**: `http://api.tianditu.gov.cn/geocoder?postStr={JSON}&type=geocode&tk={KEY}` -- **坐标系**: WGS84 (与GPS/Leaflet一致,无需转换) -- **配额**: 免费 10,000 次/天 -- **注意**: postStr 参数需使用双引号JSON并URL编码 +- 内置 LRU 缓存 (maxsize=10000),避免重复请求相同基站/坐标 +- **WGS84→GCJ02 服务端转换**: geocoding.py 内置 `wgs84_to_gcj02()` 函数 (与前端 JS 版一致) +- **高德数字签名**: 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数 ### API 认证与限流 - **认证**: 设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头 +- **多 API Key**: 支持 master key (环境变量) + 数据库管理的 API Key (SHA-256 hash) +- **权限级别**: `read` (只读) < `write` (读写) < `admin` (管理,含 key 管理) +- **权限控制**: 所有 POST/PUT/DELETE 端点需要 `write` 权限,`/api/keys` 需要 `admin` 权限 +- **Key 管理**: `POST /api/keys` 创建 key (返回明文一次), `GET /api/keys` 列表, `PUT /api/keys/{id}` 更新, `DELETE /api/keys/{id}` 停用 - **限流**: 全局 60/min (default_limits),写操作 30/min (`@limiter.limit`) - **真实 IP**: 从 `X-Forwarded-For` → `CF-Connecting-IP` → `request.client.host` 提取 - **CORS**: `CORS_ORIGINS=*` 时自动禁用 `allow_credentials` +### WebSocket 实时推送 +- **端点**: `ws://host/ws?api_key=xxx&topics=location,alarm` +- **Topics**: location, alarm, device_status, attendance, bluetooth +- **认证**: query param api_key (支持 master key + DB key) +- **最大连接**: 100 个 WebSocket 连接 +- **消息格式**: JSON `{"topic": "...", "data": {...}, "timestamp": "..."}` +- **广播点**: 位置存储、报警存储、设备上下线、考勤存储、蓝牙存储 +- **Ping/Pong**: 客户端发送 "ping" 保活,服务器回复 "pong" + +### 数据清理 +- **自动清理**: 后台定时任务,每 `DATA_CLEANUP_INTERVAL_HOURS`(默认24) 小时执行 +- **保留天数**: `DATA_RETENTION_DAYS`(默认90) 天,删除过期的 location/heartbeat/alarm/attendance/bluetooth 记录 +- **手动清理**: `POST /api/system/cleanup` (admin only) + ### 批量操作 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/locations/batch-latest` — 批量获取多设备最新位置 (最多100) +- `GET /api/devices/all-latest-locations` — 获取所有在线设备最新位置 - `POST /api/commands/batch` — 批量发送指令 (最多100),`model_validator` 互斥校验 - 所有批量操作使用 WHERE IN 批量查询,避免 N+1 @@ -254,12 +270,13 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` - 子协议 0x0A: IMEI(8字节) + IMSI(8字节) + ICCID(10字节) - 子协议 0x09: GPS 卫星状态 - 子协议 0x00: ICCID(10字节) +- 子协议 0x04: 设备配置上报 `ALM2=40;ALM4=E0;MODE=03;IMSI=...` ### 前端字段映射 (admin.html) - 设备信号: `d.gsm_signal` (非 `d.signal_strength`) - 指令响应: `c.response_content` (非 `c.response`) - 响应时间: `c.response_at || c.sent_at` (非 `c.updated_at`) -- 位置地址: `l.address` (天地图逆地理编码结果) +- 位置地址: `l.address` (高德逆地理编码结果) - 卫星数: `l.gps_satellites` (非 `l.accuracy`) - 记录时间: `l.recorded_at` (非 `l.timestamp`) - 报警来源: `a.alarm_source` (非 `a.source`) @@ -292,42 +309,20 @@ remotePort = 5001 ## 高德地图 API -### 已有 Key +### Key - **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b` - **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5` - **账号类型**: 个人认证开发者 (正在申请企业认证) -### 已验证可用的 API -- **反地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本 -- **坐标转换** (`restapi.amap.com/v3/assistant/coordinate/convert`): WGS-84 → GCJ-02 - -### 待企业认证后启用 -- **智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (需企业认证, 错误码 10012) - - v1.0 GET: `apilocate.amap.com/position` - - v2.0 POST: `restapi.amap.com/v5/position/IoT` - - 参数格式: `bts=mcc,mnc,lac,cellid,signal` / `macs=mac,signal,ssid|mac,signal,ssid|` +### 已接入服务 +- **✅ 逆地理编码** (`restapi.amap.com/v3/geocode/regeo`): 经纬度 → 地址文本,服务端 WGS84→GCJ02 坐标转换 +- **✅ 智能硬件定位** (`apilocate.amap.com/position`): WiFi+基站 → 经纬度 (代码就绪,企业认证通过前返回 10012) +- **✅ 前端地图瓦片**: 高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换 +- **数字签名**: `_amap_sign()` — 参数按key排序拼接 + AMAP_SECRET → MD5 → sig 参数 ### 配额 (个人认证开发者) -- 基础LBS服务: 5,000 次/日 (反地理编码、坐标转换等) -- 在线定位: 50,000 次/日 - -### 接入步骤 (企业认证通过后) -1. 在 `app/geocoding.py` 中设置 `AMAP_KEY` -2. 实现 `_geocode_amap()` 函数调用智能硬件定位 API -3. 注意返回坐标为 GCJ-02,需转换为 WGS-84 用于 Leaflet 地图 -4. 高德数字签名: 参数按key排序拼接 + 安全密钥 → MD5 → sig 参数 - -## 百度地图 API - -### Key -- **服务端 AK**: `nZ4AyCm7FTn85HbFuQjw0ItSYkgxEuhA` - -### 已接入服务 -- **✅ 逆地理编码**: `api.map.baidu.com/reverse_geocoding/v3` — 经纬度 → 地址 (coordtype=wgs84ll, 无需坐标转换) - - 优先级: 百度 > 天地图 (fallback) - - 配额: 5,000次/日 (个人开发者) -- **注意**: 百度内部使用 BD-09 坐标系,但逆地理编码接口支持 `coordtype=wgs84ll` 直接传入 WGS-84 坐标 -- 百度**无服务端基站/WiFi定位API**,基站定位仍用 Mylnikov +- 基础LBS服务: 5,000 次/日 (逆地理编码等) +- 在线定位: 50,000 次/日 (企业认证后 1,000,000 次/日) ## 已知限制 @@ -335,8 +330,7 @@ remotePort = 5001 2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发 3. **SQLite 单写** - 高并发场景需切换 PostgreSQL 4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询 -5. **基站定位精度差** - 当前 Mylnikov API 中国基站精度 ~16km,待接入高德智能硬件定位后可达 ~30m -6. **天地图逆地理编码使用 HTTP** - API不支持HTTPS,Key在URL中明文传输 (低风险: 免费Key, 已降级为备选) +5. **基站/WiFi定位需企业认证** - 高德智能硬件定位 API 已接入但个人账号返回 10012,企业认证通过后自动生效 ## 已修复的问题 (Bug Fix 记录) @@ -359,7 +353,7 @@ remotePort = 5001 ### 定位功能修复 12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失) -13. **地理编码集成** - 集成 Mylnikov.org API,LBS/WiFi 定位数据自动转换为经纬度坐标 +13. **地理编码集成** - LBS/WiFi 定位数据自动转换为经纬度坐标 14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段 ### 告警功能修复 @@ -372,7 +366,7 @@ remotePort = 5001 21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码 ### 逆地理编码 -22. **天地图集成** - 位置和报警记录自动获取中文地址 (天地图逆地理编码) +22. **逆地理编码集成** - 位置和报警记录自动获取中文地址 (高德逆地理编码) 23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址 ### 蓝牙与考勤功能 @@ -410,43 +404,101 @@ remotePort = 5001 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` 变量) +### 连接修复 (2026-03-19) +51. **PROTO_ADDRESS_REPLY_EN 未导入** - 0xA5 报警地址回复时 NameError,补充 import +52. **心跳自动恢复 online** - 心跳处理时自动将设备 status 设为 online + 重新注册 connections + +### 全面切换高德 API (2026-03-23) +53. **高德逆地理编码** - 接入 restapi.amap.com/v3/geocode/regeo,服务端 WGS84→GCJ02 坐标转换 +54. **高德智能硬件定位** - 接入 apilocate.amap.com/position (基站+WiFi定位,企业认证通过前返回 10012) +55. **高德数字签名** - 实现 `_amap_sign()` 函数 (参数排序 + AMAP_SECRET + MD5) +56. **服务端坐标转换** - geocoding.py 内置 `wgs84_to_gcj02()` Python 实现 +57. **高德地图瓦片** - 前端替换为高德瓦片 (GCJ-02, 标准Mercator),前端 WGS84→GCJ02 坐标转换 +58. **移除第三方地理编码** - 清理天地图/百度/Google/Unwired/Mylnikov,统一使用高德 ### 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 层 +59. **API Key 认证** - `dependencies.py` 实现 X-API-Key 头认证,`secrets.compare_digest` 防时序攻击 +60. **CORS + 限流** - `SlowAPIMiddleware` 全局限流 (60/min),写操作独立限速 (30/min) +61. **限流器真实 IP** - `extensions.py` 从 `X-Forwarded-For` / `CF-Connecting-IP` 提取真实客户端 IP +62. **全局异常处理** - 拦截未处理异常返回 500,不泄露堆栈;放行 HTTPException/ValidationError +63. **Schema 校验加强** - IMEI pattern、经纬度范围、Literal 枚举、command max_length、BeaconConfig MAC/UUID pattern +64. **Health 端点增强** - `/health` 检测数据库连通性 + TCP 连接设备数 +65. **批量设备创建** - `POST /api/devices/batch` (最多500台),WHERE IN 单次查询去重,输入列表内 IMEI 去重 +66. **批量设备更新** - `PUT /api/devices/batch`,单次查询 + 批量更新 + 单次 flush +67. **批量设备删除** - `POST /api/devices/batch-delete`,通过 body 传递避免 URL 长度限制 +68. **批量指令发送** - `POST /api/commands/batch` (最多100台),`model_validator` 互斥校验 device_ids/imeis +69. **Heartbeats 路由** - 新增 `GET /api/heartbeats` 心跳记录查询 + 按 ID 获取 +70. **按 ID 查询端点** - locations/{id}, attendance/{id}, bluetooth/{id} 放在路由末尾避免冲突 +71. **Beacons double-commit 修复** - 移除 router 层多余的 flush/refresh,依赖 service 层 -### 0x94 子协议 0x04 -- 设备配置上报: `ALM2=40;ALM4=E0;MODE=03;IMSI=460240388355286` -- 在设备重连/重启后上报 +### 全面改进 (2026-03-22) + +#### Protocol 修复 +72. **parser.py 0x22 CellID** - 从2字节改为3字节解析 +73. **parser.py 0xA5 LBS告警** - 移除错误的 datetime+lbs_length 前缀 +74. **parser.py 0xA9 WiFi告警** - 完全重写为 datetime+MCC/MNC+cell_type+cell_count+基站+TA+WiFi+alarm_code +75. **parser.py voltage** - 0xA3/0xA5/0xA9 voltage 从2字节改为1字节 +76. **parser.py 0xA4** - 新增多围栏告警解析器 (含 fence_id) +77. **parser.py 0xB2** - 完整 iBeacon 解析 (RSSI/MAC/UUID/Major/Minor/Battery) +78. **parser.py 0xB3** - beacon_count + 每信标30字节解析 +79. **parser.py 0xB0/0xB1** - 补充 gps_positioned/terminal_info/voltage/gsm_signal 等中间字段 +80. **parser.py 0x81** - 新增指令回复解析器 +81. **builder.py 0x1F** - 时间同步添加2字节 language 参数 +82. **builder.py 地址回复** - 重写 CN/EN 地址回复完整格式 +83. **builder.py 0x80/0x82** - cmd_len 正确包含 language(2) +84. **constants.py** - 从 PROTOCOLS_REQUIRING_RESPONSE 移除 0x28 + +#### 批量 API + 索引 +85. **batch-latest** - `POST /api/locations/batch-latest` 批量获取多设备最新位置 +86. **all-latest-locations** - `GET /api/devices/all-latest-locations` 所有在线设备位置 +87. **数据库索引** - 新增5个索引 (alarm_type, acknowledged, beacon_mac, location_type, attendance_type) + +#### 多 API Key + 权限控制 +88. **ApiKey 模型** - SHA-256 hash, permissions(read/write/admin), is_active +89. **多 Key 认证** - master key (env) + DB keys, last_used_at 追踪 +90. **权限控制** - require_write/require_admin 依赖,写端点需 write 权限 +91. **Key 管理 API** - `/api/keys` CRUD (admin only),创建时返回明文一次 +92. **DeviceUpdate** - 移除 status 字段 (设备状态由系统管理) + +#### WebSocket 实时推送 +93. **WebSocketManager** - 连接管理器,topic 订阅,最大100连接 +94. **WS 端点** - `/ws?api_key=xxx&topics=location,alarm` WebSocket 认证 +95. **TCP 广播集成** - 7个广播点 (location, alarm, device_status x2, attendance, bluetooth x2) + +#### 数据清理 + 补充 +96. **自动数据清理** - 后台定时任务,DATA_RETENTION_DAYS=90, DATA_CLEANUP_INTERVAL_HOURS=24 +97. **手动清理 API** - `POST /api/system/cleanup` (admin only) +98. **alarm_source 过滤** - alarms 列表新增 alarm_source 参数 +99. **beacon_mac 过滤** - bluetooth 列表新增 beacon_mac 参数 +100. **command_type 过滤** - commands 列表新增 command_type 参数 +101. **sort_order 参数** - alarms/bluetooth 列表支持 asc/desc 排序 + +#### 协议层统一 +102. **PacketBuilder 统一** - tcp_server.py 内嵌 PacketBuilder 改为委托 protocol/builder.py + +### 审计修复 (2026-03-23) +103. **require_admin 导入** - main.py 缺少 require_admin 导入,设置 API_KEY 后 cleanup 端点崩溃 +104. **0x28/0x2C CellID 3字节** - tcp_server.py 2G 基站 CellID 从2字节修正为3字节 (0x28 LBS, 0x2C WiFi, 邻区解析) +105. **parser.py CellID 3字节** - parse_lbs_station 默认 cell_id_size=2→3,新增3字节分支; _parse_lbs_address_req CellID 2→3字节 +106. **alarm_source 字段长度** - models.py String(10)→String(20), schemas.py max_length=10→20 ("single_fence"=12字符) +107. **AlarmRecord wifi_data/fence_data** - 0xA9 WiFi报警存储 wifi_data; 0xA4 多围栏报警提取并存储 fence_id +108. **CommandLog.sent_at** - 指令发送成功后设置 sent_at 时间戳 (原来始终为 NULL) +109. **geocoding IMEI 参数化** - 移除硬编码 IMEI,新增 GEOCODING_DEFAULT_IMEI 配置项,geocode_location 接受 imei 参数,TCP 层传递设备 IMEI +110. **parser.py 循环长度检查** - _parse_lbs_multi 和 _parse_wifi 站点循环检查从 pos+5 改为 pos+6 (LAC=2+CellID=3+RSSI=1=6) +111. **batch sent_at** - batch_send_command 批量指令路径也设置 cmd_log.sent_at (与单条路径一致) +112. **GPS 经度符号反转** - course_status bit 11 含义为 0=东经/1=西经 (非 1=东经),`is_east` 改为 `is_west`,修复成都定位到北美洲的问题 (tcp_server.py + parser.py) ## 待完成功能 -1. **⭐ 接入高德智能硬件定位** - 企业认证通过后,替换 Mylnikov,大幅提升 WiFi/基站定位精度 -2. ~~**地图瓦片**~~ - ✅ 已切换为高德瓦片 (GCJ-02),支持 MAP_PROVIDER 切换 ('gaode'|'tianditu') -3. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 -4. ~~**蓝牙信标调试**~~ - ✅ 已完成 (2026-03-18),0xB2打卡数据正常上报,信标匹配成功 +1. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 +2. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知 +3. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py ## 调试技巧 ```bash # 查看实时日志 -tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Tianditu" --line-buffered +tail -f /home/gpsystem/server.log | grep -aE "TCP|login|heartbeat|error|geocod|Amap" --line-buffered # 检查数据库 python3 -c " diff --git a/app/config.py b/app/config.py index 303ffdc..d8798f4 100644 --- a/app/config.py +++ b/app/config.py @@ -29,20 +29,21 @@ class Settings(BaseSettings): 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") + # 高德地图 API (geocoding) 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 + GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API") 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") + # Data retention + DATA_RETENTION_DAYS: int = Field(default=90, description="Days to keep location/heartbeat/alarm/attendance/bluetooth records") + DATA_CLEANUP_INTERVAL_HOURS: int = Field(default=24, description="Hours between automatic cleanup runs") + model_config = {"env_file": ".env", "env_file_encoding": "utf-8", "extra": "ignore"} diff --git a/app/dependencies.py b/app/dependencies.py index 88d69ef..0cc66cd 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -1,20 +1,85 @@ """ Shared FastAPI dependencies. +Supports master API key (env) and database-managed API keys with permission levels. """ +import hashlib import secrets +from datetime import datetime, timezone -from fastapi import HTTPException, Security +from fastapi import Depends, HTTPException, Security from fastapi.security import APIKeyHeader +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession from app.config import settings +from app.database import get_db _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False) +# Permission hierarchy: admin > write > read +_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3} -async def verify_api_key(api_key: str | None = Security(_api_key_header)): - """Verify API key if authentication is enabled.""" + +def _hash_key(key: str) -> str: + """SHA-256 hash of an API key.""" + return hashlib.sha256(key.encode()).hexdigest() + + +async def verify_api_key( + api_key: str | None = Security(_api_key_header), + db: AsyncSession = Depends(get_db), +) -> dict | None: + """Verify API key. Returns key info dict or None (auth disabled). + + Checks master key first, then database keys. + Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}. + """ 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") + return None # Auth disabled + + if api_key is None: + raise HTTPException(status_code=401, detail="Missing API key / 缺少 API Key") + + # Check master key + if secrets.compare_digest(api_key, settings.API_KEY): + return {"permissions": "admin", "key_id": None, "name": "master"} + + # Check database keys + from app.models import ApiKey + + key_hash = _hash_key(api_key) + result = await db.execute( + select(ApiKey).where(ApiKey.key_hash == key_hash, ApiKey.is_active == True) # noqa: E712 + ) + db_key = result.scalar_one_or_none() + if db_key is None: + raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key") + + # Update last_used_at + db_key.last_used_at = datetime.now(timezone.utc) + await db.flush() + + return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} + + +def require_permission(min_level: str): + """Factory for permission-checking dependencies.""" + + async def _check(key_info: dict | None = Depends(verify_api_key)): + if key_info is None: + return # Auth disabled + current = _PERMISSION_LEVELS.get(key_info["permissions"], 0) + required = _PERMISSION_LEVELS.get(min_level, 0) + if current < required: + raise HTTPException( + status_code=403, + detail=f"Insufficient permissions. Requires '{min_level}' / 权限不足,需要 '{min_level}' 权限", + ) + return key_info + + return _check + + +require_write = require_permission("write") +require_admin = require_permission("admin") diff --git a/app/geocoding.py b/app/geocoding.py index 1897598..d255b68 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -2,34 +2,77 @@ Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates, and reverse geocode coordinates to addresses. -Uses free APIs: -- Cell tower: Google Geolocation API (if key available) or unwiredlabs.com -- WiFi: Same APIs support WiFi AP lookup -- Reverse geocoding: 天地图 (Tianditu) - free, WGS84 native +All services use 高德 (Amap) API exclusively. +- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位 +- Reverse geocoding (coords → address): 高德逆地理编码 """ -import json +import hashlib import logging +import math from collections import OrderedDict from typing import Optional -from urllib.parse import quote import aiohttp logger = logging.getLogger(__name__) -# Import keys from centralized config (no more hardcoded values here) from app.config import settings as _settings -GOOGLE_API_KEY: Optional[str] = _settings.GOOGLE_API_KEY -UNWIRED_API_TOKEN: Optional[str] = _settings.UNWIRED_API_TOKEN -TIANDITU_API_KEY: Optional[str] = _settings.TIANDITU_API_KEY -BAIDU_MAP_AK: Optional[str] = _settings.BAIDU_MAP_AK +AMAP_KEY: Optional[str] = _settings.AMAP_KEY +AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET -# Maximum cache entries (LRU eviction) — configurable via settings _CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE +# --------------------------------------------------------------------------- +# WGS-84 → GCJ-02 coordinate conversion (server-side) +# --------------------------------------------------------------------------- + +_A = 6378245.0 +_EE = 0.00669342162296594 + + +def _out_of_china(lat: float, lon: float) -> bool: + return not (73.66 < lon < 135.05 and 3.86 < lat < 53.55) + + +def _transform_lat(x: float, y: float) -> float: + ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * math.sqrt(abs(x)) + ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(y * math.pi) + 40.0 * math.sin(y / 3.0 * math.pi)) * 2.0 / 3.0 + ret += (160.0 * math.sin(y / 12.0 * math.pi) + 320.0 * math.sin(y * math.pi / 30.0)) * 2.0 / 3.0 + return ret + + +def _transform_lon(x: float, y: float) -> float: + ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * math.sqrt(abs(x)) + ret += (20.0 * math.sin(6.0 * x * math.pi) + 20.0 * math.sin(2.0 * x * math.pi)) * 2.0 / 3.0 + ret += (20.0 * math.sin(x * math.pi) + 40.0 * math.sin(x / 3.0 * math.pi)) * 2.0 / 3.0 + ret += (150.0 * math.sin(x / 12.0 * math.pi) + 300.0 * math.sin(x / 30.0 * math.pi)) * 2.0 / 3.0 + return ret + + +def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]: + """Convert WGS-84 to GCJ-02 (used by 高德).""" + if _out_of_china(lat, lon): + return (lat, lon) + d_lat = _transform_lat(lon - 105.0, lat - 35.0) + d_lon = _transform_lon(lon - 105.0, lat - 35.0) + rad_lat = lat / 180.0 * math.pi + magic = math.sin(rad_lat) + magic = 1 - _EE * magic * magic + sqrt_magic = math.sqrt(magic) + d_lat = (d_lat * 180.0) / ((_A * (1 - _EE)) / (magic * sqrt_magic) * math.pi) + d_lon = (d_lon * 180.0) / (_A / sqrt_magic * math.cos(rad_lat) * math.pi) + return (lat + d_lat, lon + d_lon) + + +# --------------------------------------------------------------------------- +# LRU Cache +# --------------------------------------------------------------------------- + + class LRUCache(OrderedDict): """Simple LRU cache based on OrderedDict.""" @@ -51,13 +94,29 @@ class LRUCache(OrderedDict): self.popitem(last=False) -# Cache cell tower lookups to avoid redundant API calls _cell_cache: LRUCache = LRUCache() _wifi_cache: LRUCache = LRUCache() -# Cache reverse geocoding results (coord rounded to ~100m -> address) _address_cache: LRUCache = LRUCache() +# --------------------------------------------------------------------------- +# 高德数字签名 (AMAP_SECRET) +# --------------------------------------------------------------------------- + +def _amap_sign(params: dict) -> str: + """Generate 高德 API digital signature (MD5).""" + if not AMAP_SECRET: + return "" + sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys())) + raw = sorted_str + AMAP_SECRET + return hashlib.md5(raw.encode()).hexdigest() + + +# =========================================================================== +# Forward Geocoding: cell/WiFi → lat/lon +# =========================================================================== + + async def geocode_location( mcc: Optional[int] = None, mnc: Optional[int] = None, @@ -65,272 +124,129 @@ async def geocode_location( cell_id: Optional[int] = None, wifi_list: Optional[list[dict]] = None, neighbor_cells: Optional[list[dict]] = None, + imei: Optional[str] = None, ) -> tuple[Optional[float], Optional[float]]: """ Convert cell tower and/or WiFi AP data to lat/lon. - Parameters - ---------- - mcc : int - Mobile Country Code - mnc : int - Mobile Network Code - lac : int - Location Area Code - cell_id : int - Cell Tower ID - wifi_list : list[dict] - WiFi APs [{"mac": "AA:BB:CC:DD:EE:FF", "signal": -70}, ...] - neighbor_cells : list[dict] - Neighbor cells [{"lac": ..., "cell_id": ..., "rssi": ...}, ...] - - Returns - ------- - (latitude, longitude) or (None, None) + Uses 高德智能硬件定位 API exclusively. """ - # Check cache first (cell tower) + # Check cache first if mcc is not None and lac is not None and cell_id is not None: cache_key = (mcc, mnc or 0, lac, cell_id) cached = _cell_cache.get_cached(cache_key) if cached is not None: return cached - # Try Google Geolocation API first - if GOOGLE_API_KEY: - result = await _geocode_google(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells) + if AMAP_KEY: + result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei) if result[0] is not None: if mcc is not None and lac is not None and cell_id is not None: _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) return result - # Try Unwired Labs API - if UNWIRED_API_TOKEN: - result = await _geocode_unwired(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells) - if result[0] is not None: - if mcc is not None and lac is not None and cell_id is not None: - _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) - return result - - # Fallback: Mylnikov.org (free, no API key required) - if mcc is not None and lac is not None and cell_id is not None: - result = await _geocode_mylnikov_cell(mcc, mnc or 0, lac, cell_id) - if result[0] is not None: - _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) - return result - - # Try WiFi via Mylnikov - if wifi_list: - for ap in wifi_list: - mac = ap.get("mac", "") - if mac: - result = await _geocode_mylnikov_wifi(mac) - if result[0] is not None: - return result - return (None, None) -async def _geocode_google( - mcc, mnc, lac, cell_id, wifi_list, neighbor_cells +async def _geocode_amap( + mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None ) -> tuple[Optional[float], Optional[float]]: - """Use Google Geolocation API.""" - url = f"https://www.googleapis.com/geolocation/v1/geolocate?key={GOOGLE_API_KEY}" - body: dict = {} + """ + Use 高德智能硬件定位 API (apilocate.amap.com/position). - if mcc is not None: - body["homeMobileCountryCode"] = mcc - if mnc is not None: - body["homeMobileNetworkCode"] = mnc + Returns coordinates (高德 returns GCJ-02). + """ + # Build bts (base station) parameter: mcc,mnc,lac,cellid,signal + bts = "" + if mcc is not None and lac is not None and cell_id is not None: + bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65" - # Cell towers - towers = [] - if lac is not None and cell_id is not None: - towers.append({ - "cellId": cell_id, - "locationAreaCode": lac, - "mobileCountryCode": mcc or 0, - "mobileNetworkCode": mnc or 0, - }) + # Build nearbts (neighbor cells) + nearbts_parts = [] if neighbor_cells: for nc in neighbor_cells: - towers.append({ - "cellId": nc.get("cell_id", 0), - "locationAreaCode": nc.get("lac", 0), - "mobileCountryCode": mcc or 0, - "mobileNetworkCode": mnc or 0, - "signalStrength": -(nc.get("rssi", 0)), - }) - if towers: - body["cellTowers"] = towers + nc_lac = nc.get("lac", 0) + nc_cid = nc.get("cell_id", 0) + nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80 + nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}") - # WiFi APs + # Build macs (WiFi APs): mac,signal,ssid + macs_parts = [] if wifi_list: - aps = [] for ap in wifi_list: - aps.append({ - "macAddress": ap.get("mac", ""), - "signalStrength": -(ap.get("signal", 0)), - }) - body["wifiAccessPoints"] = aps + mac = ap.get("mac", "").lower().replace(":", "") + signal = -(ap.get("signal", 0)) if ap.get("signal") else -70 + ssid = ap.get("ssid", "") + macs_parts.append(f"{mac},{signal},{ssid}") + + if not bts and not macs_parts: + return (None, None) + + params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY} + if bts: + params["bts"] = bts + if nearbts_parts: + params["nearbts"] = "|".join(nearbts_parts) + if macs_parts: + params["macs"] = "|".join(macs_parts) + + # Add digital signature + sig = _amap_sign(params) + if sig: + params["sig"] = sig + + url = "https://apilocate.amap.com/position" try: async with aiohttp.ClientSession() as session: - async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp: - if resp.status == 200: - data = await resp.json() - loc = data.get("location", {}) - lat = loc.get("lat") - lng = loc.get("lng") - if lat is not None and lng is not None: - logger.info("Google geocode: lat=%.6f, lon=%.6f", lat, lng) - return (lat, lng) - else: - text = await resp.text() - logger.warning("Google geocode failed: %d %s", resp.status, text[:200]) - except Exception as e: - logger.warning("Google geocode error: %s", e) - - return (None, None) - - -async def _geocode_unwired( - mcc, mnc, lac, cell_id, wifi_list, neighbor_cells -) -> tuple[Optional[float], Optional[float]]: - """Use Unwired Labs LocationAPI.""" - url = "https://us1.unwiredlabs.com/v2/process.php" - body: dict = {"token": UNWIRED_API_TOKEN} - - # Cell towers - cells = [] - if mcc is not None and lac is not None and cell_id is not None: - cells.append({ - "lac": lac, - "cid": cell_id, - "mcc": mcc, - "mnc": mnc or 0, - }) - if neighbor_cells: - for nc in neighbor_cells: - cells.append({ - "lac": nc.get("lac", 0), - "cid": nc.get("cell_id", 0), - "mcc": mcc or 0, - "mnc": mnc or 0, - "signal": -(nc.get("rssi", 0)), - }) - if cells: - body["cells"] = cells - - # WiFi APs - if wifi_list: - aps = [] - for ap in wifi_list: - aps.append({ - "bssid": ap.get("mac", ""), - "signal": -(ap.get("signal", 0)), - }) - body["wifi"] = aps - - try: - async with aiohttp.ClientSession() as session: - async with session.post(url, json=body, timeout=aiohttp.ClientTimeout(total=5)) as resp: - if resp.status == 200: - data = await resp.json() - if data.get("status") == "ok": - lat = data.get("lat") - lon = data.get("lon") - if lat is not None and lon is not None: - logger.info("Unwired geocode: lat=%.6f, lon=%.6f", lat, lon) - return (lat, lon) - else: - logger.warning("Unwired geocode: %s", data.get("message", "unknown error")) - except Exception as e: - logger.warning("Unwired geocode error: %s", e) - - return (None, None) - - -async def _geocode_mylnikov_cell( - mcc: int, mnc: int, lac: int, cell_id: int -) -> tuple[Optional[float], Optional[float]]: - """Use Mylnikov.org free cell tower geocoding API (no API key required).""" - url = ( - f"https://api.mylnikov.org/geolocation/cell" - f"?v=1.1&data=open" - f"&mcc={mcc}&mnc={mnc}&lac={lac}&cellid={cell_id}" - ) - try: - async with aiohttp.ClientSession() as session: - async with session.get(url, timeout=aiohttp.ClientTimeout(total=5)) as resp: + async with session.get( + url, params=params, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: if resp.status == 200: data = await resp.json(content_type=None) - if data.get("result") == 200: - lat = data.get("data", {}).get("lat") - lon = data.get("data", {}).get("lon") - if lat is not None and lon is not None: - logger.info("Mylnikov cell geocode: lat=%.6f, lon=%.6f", lat, lon) + if data.get("status") == "1" and data.get("result"): + result = data["result"] + location = result.get("location", "") + if location and "," in location: + lon_str, lat_str = location.split(",") + lat = float(lat_str) + lon = float(lon_str) + logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon) return (lat, lon) else: - logger.debug("Mylnikov cell: no result for MCC=%d MNC=%d LAC=%d CellID=%d", - mcc, mnc, lac, cell_id) + infocode = data.get("infocode", "") + if infocode == "10012": + logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)") + else: + logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode) else: - logger.warning("Mylnikov cell API HTTP %d", resp.status) + logger.warning("Amap geocode HTTP %d", resp.status) except Exception as e: - logger.warning("Mylnikov cell geocode error: %s", e) + logger.warning("Amap geocode error: %s", e) return (None, None) -async def _geocode_mylnikov_wifi(mac: str) -> tuple[Optional[float], Optional[float]]: - """Use Mylnikov.org free WiFi AP geocoding API.""" - # Normalize MAC format (needs colons) - mac = mac.upper().replace("-", ":") - url = f"https://api.mylnikov.org/geolocation/wifi?v=1.1&data=open&bssid={mac}" - 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("result") == 200: - lat = data.get("data", {}).get("lat") - lon = data.get("data", {}).get("lon") - if lat is not None and lon is not None: - logger.info("Mylnikov WiFi geocode: lat=%.6f, lon=%.6f (MAC=%s)", lat, lon, mac) - _wifi_cache.put(mac, (lat, lon)) - return (lat, lon) - else: - logger.debug("Mylnikov WiFi API HTTP %d for MAC=%s", resp.status, mac) - except Exception as e: - logger.warning("Mylnikov WiFi geocode error: %s", e) - - return (None, None) - - -# --------------------------------------------------------------------------- -# Reverse Geocoding: coordinates -> address -# --------------------------------------------------------------------------- +# =========================================================================== +# Reverse Geocoding: coordinates → address +# =========================================================================== async def reverse_geocode( lat: float, lon: float ) -> Optional[str]: """ - Convert lat/lon to a human-readable address. + Convert lat/lon (WGS-84) to a human-readable address. - Priority: Baidu Map > Tianditu (fallback). - Both accept WGS84 coordinates natively (Baidu via coordtype=wgs84ll). - Returns None if no reverse geocoding service is available. + Uses 高德逆地理编码 API exclusively. """ - # Round to ~100m for cache key to reduce API calls cache_key = (round(lat, 3), round(lon, 3)) cached = _address_cache.get_cached(cache_key) if cached is not None: return cached - # Try Baidu Map first (higher quality addresses for China) - if BAIDU_MAP_AK: - result = await _reverse_geocode_baidu(lat, lon) - if result: - _address_cache.put(cache_key, result) - return result - - # Fallback to Tianditu - if TIANDITU_API_KEY: - result = await _reverse_geocode_tianditu(lat, lon) + if AMAP_KEY: + result = await _reverse_geocode_amap(lat, lon) if result: _address_cache.put(cache_key, result) return result @@ -338,101 +254,55 @@ async def reverse_geocode( return None -async def _reverse_geocode_baidu( +async def _reverse_geocode_amap( lat: float, lon: float ) -> Optional[str]: """ - Use Baidu Map reverse geocoding API. + Use 高德逆地理编码 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). + API: https://restapi.amap.com/v3/geocode/regeo + Input: GCJ-02 coordinates (need to convert from WGS-84). + Free tier: 5,000 requests/day (personal), 1,000,000/day (enterprise). """ - url = ( - f"https://api.map.baidu.com/reverse_geocoding/v3/" - f"?ak={BAIDU_MAP_AK}&output=json&coordtype=wgs84ll" - f"&location={lat},{lon}" - ) + gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon) + + params = { + "key": AMAP_KEY, + "location": f"{gcj_lon:.6f},{gcj_lat:.6f}", + "extensions": "base", + "output": "json", + } + + sig = _amap_sign(params) + if sig: + params["sig"] = sig + + url = "https://restapi.amap.com/v3/geocode/regeo" + try: async with aiohttp.ClientSession() as session: async with session.get( - url, timeout=aiohttp.ClientTimeout(total=5) + url, params=params, 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})" + if data.get("status") == "1": + regeocode = data.get("regeocode", {}) + formatted = regeocode.get("formatted_address", "") + if formatted and formatted != "[]": logger.info( - "Baidu reverse geocode: %.6f,%.6f -> %s", - lat, lon, address, + "Amap reverse geocode: %.6f,%.6f -> %s", + lat, lon, formatted, ) - return address + return formatted else: logger.warning( - "Baidu reverse geocode error: status=%s, msg=%s", - data.get("status"), data.get("message", ""), + "Amap reverse geocode error: info=%s, infocode=%s", + data.get("info", ""), data.get("infocode", ""), ) else: - logger.warning("Baidu reverse geocode HTTP %d", resp.status) + logger.warning("Amap reverse geocode HTTP %d", resp.status) except Exception as e: - logger.warning("Baidu reverse geocode error: %s", e) - - return None - - -async def _reverse_geocode_tianditu( - lat: float, lon: float -) -> Optional[str]: - """ - Use 天地图 (Tianditu) reverse geocoding API. - - API docs: http://lbs.tianditu.gov.cn/server/geocoding.html - Coordinate system: WGS84 (same as our GPS data, no conversion needed). - Free tier: 10,000 requests/day. - """ - post_str = json.dumps({"lon": lon, "lat": lat, "ver": 1}, separators=(",", ":")) - url = ( - f"http://api.tianditu.gov.cn/geocoder" - f"?postStr={quote(post_str)}&type=geocode&tk={TIANDITU_API_KEY}" - ) - 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", {}) - # Build address from components - addr_comp = result.get("addressComponent", {}) - formatted = result.get("formatted_address", "") - if formatted: - # Add nearby POI if available - poi = addr_comp.get("poi", "") - address = formatted - if poi and poi not in formatted: - address = f"{formatted} ({poi})" - logger.info( - "Tianditu reverse geocode: %.6f,%.6f -> %s", - lat, lon, address, - ) - return address - else: - logger.warning( - "Tianditu reverse geocode error: %s", - data.get("msg", data), - ) - else: - logger.warning("Tianditu reverse geocode HTTP %d", resp.status) - except Exception as e: - logger.warning("Tianditu reverse geocode error: %s", e) + logger.warning("Amap reverse geocode error: %s", e) return None diff --git a/app/main.py b/app/main.py index 537542f..cd878e7 100644 --- a/app/main.py +++ b/app/main.py @@ -1,6 +1,6 @@ from pathlib import Path -from fastapi import FastAPI, Request +from fastapi import Depends, FastAPI, Request from fastapi.middleware.cors import CORSMiddleware from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles @@ -13,8 +13,8 @@ from slowapi.errors import RateLimitExceeded from app.database import init_db, async_session, engine from app.tcp_server import tcp_manager from app.config import settings -from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats -from app.dependencies import verify_api_key +from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding +from app.dependencies import verify_api_key, require_write, require_admin import asyncio import logging @@ -26,6 +26,46 @@ logger = logging.getLogger(__name__) from app.extensions import limiter +async def run_data_cleanup(): + """Delete records older than DATA_RETENTION_DAYS.""" + from datetime import datetime, timezone, timedelta + from sqlalchemy import delete + from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord + + cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS) + total_deleted = 0 + async with async_session() as session: + async with session.begin(): + for model, time_col in [ + (LocationRecord, LocationRecord.created_at), + (HeartbeatRecord, HeartbeatRecord.created_at), + (AlarmRecord, AlarmRecord.created_at), + (AttendanceRecord, AttendanceRecord.created_at), + (BluetoothRecord, BluetoothRecord.created_at), + ]: + result = await session.execute( + delete(model).where(time_col < cutoff) + ) + if result.rowcount: + total_deleted += result.rowcount + logger.info("Cleanup: deleted %d old %s records", result.rowcount, model.__tablename__) + return total_deleted + + +async def _data_cleanup_loop(): + """Background task that runs cleanup periodically.""" + while True: + try: + await asyncio.sleep(settings.DATA_CLEANUP_INTERVAL_HOURS * 3600) + deleted = await run_data_cleanup() + if deleted: + logger.info("Data cleanup completed: %d records removed", deleted) + except asyncio.CancelledError: + break + except Exception: + logger.exception("Data cleanup error") + + @asynccontextmanager async def lifespan(app: FastAPI): # Startup @@ -41,10 +81,28 @@ async def lifespan(app: FastAPI): logger.info("All devices reset to offline on startup") except Exception: logger.exception("Failed to reset device statuses on startup") + # Create missing indexes (safe for existing databases) + try: + from sqlalchemy import text as sa_text + async with engine.begin() as conn: + for stmt in [ + "CREATE INDEX IF NOT EXISTS ix_alarm_type ON alarm_records(alarm_type)", + "CREATE INDEX IF NOT EXISTS ix_alarm_ack ON alarm_records(acknowledged)", + "CREATE INDEX IF NOT EXISTS ix_bt_beacon_mac ON bluetooth_records(beacon_mac)", + "CREATE INDEX IF NOT EXISTS ix_loc_type ON location_records(location_type)", + "CREATE INDEX IF NOT EXISTS ix_att_type ON attendance_records(attendance_type)", + ]: + await conn.execute(sa_text(stmt)) + logger.info("Database indexes verified/created") + except Exception: + logger.exception("Failed to create indexes") + logger.info("Starting TCP server on %s:%d", settings.TCP_HOST, settings.TCP_PORT) tcp_task = asyncio.create_task(tcp_manager.start(settings.TCP_HOST, settings.TCP_PORT)) + cleanup_task = asyncio.create_task(_data_cleanup_loop()) yield # Shutdown + cleanup_task.cancel() logger.info("Shutting down TCP server...") await tcp_manager.stop() tcp_task.cancel() @@ -119,6 +177,9 @@ app.include_router(commands.router, dependencies=[*_api_deps]) app.include_router(bluetooth.router, dependencies=[*_api_deps]) app.include_router(beacons.router, dependencies=[*_api_deps]) app.include_router(heartbeats.router, dependencies=[*_api_deps]) +app.include_router(api_keys.router, dependencies=[*_api_deps]) +app.include_router(ws.router) # WebSocket handles auth internally +app.include_router(geocoding.router, dependencies=[*_api_deps]) _STATIC_DIR = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") @@ -156,9 +217,22 @@ async def health(): except Exception: logger.warning("Health check: database unreachable") + from app.websocket_manager import ws_manager status = "healthy" if db_ok else "degraded" return { "status": status, "database": "ok" if db_ok else "error", "connected_devices": len(tcp_manager.connections), + "websocket_connections": ws_manager.connection_count, } + + +@app.post("/api/system/cleanup", tags=["System / 系统管理"], dependencies=[Depends(require_admin)] if settings.API_KEY else []) +async def manual_cleanup(): + """手动触发数据清理 / Manually trigger data cleanup (admin only).""" + try: + deleted = await run_data_cleanup() + return {"code": 0, "message": f"Cleanup completed: {deleted} records removed", "data": {"deleted": deleted}} + except Exception as e: + logger.exception("Manual cleanup failed") + return {"code": 500, "message": f"Cleanup failed: {str(e)}", "data": None} diff --git a/app/models.py b/app/models.py index 831cef2..9a9bf2f 100644 --- a/app/models.py +++ b/app/models.py @@ -129,7 +129,7 @@ class AlarmRecord(Base): String(30), nullable=False ) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ... alarm_source: Mapped[str | None] = mapped_column( - String(10), nullable=True + String(20), nullable=True ) # single_fence, multi_fence, lbs, wifi protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) latitude: Mapped[float | None] = mapped_column(Float, nullable=True) @@ -321,3 +321,22 @@ class CommandLog(Base): f"" ) + + +class ApiKey(Base): + """API keys for external system authentication.""" + + __tablename__ = "api_keys" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + key_hash: Mapped[str] = mapped_column(String(64), unique=True, index=True, nullable=False) + name: Mapped[str] = mapped_column(String(100), nullable=False) + permissions: Mapped[str] = mapped_column( + String(20), default="read", nullable=False + ) # read, write, admin + is_active: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False) + last_used_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + + def __repr__(self) -> str: + return f"" diff --git a/app/protocol/builder.py b/app/protocol/builder.py index d8588ec..4254f0c 100644 --- a/app/protocol/builder.py +++ b/app/protocol/builder.py @@ -123,14 +123,18 @@ class PacketBuilder: self, serial_number: int, protocol: int = PROTO_TIME_SYNC, + language: int = 0x0001, ) -> bytes: """ Build a time sync response (0x1F). - Returns the current UTC time as a 4-byte Unix timestamp. + Returns the current UTC time as a 4-byte Unix timestamp + 2-byte language. + For Chinese (0x0001), the timestamp is GMT+8. """ utc_now = int(time.time()) - info = struct.pack("!I", utc_now) + if language == 0x0001: + utc_now += 8 * 3600 # GMT+8 for Chinese + info = struct.pack("!IH", utc_now, language) return self.build_response(protocol, serial_number, info) def build_time_sync_8a_response(self, serial_number: int) -> bytes: @@ -186,8 +190,8 @@ class PacketBuilder: Complete packet. """ cmd_bytes = command.encode("ascii") - # inner_len = server_flag(4) + cmd_content(N) - inner_len = 4 + len(cmd_bytes) + # inner_len = server_flag(4) + cmd_content(N) + language(2) + inner_len = 4 + len(cmd_bytes) + 2 info = struct.pack("B", inner_len) # 1 byte inner length info += struct.pack("!I", server_flag) # 4 bytes server flag @@ -225,8 +229,8 @@ class PacketBuilder: Complete packet. """ msg_bytes = message_text.encode("utf-16-be") - # inner_len = server_flag(4) + msg_content(N) - inner_len = 4 + len(msg_bytes) + # inner_len = server_flag(4) + msg_content(N) + language(2) + inner_len = 4 + len(msg_bytes) + 2 info = struct.pack("B", inner_len) # 1 byte inner length info += struct.pack("!I", server_flag) # 4 bytes server flag @@ -238,94 +242,57 @@ class PacketBuilder: def build_address_reply_cn( self, serial_number: int, - server_flag: int, - address: str, + server_flag: int = 0, + address: str = "", phone: str = "", protocol: int = PROTO_LBS_ADDRESS_REQ, + is_alarm: bool = False, ) -> bytes: """ - Build a Chinese address reply packet. + Build a Chinese address reply packet (0x17). - Used as a response to protocol 0x17 (LBS Address Request) - or similar address query protocols. - - Parameters - ---------- - serial_number : int - Packet serial number. - server_flag : int - Server flag bits (32-bit). - address : str - Address string (encoded as UTF-16 Big-Endian). - phone : str - Phone number string (BCD encoded, even length, padded with 'F'). - protocol : int - Protocol number to respond with (default 0x17). - - Returns - ------- - bytes - Complete packet. + Format: cmd_length(1) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF16BE) + && + phone(21) + ## """ + flag_bytes = struct.pack("!I", server_flag) + marker = b"ALARMSMS" if is_alarm else b"ADDRESS" + separator = b"&&" + terminator = b"##" + addr_bytes = address.encode("utf-16-be") - addr_len = len(addr_bytes) + # Phone field: 21 bytes ASCII, zero-padded + phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0") - info = struct.pack("!I", server_flag) # 4 bytes server flag - info += struct.pack("!H", addr_len) # 2 bytes address length - info += addr_bytes # N bytes address - - if phone: - phone_padded = phone if len(phone) % 2 == 0 else phone + "F" - phone_bcd = bytes.fromhex(phone_padded) - info += struct.pack("B", len(phone_bcd)) # 1 byte phone length - info += phone_bcd # N bytes phone BCD - else: - info += struct.pack("B", 0) # 0 phone length + inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator + # 0x17 uses 1-byte cmd_length + cmd_len = min(len(inner), 0xFF) + info = bytes([cmd_len]) + inner return self.build_response(protocol, serial_number, info) def build_address_reply_en( self, serial_number: int, - server_flag: int, - address: str, + server_flag: int = 0, + address: str = "", phone: str = "", protocol: int = PROTO_ADDRESS_REPLY_EN, + is_alarm: bool = False, ) -> bytes: """ Build an English address reply packet (0x97). - Parameters - ---------- - serial_number : int - Packet serial number. - server_flag : int - Server flag bits (32-bit). - address : str - Address string (ASCII/UTF-8 encoded). - phone : str - Phone number string (BCD encoded, even length, padded with 'F'). - protocol : int - Protocol number to respond with (default 0x97). - - Returns - ------- - bytes - Complete packet. + Format: cmd_length(2) + server_flag(4) + ADDRESS/ALARMSMS + && + addr(UTF-8) + && + phone(21) + ## """ + flag_bytes = struct.pack("!I", server_flag) + marker = b"ALARMSMS" if is_alarm else b"ADDRESS" + separator = b"&&" + terminator = b"##" + addr_bytes = address.encode("utf-8") - addr_len = len(addr_bytes) + phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0") - info = struct.pack("!I", server_flag) # 4 bytes server flag - info += struct.pack("!H", addr_len) # 2 bytes address length - info += addr_bytes # N bytes address - - if phone: - phone_padded = phone if len(phone) % 2 == 0 else phone + "F" - phone_bcd = bytes.fromhex(phone_padded) - info += struct.pack("B", len(phone_bcd)) # 1 byte phone length - info += phone_bcd # N bytes phone BCD - else: - info += struct.pack("B", 0) # 0 phone length + inner = flag_bytes + marker + separator + addr_bytes + separator + phone_bytes + terminator + # 0x97 uses 2-byte cmd_length + info = struct.pack("!H", len(inner)) + inner return self.build_response(protocol, serial_number, info) diff --git a/app/protocol/constants.py b/app/protocol/constants.py index e83858a..c680f15 100644 --- a/app/protocol/constants.py +++ b/app/protocol/constants.py @@ -121,7 +121,7 @@ PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({ PROTO_LBS_ADDRESS_REQ, PROTO_ADDRESS_QUERY, PROTO_TIME_SYNC, - PROTO_LBS_MULTI, + # Note: PROTO_LBS_MULTI (0x28) does NOT require response; only 0x2E does PROTO_HEARTBEAT_EXT, PROTO_TIME_SYNC_2, # PROTO_GENERAL_INFO (0x94) does NOT require response per protocol doc diff --git a/app/protocol/parser.py b/app/protocol/parser.py index b6e1014..0b7151f 100644 --- a/app/protocol/parser.py +++ b/app/protocol/parser.py @@ -14,9 +14,13 @@ from typing import Any, Dict, List, Tuple from .constants import ( ALARM_TYPES, + ATTENDANCE_STATUS_MASK, + ATTENDANCE_STATUS_SHIFT, + ATTENDANCE_TYPES, DATA_REPORT_MODES, GSM_SIGNAL_LEVELS, PROTOCOL_NAMES, + VOLTAGE_LEVELS, PROTO_ADDRESS_QUERY, PROTO_ALARM_LBS_4G, PROTO_ALARM_MULTI_FENCE, @@ -272,29 +276,23 @@ class PacketParser: speed = data[offset + 9] course_status = struct.unpack_from("!H", data, offset + 10)[0] - # Decode course/status - is_realtime = bool(course_status & 0x2000) # bit 13 (from MSB: bit 12 if 0-indexed from MSB) - is_gps_positioned = bool(course_status & 0x1000) # bit 12 -> actually bit 11 - is_east = bool(course_status & 0x0800) # bit 11 -> bit 10 - is_north = bool(course_status & 0x0400) # bit 10 -> bit 9 - course = course_status & 0x03FF # lower 10 bits - - # Wait -- the standard mapping for this protocol: - # bit 13 (0x2000): real-time GPS - # bit 12 (0x1000): GPS is positioned - # bit 11 (0x0800): East longitude (0=West) - # bit 10 (0x0400): North latitude (0=South, but spec says 1=South sometimes) - # We'll use the most common convention: bit10=1 means South latitude is *negated*. - # Actually, common convention: bit10 = 0 -> South, bit10 = 1 -> North? No -- - # In most implementations of this protocol family: - # bit 10 (0x0400): 1 = North latitude, 0 = South - # We'll go with that. + # Decode course/status (per protocol doc): + # bit 13 (0x2000): GPS real-time differential positioning + # bit 12 (0x1000): GPS positioned + # bit 11 (0x0800): 0=East, 1=West (东经/西经) + # bit 10 (0x0400): 0=South, 1=North (南纬/北纬) + # bits 9-0: course (0-360) + is_realtime = bool(course_status & 0x2000) + is_gps_positioned = bool(course_status & 0x1000) + is_west = bool(course_status & 0x0800) + is_north = bool(course_status & 0x0400) + course = course_status & 0x03FF latitude = lat_raw / 1_800_000.0 longitude = lon_raw / 1_800_000.0 if not is_north: latitude = -latitude - if not is_east: + if is_west: longitude = -longitude return { @@ -308,7 +306,7 @@ class PacketParser: "course": course, "is_realtime": is_realtime, "is_gps_positioned": is_gps_positioned, - "is_east": is_east, + "is_west": is_west, "is_north": is_north, "course_status_raw": course_status, } @@ -347,7 +345,7 @@ class PacketParser: offset: int = 0, *, lac_size: int = 2, - cell_id_size: int = 2, + cell_id_size: int = 3, ) -> Tuple[Dict[str, Any], int]: """ Parse a single LBS station (LAC + Cell ID + RSSI). @@ -370,6 +368,8 @@ class PacketParser: if cell_id_size == 2: cell_id = struct.unpack_from("!H", data, offset + consumed)[0] + elif cell_id_size == 3: + cell_id = int.from_bytes(data[offset + consumed : offset + consumed + 3], "big") elif cell_id_size == 4: cell_id = struct.unpack_from("!I", data, offset + consumed)[0] else: # 8 @@ -479,8 +479,8 @@ class PacketParser: result["lac"] = struct.unpack_from("!H", info, pos)[0] pos += 2 - result["cell_id"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 + result["cell_id"] = int.from_bytes(info[pos : pos + 3], "big") + pos += 3 # Remaining bytes: phone number (BCD) + alarm_language if pos < len(info): @@ -524,7 +524,7 @@ class PacketParser: return result def _parse_gps_packet(self, info: bytes) -> Dict[str, Any]: - """0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(2) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4).""" + """0x22 GPS: datetime(6) + gps(12) + mcc(2) + mnc(1-2) + lac(2) + cell_id(3) + acc(1) + report_mode(1) + realtime_upload(1) + mileage(4).""" result: Dict[str, Any] = {} pos = 0 @@ -542,9 +542,10 @@ class PacketParser: result["lac"] = struct.unpack_from("!H", info, pos)[0] pos += 2 - if len(info) >= pos + 2: - result["cell_id"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 + # 2G Cell ID is 3 bytes (not 2) + if len(info) >= pos + 3: + result["cell_id"] = int.from_bytes(info[pos:pos + 3], "big") + pos += 3 if len(info) >= pos + 1: result["acc"] = info[pos] @@ -579,7 +580,7 @@ class PacketParser: stations: List[Dict[str, Any]] = [] for i in range(7): # main + 6 neighbors - if len(info) < pos + 5: + if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6 break station, consumed = self.parse_lbs_station(info, pos) station["is_main"] = (i == 0) @@ -614,7 +615,7 @@ class PacketParser: stations: List[Dict[str, Any]] = [] for i in range(7): - if len(info) < pos + 5: + if len(info) < pos + 6: # LAC(2) + CellID(3) + RSSI(1) = 6 break station, consumed = self.parse_lbs_station(info, pos) station["is_main"] = (i == 0) @@ -807,8 +808,44 @@ class PacketParser: return result + @staticmethod + def _parse_alarm_tail(info: bytes, pos: int) -> Tuple[Dict[str, Any], int]: + """Parse common alarm tail: terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1).""" + result: Dict[str, Any] = {} + if len(info) >= pos + 1: + ti = info[pos] + result["terminal_info"] = ti + result["terminal_info_bits"] = { + "oil_electricity_connected": bool(ti & 0x80), + "gps_tracking_on": bool(ti & 0x40), + "alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"), + "charging": bool(ti & 0x04), + "acc_on": bool(ti & 0x02), + "armed": bool(ti & 0x01), + } + pos += 1 + if len(info) >= pos + 1: + voltage_level = info[pos] + result["voltage_level"] = voltage_level + result["voltage_name"] = VOLTAGE_LEVELS.get(voltage_level, "Unknown") + result["battery_level"] = min(voltage_level * 17, 100) if voltage_level <= 6 else None + pos += 1 + if len(info) >= pos + 1: + result["gsm_signal"] = info[pos] + result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown") + pos += 1 + if len(info) >= pos + 1: + alarm_code = info[pos] + result["alarm_code"] = alarm_code + result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}") + pos += 1 + if len(info) >= pos + 1: + result["language"] = info[pos] + pos += 1 + return result, pos + def _parse_alarm_single_fence(self, info: bytes) -> Dict[str, Any]: - """0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage(2) + gsm_signal(1) + alarm_language(2).""" + """0xA3 Single Fence Alarm: datetime(6) + gps(12) + lbs_length(1) + mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1).""" result: Dict[str, Any] = {} pos = 0 @@ -836,45 +873,19 @@ class PacketParser: result["cell_id"] = struct.unpack_from("!Q", info, pos)[0] pos += 8 - if len(info) >= pos + 1: - ti = info[pos] - result["terminal_info"] = ti - result["terminal_info_bits"] = { - "oil_electricity_connected": bool(ti & 0x80), - "gps_tracking_on": bool(ti & 0x40), - "alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"), - "charging": bool(ti & 0x04), - "acc_on": bool(ti & 0x02), - "armed": bool(ti & 0x01), - } - pos += 1 - - if len(info) >= pos + 2: - result["voltage"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 - - if len(info) >= pos + 1: - result["gsm_signal"] = info[pos] - result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown") - pos += 1 - - if len(info) >= pos + 2: - result["alarm_language"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 + tail, pos = self._parse_alarm_tail(info, pos) + result.update(tail) return result def _parse_alarm_lbs_4g(self, info: bytes) -> Dict[str, Any]: - """0xA5 LBS 4G Alarm: similar to 0xA3 but LBS-based.""" + """0xA5 LBS 4G Alarm: NO datetime, NO GPS, NO lbs_length. + + Content starts directly with MCC(2) + MNC(1-2) + LAC(4) + CellID(8) + + terminal_info(1) + voltage_level(1) + gsm_signal(1) + alarm_code(1) + language(1). + """ result: Dict[str, Any] = {} - pos = 0 - - result["datetime"] = self.parse_datetime(info, pos) - pos += 6 - - if len(info) >= pos + 1: - result["lbs_length"] = info[pos] - pos += 1 + pos = 0 # content starts directly with MCC if len(info) >= pos + 3: mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) @@ -889,85 +900,63 @@ class PacketParser: result["cell_id"] = struct.unpack_from("!Q", info, pos)[0] pos += 8 - if len(info) >= pos + 1: - ti = info[pos] - result["terminal_info"] = ti - result["terminal_info_bits"] = { - "oil_electricity_connected": bool(ti & 0x80), - "gps_tracking_on": bool(ti & 0x40), - "alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"), - "charging": bool(ti & 0x04), - "acc_on": bool(ti & 0x02), - "armed": bool(ti & 0x01), - } - pos += 1 - - if len(info) >= pos + 2: - result["voltage"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 - - if len(info) >= pos + 1: - result["gsm_signal"] = info[pos] - result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown") - pos += 1 - - if len(info) >= pos + 2: - result["alarm_language"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 + tail, pos = self._parse_alarm_tail(info, pos) + result.update(tail) return result def _parse_alarm_wifi(self, info: bytes) -> Dict[str, Any]: - """0xA9 WIFI Alarm: datetime + gps + lbs + terminal_info + voltage + gsm + wifi_count + wifi_list + alarm_language.""" + """0xA9 WIFI Alarm: datetime(6) + MCC(2) + MNC(1-2) + cell_type(1) + cell_count(1) + + [cell_stations] + timing_advance(1) + wifi_count(1) + [wifi_list] + alarm_code(1) + language(1). + + No GPS block, no lbs_length. Independent format. + """ result: Dict[str, Any] = {} pos = 0 result["datetime"] = self.parse_datetime(info, pos) pos += 6 - if len(info) >= pos + 12: - result["gps_info"] = self.parse_gps(info, pos) - pos += 12 - - if len(info) >= pos + 1: - result["lbs_length"] = info[pos] - pos += 1 - if len(info) >= pos + 3: mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) result.update(mcc_mnc) pos += consumed - if len(info) >= pos + 4: - result["lac"] = struct.unpack_from("!I", info, pos)[0] - pos += 4 - - if len(info) >= pos + 8: - result["cell_id"] = struct.unpack_from("!Q", info, pos)[0] - pos += 8 - - if len(info) >= pos + 1: - ti = info[pos] - result["terminal_info"] = ti - result["terminal_info_bits"] = { - "oil_electricity_connected": bool(ti & 0x80), - "gps_tracking_on": bool(ti & 0x40), - "alarm": ALARM_TYPES.get((ti >> 3) & 0x07, "Unknown"), - "charging": bool(ti & 0x04), - "acc_on": bool(ti & 0x02), - "armed": bool(ti & 0x01), - } - pos += 1 - + # cell_type(1) + cell_count(1) + cell_type = 0 # 0=2G, 1=4G + cell_count = 0 if len(info) >= pos + 2: - result["voltage"] = struct.unpack_from("!H", info, pos)[0] + cell_type = info[pos] + cell_count = info[pos + 1] + result["cell_type"] = cell_type + result["cell_count"] = cell_count pos += 2 + # Parse cell stations + stations: List[Dict[str, Any]] = [] + for i in range(cell_count): + if cell_type == 1: # 4G: LAC(4) + CI(8) + RSSI(1) = 13 bytes + if len(info) < pos + 13: + break + station, consumed = self.parse_lbs_station(info, pos, lac_size=4, cell_id_size=8) + stations.append(station) + pos += consumed + else: # 2G: LAC(2) + CI(3) + RSSI(1) = 6 bytes + if len(info) < pos + 6: + break + lac_val = struct.unpack_from("!H", info, pos)[0] + ci_val = int.from_bytes(info[pos + 2:pos + 5], "big") + rssi_val = info[pos + 5] + stations.append({"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val}) + pos += 6 + result["stations"] = stations + + # timing_advance(1) if len(info) >= pos + 1: - result["gsm_signal"] = info[pos] - result["gsm_signal_name"] = GSM_SIGNAL_LEVELS.get(info[pos], "Unknown") + result["timing_advance"] = info[pos] pos += 1 + # WiFi APs: wifi_count(1) + [mac(6) + signal(1)]*N if len(info) >= pos + 1: wifi_count = info[pos] result["wifi_count"] = wifi_count @@ -977,39 +966,85 @@ class PacketParser: result["wifi_list"] = wifi_list pos += consumed - if len(info) >= pos + 2: - result["alarm_language"] = struct.unpack_from("!H", info, pos)[0] - pos += 2 + # alarm_code(1) + language(1) + if len(info) >= pos + 1: + alarm_code = info[pos] + result["alarm_code"] = alarm_code + result["alarm_type"] = ALARM_TYPES.get(alarm_code, f"unknown_0x{alarm_code:02X}") + pos += 1 + if len(info) >= pos + 1: + result["language"] = info[pos] + pos += 1 return result def _parse_attendance(self, info: bytes) -> Dict[str, Any]: - """0xB0 Attendance: GPS + WIFI + LBS combined attendance data.""" + """0xB0 Attendance: datetime(6) + gps_positioned(1) + reserved(2) + GPS(12) + + terminal_info(1) + voltage_level(1) + gsm_signal(1) + reserved_ext(2) + + MCC/MNC + 7 stations(LAC2+CI3+RSSI) + TA(1) + wifi_count(1) + wifi_list. + """ result: Dict[str, Any] = {} pos = 0 result["datetime"] = self.parse_datetime(info, pos) pos += 6 - # GPS data + # GPS positioned flag (1 byte) + if len(info) > pos: + result["gps_positioned"] = info[pos] == 1 + pos += 1 + + # Terminal reserved (2 bytes) + if len(info) >= pos + 2: + result["terminal_reserved"] = info[pos:pos + 2] + pos += 2 + + # GPS data (12 bytes) if len(info) >= pos + 12: result["gps_info"] = self.parse_gps(info, pos) pos += 12 - # LBS data + # Terminal info (1 byte) - clock_in/clock_out + if len(info) > pos: + ti = info[pos] + result["terminal_info"] = ti + status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK + result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown") + pos += 1 + + # Voltage level (1 byte) + if len(info) > pos: + vl = info[pos] + result["voltage_level"] = vl + result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None + pos += 1 + + # GSM signal (1 byte) + if len(info) > pos: + result["gsm_signal"] = info[pos] + pos += 1 + + # Reserved extension (2 bytes) + if len(info) >= pos + 2: + pos += 2 + + # LBS: MCC/MNC if len(info) >= pos + 3: mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) result.update(mcc_mnc) pos += consumed + # 7 stations: LAC(2) + CI(3) + RSSI(1) = 6 bytes each for 2G stations: List[Dict[str, Any]] = [] for i in range(7): - if len(info) < pos + 5: + if len(info) < pos + 6: break - station, consumed = self.parse_lbs_station(info, pos) - station["is_main"] = (i == 0) + lac_val = struct.unpack_from("!H", info, pos)[0] + ci_val = int.from_bytes(info[pos + 2:pos + 5], "big") + rssi_val = info[pos + 5] + station = {"lac": lac_val, "cell_id": ci_val, "rssi": rssi_val, "is_main": (i == 0)} stations.append(station) - pos += consumed + pos += 6 result["stations"] = stations @@ -1027,31 +1062,66 @@ class PacketParser: result["wifi_list"] = wifi_list pos += consumed - # Attendance-specific trailing data - if pos < len(info): - result["attendance_data"] = info[pos:] - return result def _parse_attendance_4g(self, info: bytes) -> Dict[str, Any]: - """0xB1 Attendance 4G: 4G version of attendance.""" + """0xB1 Attendance 4G: same layout as 0xB0 but MNC=2B fixed, LAC=4B, CI=8B.""" result: Dict[str, Any] = {} pos = 0 result["datetime"] = self.parse_datetime(info, pos) pos += 6 - # GPS data + # GPS positioned flag (1 byte) + if len(info) > pos: + result["gps_positioned"] = info[pos] == 1 + pos += 1 + + # Terminal reserved (2 bytes) + if len(info) >= pos + 2: + result["terminal_reserved"] = info[pos:pos + 2] + pos += 2 + + # GPS data (12 bytes) if len(info) >= pos + 12: result["gps_info"] = self.parse_gps(info, pos) pos += 12 - # LBS data (4G variant) - if len(info) >= pos + 3: - mcc_mnc, consumed = self.parse_mcc_mnc(info, pos) - result.update(mcc_mnc) - pos += consumed + # Terminal info (1 byte) - clock_in/clock_out + if len(info) > pos: + ti = info[pos] + result["terminal_info"] = ti + status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK + result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "unknown") + pos += 1 + # Voltage level (1 byte) + if len(info) > pos: + vl = info[pos] + result["voltage_level"] = vl + result["battery_level"] = min(vl * 17, 100) if vl <= 6 else None + pos += 1 + + # GSM signal (1 byte) + if len(info) > pos: + result["gsm_signal"] = info[pos] + pos += 1 + + # Reserved extension (2 bytes) + if len(info) >= pos + 2: + pos += 2 + + # 4G LBS: MCC(2, clear high bit) + MNC(2, fixed) + LAC(4) + CI(8) + if len(info) >= pos + 2: + mcc_raw = struct.unpack_from("!H", info, pos)[0] + result["mcc"] = mcc_raw & 0x7FFF + pos += 2 + if len(info) >= pos + 2: + result["mnc"] = struct.unpack_from("!H", info, pos)[0] + result["mnc_2byte"] = True + pos += 2 + + # 7 stations: LAC(4) + CI(8) + RSSI(1) = 13 bytes each stations: List[Dict[str, Any]] = [] for i in range(7): if len(info) < pos + 13: @@ -1079,13 +1149,10 @@ class PacketParser: result["wifi_list"] = wifi_list pos += consumed - if pos < len(info): - result["attendance_data"] = info[pos:] - return result def _parse_bt_punch(self, info: bytes) -> Dict[str, Any]: - """0xB2 BT Punch: bluetooth punch card data.""" + """0xB2 BT Punch: datetime(6) + RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + TerminalInfo(1) + Reserved(2).""" result: Dict[str, Any] = {} pos = 0 @@ -1093,14 +1160,63 @@ class PacketParser: result["datetime"] = self.parse_datetime(info, pos) pos += 6 - # Remaining is BT punch-specific payload - if pos < len(info): - result["bt_data"] = info[pos:] + # RSSI (1 byte, signed) + if len(info) > pos: + result["rssi"] = struct.unpack_from("b", info, pos)[0] + pos += 1 + + # MAC address (6 bytes) + if len(info) >= pos + 6: + result["beacon_mac"] = ":".join(f"{b:02X}" for b in info[pos:pos + 6]) + pos += 6 + + # UUID (16 bytes) + if len(info) >= pos + 16: + uuid_bytes = info[pos:pos + 16] + result["beacon_uuid"] = ( + f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-" + f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-" + f"{uuid_bytes[10:16].hex()}" + ).upper() + pos += 16 + + # Major (2 bytes) + if len(info) >= pos + 2: + result["beacon_major"] = struct.unpack_from("!H", info, pos)[0] + pos += 2 + + # Minor (2 bytes) + if len(info) >= pos + 2: + result["beacon_minor"] = struct.unpack_from("!H", info, pos)[0] + pos += 2 + + # Beacon battery (2 bytes, unit 0.01V) + if len(info) >= pos + 2: + raw_batt = struct.unpack_from("!H", info, pos)[0] + result["beacon_battery"] = raw_batt * 0.01 + result["beacon_battery_unit"] = "V" + pos += 2 + + # Terminal info (1 byte) - clock_in/clock_out + if len(info) > pos: + ti = info[pos] + result["terminal_info"] = ti + status_code = (ti >> ATTENDANCE_STATUS_SHIFT) & ATTENDANCE_STATUS_MASK + result["attendance_type"] = ATTENDANCE_TYPES.get(status_code, "clock_in") + pos += 1 + + # Terminal reserved (2 bytes) + if len(info) >= pos + 2: + result["terminal_reserved"] = info[pos:pos + 2] + pos += 2 return result def _parse_bt_location(self, info: bytes) -> Dict[str, Any]: - """0xB3 BT Location: bluetooth location data.""" + """0xB3 BT Location: datetime(6) + beacon_count(1) + per-beacon(30 bytes each). + + Per beacon: RSSI(1,signed) + MAC(6) + UUID(16) + Major(2) + Minor(2) + Battery(2) + BattUnit(1) = 30 bytes. + """ result: Dict[str, Any] = {} pos = 0 @@ -1108,8 +1224,101 @@ class PacketParser: result["datetime"] = self.parse_datetime(info, pos) pos += 6 - if pos < len(info): - result["bt_data"] = info[pos:] + beacon_count = 0 + if len(info) > pos: + beacon_count = info[pos] + result["beacon_count"] = beacon_count + pos += 1 + + beacons: List[Dict[str, Any]] = [] + for _ in range(beacon_count): + if len(info) < pos + 30: + break + + rssi = struct.unpack_from("b", info, pos)[0] + pos += 1 + + mac = ":".join(f"{b:02X}" for b in info[pos:pos + 6]) + pos += 6 + + uuid_bytes = info[pos:pos + 16] + uuid_str = ( + f"{uuid_bytes[0:4].hex()}-{uuid_bytes[4:6].hex()}-" + f"{uuid_bytes[6:8].hex()}-{uuid_bytes[8:10].hex()}-" + f"{uuid_bytes[10:16].hex()}" + ).upper() + pos += 16 + + major = struct.unpack_from("!H", info, pos)[0] + pos += 2 + minor = struct.unpack_from("!H", info, pos)[0] + pos += 2 + + raw_batt = struct.unpack_from("!H", info, pos)[0] + pos += 2 + + batt_unit_byte = info[pos] + pos += 1 + + if batt_unit_byte == 0: + battery_val = raw_batt * 0.01 + battery_unit = "V" + else: + battery_val = float(raw_batt) + battery_unit = "%" + + beacons.append({ + "rssi": rssi, + "mac": mac, + "uuid": uuid_str, + "major": major, + "minor": minor, + "battery": battery_val, + "battery_unit": battery_unit, + }) + + result["beacons"] = beacons + + return result + + def _parse_alarm_multi_fence(self, info: bytes) -> Dict[str, Any]: + """0xA4 Multi Fence Alarm: same as 0xA3 + fence_id(1) at the end.""" + result = self._parse_alarm_single_fence(info) + # After the standard alarm fields, 0xA4 has an extra fence_id byte + # We need to re-parse to find the fence_id position + # The simplest approach: fence_id is the last unparsed byte + # Since _parse_alarm_single_fence consumed up to language(1), + # the fence_id follows it. Calculate position from the end. + # Format ends with: ...alarm_code(1) + language(1) + fence_id(1) + if len(info) > 0: + result["fence_id"] = info[-1] + return result + + def _parse_online_cmd_reply(self, info: bytes) -> Dict[str, Any]: + """0x81 Online Command Reply: length(1) + server_flag(4) + content(N) + language(2).""" + result: Dict[str, Any] = {} + if len(info) < 1: + return result + + result["cmd_length"] = info[0] + pos = 1 + + if len(info) >= pos + 4: + result["server_flag"] = struct.unpack_from("!I", info, pos)[0] + pos += 4 + + # Content is between server_flag and language(2 bytes at end) + if len(info) > pos + 2: + try: + result["response_content"] = info[pos:-2].decode("utf-8", errors="replace") + except Exception: + result["response_content"] = info[pos:-2].hex() + result["language"] = struct.unpack_from("!H", info, len(info) - 2)[0] + elif len(info) > pos: + try: + result["response_content"] = info[pos:].decode("utf-8", errors="replace") + except Exception: + result["response_content"] = info[pos:].hex() return result @@ -1133,8 +1342,10 @@ class PacketParser: PROTO_LBS_4G: _parse_lbs_4g, PROTO_WIFI_4G: _parse_wifi_4g, PROTO_ALARM_SINGLE_FENCE: _parse_alarm_single_fence, + PROTO_ALARM_MULTI_FENCE: _parse_alarm_multi_fence, PROTO_ALARM_LBS_4G: _parse_alarm_lbs_4g, PROTO_ALARM_WIFI: _parse_alarm_wifi, + PROTO_ONLINE_CMD_REPLY: _parse_online_cmd_reply, PROTO_ATTENDANCE: _parse_attendance, PROTO_ATTENDANCE_4G: _parse_attendance_4g, PROTO_BT_PUNCH: _parse_bt_punch, diff --git a/app/routers/alarms.py b/app/routers/alarms.py index e2dcefc..bbbc921 100644 --- a/app/routers/alarms.py +++ b/app/routers/alarms.py @@ -5,11 +5,14 @@ API endpoints for alarm record queries, acknowledgement, and statistics. import math from datetime import datetime, timezone +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies import require_write + from app.database import get_db from app.models import AlarmRecord from app.schemas import ( @@ -30,16 +33,18 @@ router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"]) async def list_alarms( device_id: int | None = Query(default=None, description="设备ID / Device ID"), alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"), + alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"), acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"), end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"), + sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), db: AsyncSession = Depends(get_db), ): """ - 获取报警记录列表,支持按设备、报警类型、确认状态、时间范围过滤。 - List alarm records with filters for device, alarm type, acknowledged status, and time range. + 获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。 + List alarm records with filters for device, alarm type, source, acknowledged status, and time range. """ query = select(AlarmRecord) count_query = select(func.count(AlarmRecord.id)) @@ -52,6 +57,10 @@ async def list_alarms( query = query.where(AlarmRecord.alarm_type == alarm_type) count_query = count_query.where(AlarmRecord.alarm_type == alarm_type) + if alarm_source: + query = query.where(AlarmRecord.alarm_source == alarm_source) + count_query = count_query.where(AlarmRecord.alarm_source == alarm_source) + if acknowledged is not None: query = query.where(AlarmRecord.acknowledged == acknowledged) count_query = count_query.where(AlarmRecord.acknowledged == acknowledged) @@ -68,7 +77,8 @@ async def list_alarms( total = total_result.scalar() or 0 offset = (page - 1) * page_size - query = query.order_by(AlarmRecord.recorded_at.desc()).offset(offset).limit(page_size) + order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc() + query = query.order_by(order).offset(offset).limit(page_size) result = await db.execute(query) alarms = list(result.scalars().all()) @@ -144,6 +154,7 @@ async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)): "/{alarm_id}/acknowledge", response_model=APIResponse[AlarmRecordResponse], summary="确认报警 / Acknowledge alarm", + dependencies=[Depends(require_write)], ) async def acknowledge_alarm( alarm_id: int, diff --git a/app/routers/api_keys.py b/app/routers/api_keys.py new file mode 100644 index 0000000..d900cea --- /dev/null +++ b/app/routers/api_keys.py @@ -0,0 +1,142 @@ +""" +API Keys Router - API密钥管理接口 +Endpoints for creating, listing, updating, and deactivating API keys. +Admin permission required. +""" + +import secrets + +from fastapi import APIRouter, Depends, HTTPException, Query +from sqlalchemy import func, select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.database import get_db +from app.dependencies import require_admin, _hash_key +from app.models import ApiKey +from app.schemas import ( + APIResponse, + ApiKeyCreate, + ApiKeyCreateResponse, + ApiKeyResponse, + ApiKeyUpdate, + PaginatedList, +) + +import math + +router = APIRouter(prefix="/api/keys", tags=["API Keys / 密钥管理"]) + + +def _generate_key() -> str: + """Generate a random 32-char hex API key.""" + return secrets.token_hex(16) + + +@router.get( + "", + response_model=APIResponse[PaginatedList[ApiKeyResponse]], + summary="列出API密钥 / List API keys", + dependencies=[Depends(require_admin)], +) +async def list_keys( + page: int = Query(default=1, ge=1), + page_size: int = Query(default=20, ge=1, le=100), + db: AsyncSession = Depends(get_db), +): + """列出所有API密钥(不返回密钥值)/ List all API keys (key values are never shown).""" + count_result = await db.execute(select(func.count(ApiKey.id))) + total = count_result.scalar() or 0 + + offset = (page - 1) * page_size + result = await db.execute( + select(ApiKey).order_by(ApiKey.created_at.desc()).offset(offset).limit(page_size) + ) + keys = list(result.scalars().all()) + + return APIResponse( + data=PaginatedList( + items=[ApiKeyResponse.model_validate(k) for k in keys], + total=total, + page=page, + page_size=page_size, + total_pages=math.ceil(total / page_size) if total else 0, + ) + ) + + +@router.post( + "", + response_model=APIResponse[ApiKeyCreateResponse], + status_code=201, + summary="创建API密钥 / Create API key", + dependencies=[Depends(require_admin)], +) +async def create_key(body: ApiKeyCreate, db: AsyncSession = Depends(get_db)): + """创建新的API密钥。明文密钥仅在创建时返回一次。 + Create a new API key. The plaintext key is returned only once.""" + raw_key = _generate_key() + key_hash = _hash_key(raw_key) + + db_key = ApiKey( + key_hash=key_hash, + name=body.name, + permissions=body.permissions, + ) + db.add(db_key) + await db.flush() + await db.refresh(db_key) + + # Build response with plaintext key included (shown once) + base_data = ApiKeyResponse.model_validate(db_key).model_dump() + base_data["key"] = raw_key + response_data = ApiKeyCreateResponse(**base_data) + + return APIResponse( + message="API key created. Store the key securely — it won't be shown again. / API密钥已创建,请妥善保管", + data=response_data, + ) + + +@router.put( + "/{key_id}", + response_model=APIResponse[ApiKeyResponse], + summary="更新API密钥 / Update API key", + dependencies=[Depends(require_admin)], +) +async def update_key( + key_id: int, body: ApiKeyUpdate, db: AsyncSession = Depends(get_db) +): + """更新API密钥的名称、权限或激活状态 / Update key name, permissions, or active status.""" + result = await db.execute(select(ApiKey).where(ApiKey.id == key_id)) + db_key = result.scalar_one_or_none() + if db_key is None: + raise HTTPException(status_code=404, detail="API key not found / 未找到密钥") + + update_data = body.model_dump(exclude_unset=True) + for field, value in update_data.items(): + setattr(db_key, field, value) + + await db.flush() + await db.refresh(db_key) + return APIResponse( + message="API key updated / 密钥已更新", + data=ApiKeyResponse.model_validate(db_key), + ) + + +@router.delete( + "/{key_id}", + response_model=APIResponse, + summary="停用API密钥 / Deactivate API key", + dependencies=[Depends(require_admin)], +) +async def deactivate_key(key_id: int, db: AsyncSession = Depends(get_db)): + """停用API密钥(软删除)/ Deactivate an API key (soft delete).""" + result = await db.execute(select(ApiKey).where(ApiKey.id == key_id)) + db_key = result.scalar_one_or_none() + if db_key is None: + raise HTTPException(status_code=404, detail="API key not found / 未找到密钥") + + db_key.is_active = False + await db.flush() + return APIResponse(message="API key deactivated / 密钥已停用") diff --git a/app/routers/beacons.py b/app/routers/beacons.py index 6273292..f21754f 100644 --- a/app/routers/beacons.py +++ b/app/routers/beacons.py @@ -8,6 +8,8 @@ import math from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy.ext.asyncio import AsyncSession +from app.dependencies import require_write + from app.database import get_db from app.schemas import ( APIResponse, @@ -64,6 +66,7 @@ async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)): response_model=APIResponse[BeaconConfigResponse], status_code=201, summary="添加信标 / Create beacon", + dependencies=[Depends(require_write)], ) async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get_db)): existing = await beacon_service.get_beacon_by_mac(db, body.beacon_mac) @@ -77,6 +80,7 @@ async def create_beacon(body: BeaconConfigCreate, db: AsyncSession = Depends(get "/{beacon_id}", response_model=APIResponse[BeaconConfigResponse], summary="更新信标 / Update beacon", + dependencies=[Depends(require_write)], ) async def update_beacon( beacon_id: int, body: BeaconConfigUpdate, db: AsyncSession = Depends(get_db) @@ -91,6 +95,7 @@ async def update_beacon( "/{beacon_id}", response_model=APIResponse, summary="删除信标 / Delete beacon", + dependencies=[Depends(require_write)], ) async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)): success = await beacon_service.delete_beacon(db, beacon_id) diff --git a/app/routers/bluetooth.py b/app/routers/bluetooth.py index 509598b..62d5430 100644 --- a/app/routers/bluetooth.py +++ b/app/routers/bluetooth.py @@ -5,6 +5,7 @@ API endpoints for querying Bluetooth punch and location records. import math from datetime import datetime +from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query from sqlalchemy import func, select @@ -30,15 +31,17 @@ router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"]) async def list_bluetooth_records( device_id: int | None = Query(default=None, description="设备ID / Device ID"), record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"), + beacon_mac: str | None = Query(default=None, description="信标MAC / Beacon MAC filter"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"), end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"), + sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), db: AsyncSession = Depends(get_db), ): """ - 获取蓝牙数据记录列表,支持按设备、记录类型、时间范围过滤。 - List Bluetooth records with filters for device, record type, and time range. + 获取蓝牙数据记录列表,支持按设备、记录类型、信标MAC、时间范围过滤。 + List Bluetooth records with filters for device, record type, beacon MAC, and time range. """ query = select(BluetoothRecord) count_query = select(func.count(BluetoothRecord.id)) @@ -51,6 +54,10 @@ async def list_bluetooth_records( query = query.where(BluetoothRecord.record_type == record_type) count_query = count_query.where(BluetoothRecord.record_type == record_type) + if beacon_mac: + query = query.where(BluetoothRecord.beacon_mac == beacon_mac) + count_query = count_query.where(BluetoothRecord.beacon_mac == beacon_mac) + if start_time: query = query.where(BluetoothRecord.recorded_at >= start_time) count_query = count_query.where(BluetoothRecord.recorded_at >= start_time) @@ -63,7 +70,8 @@ async def list_bluetooth_records( total = total_result.scalar() or 0 offset = (page - 1) * page_size - query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size) + order = BluetoothRecord.recorded_at.asc() if sort_order == "asc" else BluetoothRecord.recorded_at.desc() + query = query.order_by(order).offset(offset).limit(page_size) result = await db.execute(query) records = list(result.scalars().all()) diff --git a/app/routers/commands.py b/app/routers/commands.py index fb910ef..c61df1f 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -5,6 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his import logging import math +from datetime import datetime, timezone from fastapi import APIRouter, Depends, HTTPException, Query, Request from pydantic import BaseModel, Field @@ -21,6 +22,7 @@ from app.schemas import ( CommandResponse, PaginatedList, ) +from app.dependencies import require_write from app.services import command_service, device_service from app.services import tcp_command_service @@ -127,6 +129,7 @@ async def _send_to_device( ) command_log.status = "sent" + command_log.sent_at = datetime.now(timezone.utc) await db.flush() await db.refresh(command_log) @@ -148,17 +151,18 @@ async def _send_to_device( ) async def list_commands( device_id: int | None = Query(default=None, description="设备ID / Device ID"), + command_type: str | None = Query(default=None, description="指令类型 / Command type (online_cmd/message/tts)"), status: str | None = Query(default=None, description="指令状态 / Command status (pending/sent/success/failed)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), db: AsyncSession = Depends(get_db), ): """ - 获取指令历史记录,支持按设备和状态过滤。 - List command history with optional device and status filters. + 获取指令历史记录,支持按设备、指令类型和状态过滤。 + List command history with optional device, command type and status filters. """ commands, total = await command_service.get_commands( - db, device_id=device_id, status=status, page=page, page_size=page_size + db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size ) return APIResponse( data=PaginatedList( @@ -176,6 +180,7 @@ async def list_commands( response_model=APIResponse[CommandResponse], status_code=201, summary="发送指令 / Send command to device", + dependencies=[Depends(require_write)], ) async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_db)): """ @@ -201,6 +206,7 @@ async def send_command(body: SendCommandRequest, db: AsyncSession = Depends(get_ response_model=APIResponse[CommandResponse], status_code=201, summary="发送留言 / Send message to device (0x82)", + dependencies=[Depends(require_write)], ) async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_db)): """ @@ -223,6 +229,7 @@ async def send_message(body: SendMessageRequest, db: AsyncSession = Depends(get_ response_model=APIResponse[CommandResponse], status_code=201, summary="语音下发 / Send TTS voice broadcast to device", + dependencies=[Depends(require_write)], ) async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)): """ @@ -249,6 +256,7 @@ async def send_tts(body: SendTTSRequest, db: AsyncSession = Depends(get_db)): response_model=APIResponse[BatchCommandResponse], status_code=201, summary="批量发送指令 / Batch send command to multiple devices", + dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def batch_send_command(request: Request, body: BatchCommandRequest, db: AsyncSession = Depends(get_db)): @@ -282,6 +290,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As device.imei, body.command_type, body.command_content ) cmd_log.status = "sent" + cmd_log.sent_at = datetime.now(timezone.utc) await db.flush() await db.refresh(cmd_log) results.append(BatchCommandResult( diff --git a/app/routers/devices.py b/app/routers/devices.py index 19e97a8..84b8eba 100644 --- a/app/routers/devices.py +++ b/app/routers/devices.py @@ -22,8 +22,10 @@ from app.schemas import ( PaginatedList, ) from app.config import settings +from app.dependencies import require_write from app.extensions import limiter -from app.services import device_service +from app.schemas import LocationRecordResponse +from app.services import device_service, location_service router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"]) @@ -88,11 +90,26 @@ async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)): return APIResponse(data=DeviceResponse.model_validate(device)) +@router.get( + "/all-latest-locations", + response_model=APIResponse[list[LocationRecordResponse]], + summary="获取所有在线设备位置 / Get all online device locations", +) +async def all_latest_locations(db: AsyncSession = Depends(get_db)): + """ + 获取所有在线设备的最新位置,用于地图总览。 + Get latest location for all online devices, for map overview. + """ + records = await location_service.get_all_online_latest_locations(db) + return APIResponse(data=[LocationRecordResponse.model_validate(r) for r in records]) + + @router.post( "/batch", response_model=APIResponse[BatchDeviceCreateResponse], status_code=201, summary="批量创建设备 / Batch create devices", + dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, db: AsyncSession = Depends(get_db)): @@ -118,6 +135,7 @@ async def batch_create_devices(request: Request, body: BatchDeviceCreateRequest, "/batch", response_model=APIResponse[dict], summary="批量更新设备 / Batch update devices", + dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)): @@ -138,6 +156,7 @@ async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, "/batch-delete", response_model=APIResponse[dict], summary="批量删除设备 / Batch delete devices", + dependencies=[Depends(require_write)], ) @limiter.limit(settings.RATE_LIMIT_WRITE) async def batch_delete_devices( @@ -179,6 +198,7 @@ async def get_device(device_id: int, db: AsyncSession = Depends(get_db)): response_model=APIResponse[DeviceResponse], status_code=201, summary="创建设备 / Create device", + dependencies=[Depends(require_write)], ) async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(get_db)): """ @@ -201,6 +221,7 @@ async def create_device(device_data: DeviceCreate, db: AsyncSession = Depends(ge "/{device_id}", response_model=APIResponse[DeviceResponse], summary="更新设备信息 / Update device", + dependencies=[Depends(require_write)], ) async def update_device( device_id: int, device_data: DeviceUpdate, db: AsyncSession = Depends(get_db) @@ -219,6 +240,7 @@ async def update_device( "/{device_id}", response_model=APIResponse, summary="删除设备 / Delete device", + dependencies=[Depends(require_write)], ) async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)): """ diff --git a/app/routers/geocoding.py b/app/routers/geocoding.py new file mode 100644 index 0000000..e9d8e3c --- /dev/null +++ b/app/routers/geocoding.py @@ -0,0 +1,55 @@ +"""Geocoding proxy endpoints — keeps AMAP_KEY server-side.""" + +from fastapi import APIRouter, Query +import httpx + +from app.config import settings +from app.geocoding import reverse_geocode, _amap_sign, AMAP_KEY + +router = APIRouter(prefix="/api/geocode", tags=["geocoding"]) + + +@router.get("/search") +async def search_location( + keyword: str = Query(..., min_length=1, max_length=100), + city: str = Query(default="", max_length=50), +): + """Proxy for Amap POI text search. Returns GCJ-02 coordinates.""" + if not AMAP_KEY: + return {"results": []} + params = { + "key": AMAP_KEY, + "keywords": keyword, + "output": "json", + "offset": "10", + "page": "1", + } + if city: + params["city"] = city + sig = _amap_sign(params) + if sig: + params["sig"] = sig + async with httpx.AsyncClient(timeout=5.0) as client: + resp = await client.get("https://restapi.amap.com/v3/place/text", params=params) + data = resp.json() + if data.get("status") != "1": + return {"results": []} + results = [] + for poi in data.get("pois", [])[:10]: + if poi.get("location"): + results.append({ + "name": poi.get("name", ""), + "address": poi.get("address", ""), + "location": poi["location"], # "lng,lat" in GCJ-02 + }) + return {"results": results} + + +@router.get("/reverse") +async def reverse_geocode_endpoint( + lat: float = Query(..., ge=-90, le=90), + lon: float = Query(..., ge=-180, le=180), +): + """Reverse geocode WGS-84 coords to address via Amap.""" + address = await reverse_geocode(lat, lon) + return {"address": address or ""} diff --git a/app/routers/locations.py b/app/routers/locations.py index bd01f95..45543fd 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -6,7 +6,7 @@ API endpoints for querying location records and device tracks. import math from datetime import datetime -from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -85,6 +85,27 @@ async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)): return APIResponse(data=LocationRecordResponse.model_validate(record)) +@router.post( + "/batch-latest", + response_model=APIResponse[list[LocationRecordResponse | None]], + summary="批量获取设备最新位置 / Batch get latest locations", +) +async def batch_latest_locations( + device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True), + db: AsyncSession = Depends(get_db), +): + """ + 传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。 + Pass device_ids list, returns latest location per device in input order. + """ + records = await location_service.get_batch_latest_locations(db, device_ids) + result_map = {r.device_id: r for r in records} + return APIResponse(data=[ + LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None + for did in device_ids + ]) + + @router.get( "/track/{device_id}", response_model=APIResponse[list[LocationRecordResponse]], diff --git a/app/routers/ws.py b/app/routers/ws.py new file mode 100644 index 0000000..c8bc7c5 --- /dev/null +++ b/app/routers/ws.py @@ -0,0 +1,81 @@ +""" +WebSocket Router - WebSocket 实时推送接口 +Real-time data push via WebSocket with topic subscriptions. +""" + +import logging +import secrets + +from fastapi import APIRouter, Query, WebSocket, WebSocketDisconnect + +from app.config import settings +from app.websocket_manager import ws_manager, VALID_TOPICS + +logger = logging.getLogger(__name__) + +router = APIRouter(tags=["WebSocket / 实时推送"]) + + +@router.websocket("/ws") +async def websocket_endpoint( + websocket: WebSocket, + api_key: str | None = Query(default=None, alias="api_key"), + topics: str | None = Query(default=None, description="Comma-separated topics"), +): + """ + WebSocket endpoint for real-time data push. + + Connect: ws://host/ws?api_key=xxx&topics=location,alarm + Topics: location, alarm, device_status, attendance, bluetooth + If no topics specified, subscribes to all. + """ + # Authenticate + if settings.API_KEY is not None: + if api_key is None or not secrets.compare_digest(api_key, settings.API_KEY): + # For DB keys, do a simple hash check + if api_key is not None: + from app.dependencies import _hash_key + from app.database import async_session + from sqlalchemy import select + from app.models import ApiKey + + try: + async with async_session() as session: + key_hash = _hash_key(api_key) + result = await session.execute( + select(ApiKey.id).where( + ApiKey.key_hash == key_hash, + ApiKey.is_active == True, # noqa: E712 + ) + ) + if result.scalar_one_or_none() is None: + await websocket.close(code=4001, reason="Invalid API key") + return + except Exception: + await websocket.close(code=4001, reason="Auth error") + return + else: + await websocket.close(code=4001, reason="Missing API key") + return + + # Parse topics + requested_topics = set() + if topics: + requested_topics = {t.strip() for t in topics.split(",") if t.strip() in VALID_TOPICS} + + if not await ws_manager.connect(websocket, requested_topics): + return + + try: + # Keep connection alive, handle pings + while True: + data = await websocket.receive_text() + # Client can send "ping" to keep alive + if data.strip().lower() == "ping": + await websocket.send_text("pong") + except WebSocketDisconnect: + pass + except Exception: + logger.debug("WebSocket connection error", exc_info=True) + finally: + ws_manager.disconnect(websocket) diff --git a/app/schemas.py b/app/schemas.py index 0fa6c0d..7f7f58b 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -55,7 +55,6 @@ class DeviceCreate(DeviceBase): class DeviceUpdate(BaseModel): name: str | None = Field(None, max_length=100) - status: Literal["online", "offline"] | None = Field(None, description="Device status") iccid: str | None = Field(None, max_length=30) imsi: str | None = Field(None, max_length=20) timezone: str | None = Field(None, max_length=30) @@ -144,7 +143,7 @@ class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]): class AlarmRecordBase(BaseModel): device_id: int alarm_type: str = Field(..., max_length=30) - alarm_source: str | None = Field(None, max_length=10) + alarm_source: str | None = Field(None, max_length=20) protocol_number: int latitude: float | None = Field(None, ge=-90, le=90) longitude: float | None = Field(None, ge=-180, le=180) @@ -320,7 +319,7 @@ class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]]) class BeaconConfigBase(BaseModel): beacon_mac: str = Field(..., max_length=20, pattern=r"^([0-9A-Fa-f]{2}:){5}[0-9A-Fa-f]{2}$", description="信标MAC地址 (AA:BB:CC:DD:EE:FF)") - beacon_uuid: str | None = Field(None, max_length=36, pattern=r"^[0-9A-Fa-f]{8}-([0-9A-Fa-f]{4}-){3}[0-9A-Fa-f]{12}$", description="iBeacon UUID") + beacon_uuid: str | None = Field(None, max_length=36, description="iBeacon UUID") beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major") beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor") name: str = Field(..., max_length=100, description="信标名称") @@ -337,7 +336,7 @@ class BeaconConfigCreate(BeaconConfigBase): class BeaconConfigUpdate(BaseModel): - 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_uuid: str | None = Field(None, max_length=36) beacon_major: int | None = Field(None, ge=0, le=65535) beacon_minor: int | None = Field(None, ge=0, le=65535) name: str | None = Field(None, max_length=100) @@ -482,3 +481,35 @@ class CommandResponse(BaseModel): class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]): pass + + +# --------------------------------------------------------------------------- +# API Key schemas +# --------------------------------------------------------------------------- + + +class ApiKeyCreate(BaseModel): + name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称") + permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level") + + +class ApiKeyUpdate(BaseModel): + name: str | None = Field(None, max_length=100) + permissions: Literal["read", "write", "admin"] | None = None + is_active: bool | None = None + + +class ApiKeyResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + permissions: str + is_active: bool + last_used_at: datetime | None = None + created_at: datetime + + +class ApiKeyCreateResponse(ApiKeyResponse): + """Returned only on creation — includes the plaintext key (shown once).""" + key: str diff --git a/app/services/command_service.py b/app/services/command_service.py index 1d64c67..061f89e 100644 --- a/app/services/command_service.py +++ b/app/services/command_service.py @@ -14,30 +14,13 @@ from app.models import CommandLog async def get_commands( db: AsyncSession, device_id: int | None = None, + command_type: str | None = None, status: str | None = None, page: int = 1, page_size: int = 20, ) -> tuple[list[CommandLog], int]: """ 获取指令列表(分页)/ Get paginated command logs. - - Parameters - ---------- - db : AsyncSession - Database session. - device_id : int, optional - Filter by device ID. - status : str, optional - Filter by command status (pending, sent, success, failed). - page : int - Page number (1-indexed). - page_size : int - Number of items per page. - - Returns - ------- - tuple[list[CommandLog], int] - (list of command logs, total count) """ query = select(CommandLog) count_query = select(func.count(CommandLog.id)) @@ -46,6 +29,10 @@ async def get_commands( query = query.where(CommandLog.device_id == device_id) count_query = count_query.where(CommandLog.device_id == device_id) + if command_type: + query = query.where(CommandLog.command_type == command_type) + count_query = count_query.where(CommandLog.command_type == command_type) + if status: query = query.where(CommandLog.status == status) count_query = count_query.where(CommandLog.status == status) diff --git a/app/services/location_service.py b/app/services/location_service.py index b50ef0c..92f5eeb 100644 --- a/app/services/location_service.py +++ b/app/services/location_service.py @@ -101,6 +101,49 @@ async def get_latest_location( return result.scalar_one_or_none() +async def get_batch_latest_locations( + db: AsyncSession, device_ids: list[int] +) -> list[LocationRecord]: + """ + 批量获取多设备最新位置 / Get the most recent location for each device in the list. + + Uses a subquery with MAX(id) GROUP BY device_id for efficiency. + """ + if not device_ids: + return [] + + # Subquery: max id per device_id + subq = ( + select(func.max(LocationRecord.id).label("max_id")) + .where(LocationRecord.device_id.in_(device_ids)) + .group_by(LocationRecord.device_id) + .subquery() + ) + result = await db.execute( + select(LocationRecord).where(LocationRecord.id.in_(select(subq.c.max_id))) + ) + return list(result.scalars().all()) + + +async def get_all_online_latest_locations( + db: AsyncSession, +) -> list[LocationRecord]: + """ + 获取所有在线设备的最新位置 / Get latest location for all online devices. + """ + from app.models import Device + + # Get online device IDs + online_result = await db.execute( + select(Device.id).where(Device.status == "online") + ) + online_ids = [row[0] for row in online_result.all()] + if not online_ids: + return [] + + return await get_batch_latest_locations(db, online_ids) + + async def get_device_track( db: AsyncSession, device_id: int, diff --git a/app/static/admin.html b/app/static/admin.html index 7b9c96f..6befb75 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -28,6 +28,9 @@ .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: 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; } + .beacon-search-item:hover { background: #1e3a5f; } + #addBeaconMapDiv .leaflet-pane, #editBeaconMapDiv .leaflet-pane { z-index: 0 !important; } + #addBeaconMapDiv .leaflet-control, #editBeaconMapDiv .leaflet-control { z-index: 1 !important; } .toast-container { position: fixed; top: 20px; right: 20px; z-index: 1100; display: flex; flex-direction: column; gap: 8px; } .toast { padding: 12px 20px; border-radius: 8px; color: white; font-size: 14px; animation: slideIn 0.3s ease; min-width: 250px; display: flex; align-items: center; gap: 8px; } .toast.success { background: #059669; } @@ -938,7 +941,9 @@ }); const data = await response.json(); if (!response.ok) { - throw new Error(data.message || data.detail || `HTTP ${response.status}`); + const detail = data.detail; + const msg = data.message || (typeof detail === 'string' ? detail : Array.isArray(detail) ? detail.map(e => e.msg || JSON.stringify(e)).join('; ') : null) || `HTTP ${response.status}`; + throw new Error(msg); } if (data.code !== undefined && data.code !== 0) { throw new Error(data.message || '请求失败'); @@ -1082,6 +1087,7 @@ } function closeModal() { + if (typeof destroyBeaconPickerMap === 'function') destroyBeaconPickerMap(); document.getElementById('modalContainer').innerHTML = ''; } @@ -1775,6 +1781,19 @@ return [lat + dLat, lng + dLng]; } + function gcj02ToWgs84(gcjLat, gcjLng) { + if (_outOfChina(gcjLat, gcjLng)) return [gcjLat, gcjLng]; + let dLat = _transformLat(gcjLng - 105.0, gcjLat - 35.0); + let dLng = _transformLng(gcjLng - 105.0, gcjLat - 35.0); + const radLat = gcjLat / 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 [gcjLat - dLat, gcjLng - dLng]; + } + // Convert WGS-84 coords for current map provider function toMapCoord(lat, lng) { if (MAP_PROVIDER === 'gaode') return wgs84ToGcj02(lat, lng); @@ -1910,6 +1929,8 @@ mapMarkers.push(marker); locationMap.setView([mLat, mLng], 15); showToast('已显示最新位置'); + // Auto-load records table + loadLocationRecords(1); } catch (err) { showToast('获取最新位置失败: ' + err.message, 'error'); } @@ -2225,30 +2246,127 @@ } } + // ---- Beacon map picker ---- + let _beaconPickerMap = null; + let _beaconPickerMarker = null; + let _beaconSearchTimeout = null; + + function initBeaconPickerMap(mapDivId, latInputId, lonInputId, addrInputId, initLat, initLon) { + setTimeout(() => { + const defaultCenter = [30.605, 103.936]; + const hasInit = initLat && initLon; + const wgsCenter = hasInit ? [initLat, initLon] : defaultCenter; + const [mLat, mLng] = toMapCoord(wgsCenter[0], wgsCenter[1]); + const zoom = hasInit ? 16 : 12; + + _beaconPickerMap = L.map(mapDivId, {zoomControl: true}).setView([mLat, mLng], zoom); + L.tileLayer('https://webrd0{s}.is.autonavi.com/appmaptile?lang=zh_cn&size=1&scale=2&style=8&x={x}&y={y}&z={z}', { + subdomains: '1234', maxZoom: 18, + attribution: '© 高德地图', + }).addTo(_beaconPickerMap); + + if (hasInit) { + _beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap); + } + + _beaconPickerMap.on('click', async (e) => { + const gcjLat = e.latlng.lat, gcjLng = e.latlng.lng; + const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng); + document.getElementById(latInputId).value = wgsLat.toFixed(6); + document.getElementById(lonInputId).value = wgsLng.toFixed(6); + if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]); + else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap); + try { + const res = await apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`); + if (res.address) document.getElementById(addrInputId).value = res.address; + } catch (_) {} + }); + + const syncMarker = () => { + const lat = parseFloat(document.getElementById(latInputId).value); + const lon = parseFloat(document.getElementById(lonInputId).value); + if (!isNaN(lat) && !isNaN(lon) && lat >= -90 && lat <= 90 && lon >= -180 && lon <= 180) { + const [mLat, mLng] = toMapCoord(lat, lon); + if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([mLat, mLng]); + else _beaconPickerMarker = L.marker([mLat, mLng]).addTo(_beaconPickerMap); + _beaconPickerMap.setView([mLat, mLng], 16); + } + }; + document.getElementById(latInputId).addEventListener('change', syncMarker); + document.getElementById(lonInputId).addEventListener('change', syncMarker); + }, 150); + } + + async function searchBeaconLocation(query, resultsId, latInputId, lonInputId, addrInputId) { + if (_beaconSearchTimeout) clearTimeout(_beaconSearchTimeout); + const container = document.getElementById(resultsId); + if (!query || query.length < 2) { container.innerHTML = ''; return; } + _beaconSearchTimeout = setTimeout(async () => { + try { + const res = await apiCall(`${API_BASE}/geocode/search?keyword=${encodeURIComponent(query)}`); + if (!res.results || !res.results.length) { + container.innerHTML = '
无搜索结果
'; + return; + } + container.innerHTML = res.results.map(r => { + const [lng, lat] = r.location.split(',').map(Number); + return `
+
${escapeHtml(r.name)}
+
${escapeHtml(r.address || '')}
+
`; + }).join(''); + } catch (_) { container.innerHTML = ''; } + }, 400); + } + + function selectBeaconSearchResult(gcjLat, gcjLng, latInputId, lonInputId, addrInputId, resultsId) { + const [wgsLat, wgsLng] = gcj02ToWgs84(gcjLat, gcjLng); + document.getElementById(latInputId).value = wgsLat.toFixed(6); + document.getElementById(lonInputId).value = wgsLng.toFixed(6); + document.getElementById(resultsId).innerHTML = ''; + if (_beaconPickerMap) { + if (_beaconPickerMarker) _beaconPickerMarker.setLatLng([gcjLat, gcjLng]); + else _beaconPickerMarker = L.marker([gcjLat, gcjLng]).addTo(_beaconPickerMap); + _beaconPickerMap.setView([gcjLat, gcjLng], 16); + } + apiCall(`${API_BASE}/geocode/reverse?lat=${wgsLat.toFixed(6)}&lon=${wgsLng.toFixed(6)}`) + .then(res => { if (res.address) document.getElementById(addrInputId).value = res.address; }) + .catch(() => {}); + } + + function destroyBeaconPickerMap() { + if (_beaconPickerMap) { _beaconPickerMap.remove(); _beaconPickerMap = null; } + _beaconPickerMarker = null; + } + function showAddBeaconModal() { showModal(`

添加蓝牙信标

信标的蓝牙 MAC 地址,冒号分隔大写十六进制

信标安装位置的描述性名称

-
+

iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据

-
-
+

iBeacon Major 值,区分信标组

+

iBeacon Minor 值,区分组内信标

-
-
-
+

位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标

+
+ +
+
+

点击地图选择位置,或手动输入坐标

-
+

点击地图或搜索位置后自动填充

`); + initBeaconPickerMap('addBeaconMapDiv', 'addBeaconLat', 'addBeaconLon', 'addBeaconAddress', null, null); } async function submitAddBeacon() { @@ -2262,8 +2380,6 @@ const uuid = document.getElementById('addBeaconUuid').value.trim(); const major = document.getElementById('addBeaconMajor').value; const minor = document.getElementById('addBeaconMinor').value; - const floor = document.getElementById('addBeaconFloor').value.trim(); - const area = document.getElementById('addBeaconArea').value.trim(); const lat = document.getElementById('addBeaconLat').value; const lon = document.getElementById('addBeaconLon').value; const address = document.getElementById('addBeaconAddress').value.trim(); @@ -2271,8 +2387,6 @@ if (uuid) body.beacon_uuid = uuid; if (major !== '') body.beacon_major = parseInt(major); if (minor !== '') body.beacon_minor = parseInt(minor); - if (floor) body.floor = floor; - if (area) body.area = area; if (lat !== '') body.latitude = parseFloat(lat); if (lon !== '') body.longitude = parseFloat(lon); if (address) body.address = address; @@ -2292,33 +2406,39 @@ const b = await apiCall(`${API_BASE}/beacons/${id}`); showModal(`

编辑信标

-

MAC 地址不可修改

-
-
+

MAC 地址不可修改,设备通过此地址匹配信标

+

信标安装位置的描述性名称

+

iBeacon 协议的 UUID 标识符,用于匹配设备上报的信标数据

-
-
+

iBeacon Major 值,区分信标组

+

iBeacon Minor 值,区分组内信标

-
-
-
+

位置信息 — 设置后蓝牙打卡/定位记录将自动关联此坐标

+
+ +
+
+

点击地图选择位置,或手动输入坐标

-
+

点击地图或搜索位置后自动填充

+

停用后蓝牙记录将不再关联此信标位置

`); + initBeaconPickerMap('editBeaconMapDiv', 'editBeaconLat', 'editBeaconLon', 'editBeaconAddress', + b.latitude || null, b.longitude || null); } catch (err) { showToast('加载信标信息失败: ' + err.message, 'error'); } @@ -2330,8 +2450,6 @@ const uuid = document.getElementById('editBeaconUuid').value.trim(); const major = document.getElementById('editBeaconMajor').value; const minor = document.getElementById('editBeaconMinor').value; - const floor = document.getElementById('editBeaconFloor').value.trim(); - const area = document.getElementById('editBeaconArea').value.trim(); const lat = document.getElementById('editBeaconLat').value; const lon = document.getElementById('editBeaconLon').value; const address = document.getElementById('editBeaconAddress').value.trim(); @@ -2341,8 +2459,6 @@ body.beacon_uuid = uuid || null; if (major !== '') body.beacon_major = parseInt(major); else body.beacon_major = null; if (minor !== '') body.beacon_minor = parseInt(minor); else body.beacon_minor = null; - body.floor = floor || null; - body.area = area || null; if (lat !== '') body.latitude = parseFloat(lat); else body.latitude = null; if (lon !== '') body.longitude = parseFloat(lon); else body.longitude = null; body.address = address || null; diff --git a/app/tcp_server.py b/app/tcp_server.py index 4c25141..a669330 100644 --- a/app/tcp_server.py +++ b/app/tcp_server.py @@ -21,6 +21,7 @@ from sqlalchemy import select, update from app.config import settings from app.database import async_session from app.geocoding import geocode_location, reverse_geocode +from app.websocket_manager import ws_manager from app.models import ( AlarmRecord, AttendanceRecord, @@ -213,44 +214,18 @@ class PacketParser: class PacketBuilder: - """Construct KKS protocol response packets. + """Thin wrapper delegating to app.protocol.builder.PacketBuilder. - Length field semantics match app.protocol.builder: - length = proto(1) + info(N) + serial(2) + crc(2) - CRC is computed over: length_bytes + proto + info + serial + Preserves the (protocol, payload, serial) call signature used throughout tcp_server.py. """ + from app.protocol.builder import PacketBuilder as _ProtoBuilder + @staticmethod def build_response( protocol: int, payload: bytes, serial: int, *, long: bool = False ) -> bytes: - proto_byte = struct.pack("B", protocol) - serial_bytes = struct.pack("!H", serial) - - # length = proto(1) + info(N) + serial(2) + crc(2) - payload_len = 1 + len(payload) + 2 + 2 - - if long or payload_len > 0xFF: - length_bytes = struct.pack("!H", payload_len) - start_marker = START_MARKER_LONG - else: - length_bytes = struct.pack("B", payload_len) - start_marker = START_MARKER_SHORT - - # CRC over: length_bytes + proto + info + serial - crc_input = length_bytes + proto_byte + payload + serial_bytes - crc_value = crc_itu(crc_input) - crc_bytes = struct.pack("!H", crc_value) - - return ( - start_marker - + length_bytes - + proto_byte - + payload - + serial_bytes - + crc_bytes - + STOP_MARKER - ) + return PacketBuilder._ProtoBuilder.build_response(protocol, serial, payload) # --------------------------------------------------------------------------- @@ -452,6 +427,10 @@ class TCPManager: .where(Device.imei == imei) .values(status="offline") ) + # Broadcast device offline + ws_manager.broadcast_nonblocking("device_status", { + "imei": imei, "status": "offline", + }) except Exception: logger.exception("Failed to set IMEI=%s offline in DB", imei) @@ -547,8 +526,8 @@ class TCPManager: # bits 9-0: course (0-360) is_realtime = bool(course_status & 0x2000) gps_positioned = bool(course_status & 0x1000) - is_east = bool(course_status & 0x0800) - is_north = bool(course_status & 0x0400) + is_west = bool(course_status & 0x0800) # bit 11: 0=East, 1=West + is_north = bool(course_status & 0x0400) # bit 10: 0=South, 1=North course = course_status & 0x03FF latitude = lat_raw / 1_800_000.0 @@ -557,7 +536,7 @@ class TCPManager: # Apply hemisphere if not is_north: latitude = -latitude - if not is_east: + if is_west: longitude = -longitude result["latitude"] = latitude @@ -681,6 +660,10 @@ class TCPManager: if lang_str: device.language = lang_str # Don't overwrite user-set device_type with raw hex code + # Broadcast device online + ws_manager.broadcast_nonblocking("device_status", { + "imei": imei, "status": "online", + }) except Exception: logger.exception("DB error during login for IMEI=%s", imei) @@ -986,8 +969,8 @@ class TCPManager: if location_type == "lbs" and len(content) >= pos + 5: lac = struct.unpack("!H", content[pos : pos + 2])[0] pos += 2 - cell_id = struct.unpack("!H", content[pos : pos + 2])[0] - pos += 2 + cell_id = int.from_bytes(content[pos : pos + 3], "big") + pos += 3 elif location_type == "lbs_4g" and len(content) >= pos + 12: lac = struct.unpack("!I", content[pos : pos + 4])[0] pos += 4 @@ -1015,11 +998,11 @@ class TCPManager: pos += 4 cell_id = struct.unpack("!Q", content[pos : pos + 8])[0] pos += 8 - elif location_type == "wifi" and len(content) >= pos + 4: + elif location_type == "wifi" and len(content) >= pos + 5: lac = struct.unpack("!H", content[pos : pos + 2])[0] pos += 2 - cell_id = struct.unpack("!H", content[pos : pos + 2])[0] - pos += 2 + cell_id = int.from_bytes(content[pos : pos + 3], "big") + pos += 3 # --- Geocoding for LBS/WiFi locations (no GPS coordinates) --- neighbor_cells_data: Optional[list] = None @@ -1039,6 +1022,7 @@ class TCPManager: cell_id=cell_id, wifi_list=wifi_data_list, neighbor_cells=neighbor_cells_data, + imei=imei, ) if lat is not None and lon is not None: latitude = lat @@ -1089,6 +1073,12 @@ class TCPManager: recorded_at=recorded_at, ) session.add(record) + # Broadcast to WebSocket subscribers + ws_manager.broadcast_nonblocking("location", { + "imei": imei, "device_id": device_id, "location_type": location_type, + "latitude": latitude, "longitude": longitude, "speed": speed, + "address": address, "recorded_at": str(recorded_at), + }) except Exception: logger.exception( "DB error storing %s location for IMEI=%s", location_type, imei @@ -1121,7 +1111,7 @@ class TCPManager: # Parse stations (main + up to 6 neighbors) is_4g = location_type in ("lbs_4g", "wifi_4g") lac_size = 4 if is_4g else 2 - cid_size = 8 if is_4g else 2 + cid_size = 8 if is_4g else 3 station_size = lac_size + cid_size + 1 # +1 for RSSI for i in range(7): @@ -1135,8 +1125,8 @@ class TCPManager: else: s_lac = struct.unpack("!H", content[pos : pos + 2])[0] pos += 2 - s_cid = struct.unpack("!H", content[pos : pos + 2])[0] - pos += 2 + s_cid = int.from_bytes(content[pos : pos + 3], "big") + pos += 3 s_rssi = content[pos] pos += 1 @@ -1386,6 +1376,8 @@ class TCPManager: cell_id: Optional[int] = None battery_level: Optional[int] = None gsm_signal: Optional[int] = None + wifi_data: Optional[list] = None + fence_data: Optional[dict] = None # For alarm packets (0xA3, 0xA4, 0xA9), the terminal_info byte is # located after GPS + LBS data. Extract alarm type from terminal_info bits. @@ -1420,6 +1412,12 @@ class TCPManager: terminal_info, battery_level, gsm_signal, alarm_type_name, pos = \ self._parse_alarm_tail(content, pos) + # Extract fence_id for 0xA4 multi-fence alarm + if proto == PROTO_ALARM_MULTI_FENCE and len(content) >= pos + 1: + fence_id = content[pos] + fence_data = {"fence_id": fence_id} + pos += 1 + elif proto == PROTO_ALARM_LBS_4G: # 0xA5: NO datetime, NO GPS, NO lbs_length # mcc(2) + mnc(1-2) + lac(4) + cell_id(8) + terminal_info(1) @@ -1491,6 +1489,9 @@ class TCPManager: wifi_data_list.append({"mac": mac, "signal": signal}) pos += 7 + if wifi_data_list: + wifi_data = wifi_data_list + # alarm_code(1) + language(1) if len(content) >= pos + 1: alarm_code = content[pos] @@ -1504,6 +1505,7 @@ class TCPManager: lat, lon = await geocode_location( mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id, wifi_list=wifi_list_for_geocode, + imei=imei, ) if lat is not None and lon is not None: latitude = lat @@ -1544,9 +1546,17 @@ class TCPManager: battery_level=battery_level, gsm_signal=gsm_signal, address=address, + wifi_data=wifi_data, + fence_data=fence_data, recorded_at=recorded_at, ) session.add(record) + # Broadcast alarm to WebSocket subscribers + ws_manager.broadcast_nonblocking("alarm", { + "imei": imei, "device_id": device_id, "alarm_type": alarm_type_name, + "alarm_source": alarm_source, "latitude": latitude, "longitude": longitude, + "address": address, "recorded_at": str(recorded_at), + }) except Exception: logger.exception( "DB error storing alarm for IMEI=%s (source=%s)", imei, alarm_source @@ -1849,6 +1859,12 @@ class TCPManager: "0xB1" if is_4g else "0xB0", imei, attendance_type, gps_positioned, latitude, longitude, address, ) + # Broadcast attendance to WebSocket subscribers + ws_manager.broadcast_nonblocking("attendance", { + "imei": imei, "attendance_type": attendance_type, + "latitude": latitude, "longitude": longitude, + "address": address, "recorded_at": str(recorded_at), + }) except Exception: logger.exception("DB error storing attendance for IMEI=%s", imei) @@ -1991,6 +2007,12 @@ class TCPManager: beacon_major, beacon_minor, rssi, beacon_battery or 0, ) + # Broadcast bluetooth punch + ws_manager.broadcast_nonblocking("bluetooth", { + "imei": imei, "record_type": "punch", + "beacon_mac": beacon_mac, "attendance_type": attendance_type, + "recorded_at": str(recorded_at), + }) except Exception: logger.exception("DB error storing BT punch for IMEI=%s", imei) @@ -2163,6 +2185,11 @@ class TCPManager: recorded_at=recorded_at, ) session.add(record) + # Broadcast bluetooth location + ws_manager.broadcast_nonblocking("bluetooth", { + "imei": imei, "record_type": "location", + "beacon_count": beacon_count, "recorded_at": str(recorded_at), + }) except Exception: logger.exception("DB error storing BT location for IMEI=%s", imei) diff --git a/app/websocket_manager.py b/app/websocket_manager.py new file mode 100644 index 0000000..bef2706 --- /dev/null +++ b/app/websocket_manager.py @@ -0,0 +1,89 @@ +""" +WebSocket Manager - WebSocket 连接管理器 +Manages client connections, topic subscriptions, and broadcasting. +""" + +import asyncio +import json +import logging +from datetime import datetime, timezone + +from fastapi import WebSocket + +logger = logging.getLogger(__name__) + +# Maximum concurrent WebSocket connections +MAX_CONNECTIONS = 100 + +# Valid topics +VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"} + + +class WebSocketManager: + """Manages WebSocket connections with topic-based subscriptions.""" + + def __init__(self): + # {websocket: set_of_topics} + self.active_connections: dict[WebSocket, set[str]] = {} + + @property + def connection_count(self) -> int: + return len(self.active_connections) + + async def connect(self, websocket: WebSocket, topics: set[str]) -> bool: + """Accept and register a WebSocket connection. Returns False if limit reached.""" + if self.connection_count >= MAX_CONNECTIONS: + await websocket.close(code=1013, reason="Max connections reached") + return False + + await websocket.accept() + filtered = topics & VALID_TOPICS + self.active_connections[websocket] = filtered if filtered else VALID_TOPICS + logger.info( + "WebSocket connected (%d total), topics: %s", + self.connection_count, + self.active_connections[websocket], + ) + return True + + def disconnect(self, websocket: WebSocket): + """Remove a WebSocket connection.""" + self.active_connections.pop(websocket, None) + logger.info("WebSocket disconnected (%d remaining)", self.connection_count) + + async def broadcast(self, topic: str, data: dict): + """Broadcast a message to all subscribers of the given topic.""" + if topic not in VALID_TOPICS: + return + + message = json.dumps( + {"topic": topic, "data": data, "timestamp": datetime.now(timezone.utc).isoformat()}, + default=str, + ensure_ascii=False, + ) + + disconnected = [] + # Snapshot dict to avoid RuntimeError from concurrent modification + for ws, topics in list(self.active_connections.items()): + if topic in topics: + try: + await ws.send_text(message) + except Exception: + disconnected.append(ws) + + for ws in disconnected: + self.active_connections.pop(ws, None) + + def broadcast_nonblocking(self, topic: str, data: dict): + """Fire-and-forget broadcast (used from TCP handler context).""" + asyncio.create_task(self._safe_broadcast(topic, data)) + + async def _safe_broadcast(self, topic: str, data: dict): + try: + await self.broadcast(topic, data) + except Exception: + logger.exception("WebSocket broadcast error for topic %s", topic) + + +# Singleton instance +ws_manager = WebSocketManager()