feat: KKS P240/P241 蓝牙工牌管理系统初始提交
FastAPI + SQLAlchemy + asyncio TCP 服务器,支持设备管理、实时定位、 告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
This commit is contained in:
43
.env.example
Normal file
43
.env.example
Normal file
@@ -0,0 +1,43 @@
|
||||
# KKS Badge Management System - Environment Configuration
|
||||
# Copy to .env and customize values as needed
|
||||
|
||||
# Database (default: SQLite in project root)
|
||||
# DATABASE_URL=sqlite+aiosqlite:///path/to/badge_admin.db
|
||||
# DATABASE_URL=postgresql+asyncpg://user:password@localhost/badge_admin
|
||||
|
||||
# Server ports
|
||||
# TCP_PORT=5000
|
||||
# API_PORT=8088
|
||||
|
||||
# Debug mode (default: false)
|
||||
# DEBUG=true
|
||||
|
||||
# API authentication (uncomment to enable, all /api/ endpoints require X-API-Key header)
|
||||
# API_KEY=your-secret-api-key-here
|
||||
|
||||
# CORS origins (comma-separated, * = allow all)
|
||||
# CORS_ORIGINS=https://example.com,https://admin.example.com
|
||||
|
||||
# Rate limiting (format: "count/period", period = second/minute/hour/day)
|
||||
# RATE_LIMIT_DEFAULT=60/minute
|
||||
# RATE_LIMIT_WRITE=30/minute
|
||||
|
||||
# Track query max points (default: 10000)
|
||||
# TRACK_MAX_POINTS=10000
|
||||
|
||||
# 天地图 API key (reverse geocoding, free 10k/day)
|
||||
# Sign up: https://lbs.tianditu.gov.cn/
|
||||
# TIANDITU_API_KEY=your_tianditu_key
|
||||
|
||||
# Google Geolocation API (optional, for cell/WiFi geocoding)
|
||||
# GOOGLE_API_KEY=your_google_key
|
||||
|
||||
# Unwired Labs API (optional, for cell/WiFi geocoding)
|
||||
# UNWIRED_API_TOKEN=your_unwired_token
|
||||
|
||||
# 高德地图 API (optional, requires enterprise auth for IoT positioning)
|
||||
# AMAP_KEY=your_amap_key
|
||||
# AMAP_SECRET=your_amap_secret
|
||||
|
||||
# Geocoding cache size
|
||||
# GEOCODING_CACHE_SIZE=10000
|
||||
8
.gitignore
vendored
Normal file
8
.gitignore
vendored
Normal file
@@ -0,0 +1,8 @@
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.db
|
||||
*.log
|
||||
nohup.out
|
||||
.env
|
||||
.claude/
|
||||
.idea/
|
||||
559
CLAUDE.md
Normal file
559
CLAUDE.md
Normal file
@@ -0,0 +1,559 @@
|
||||
# Badge Admin - KKS P240/P241 蓝牙工牌管理系统
|
||||
|
||||
## 项目概览
|
||||
|
||||
KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio TCP 服务器。
|
||||
支持设备管理、实时定位、告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
|
||||
|
||||
## 项目结构
|
||||
|
||||
```
|
||||
/home/gpsystem/
|
||||
├── run.py # 启动脚本 (uvicorn)
|
||||
├── .env.example # 环境变量模板 (复制为 .env 使用)
|
||||
├── requirements.txt # Python 依赖
|
||||
├── frpc.toml # FRP 客户端配置 (TCP隧道)
|
||||
├── badge_admin.db # SQLite 数据库
|
||||
├── server.log # 服务器日志
|
||||
│
|
||||
├── app/
|
||||
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器
|
||||
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
|
||||
│ ├── database.py # SQLAlchemy async 数据库连接
|
||||
│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin)
|
||||
│ ├── extensions.py # 共享实例 (rate limiter, 真实IP提取)
|
||||
│ ├── 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 → 经纬度 + 经纬度 → 地址)
|
||||
│ │
|
||||
│ ├── protocol/
|
||||
│ │ ├── constants.py # 协议常量 (协议号、告警类型snake_case、信号等级等)
|
||||
│ │ ├── parser.py # 二进制协议解析器
|
||||
│ │ ├── builder.py # 响应包构建器
|
||||
│ │ └── crc.py # CRC-ITU (CRC-16/X-25, 多项式 0x8408)
|
||||
│ │
|
||||
│ ├── services/
|
||||
│ │ ├── device_service.py # 设备 CRUD
|
||||
│ │ ├── command_service.py # 指令日志 CRUD
|
||||
│ │ ├── location_service.py # 位置记录查询
|
||||
│ │ ├── beacon_service.py # 蓝牙信标 CRUD
|
||||
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
|
||||
│ │
|
||||
│ ├── routers/
|
||||
│ │ ├── devices.py # /api/devices (含 /stats, /batch, /batch-delete, /all-latest-locations)
|
||||
│ │ ├── commands.py # /api/commands (含 /send, /message, /tts, /batch)
|
||||
│ │ ├── 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 (含 beacon_mac过滤, /{id})
|
||||
│ │ ├── heartbeats.py # /api/heartbeats (心跳记录查询)
|
||||
│ │ ├── beacons.py # /api/beacons (信标管理 CRUD)
|
||||
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
|
||||
│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅)
|
||||
│ │
|
||||
│ └── static/
|
||||
│ └── admin.html # 管理后台 SPA (暗色主题, 8个页面)
|
||||
│
|
||||
└── doc/
|
||||
└── KKS_Protocol_P240_P241.md # 协议文档 (从PDF转换)
|
||||
```
|
||||
|
||||
## 架构设计
|
||||
|
||||
### 网络拓扑
|
||||
|
||||
```
|
||||
[P241 工牌] --TCP--> [152.69.205.186:5001 (frps)]
|
||||
|
|
||||
FRP Tunnel
|
||||
|
|
||||
[Container:5000 (frpc)]
|
||||
|
|
||||
[TCP Server (asyncio)]
|
||||
|
|
||||
[FastAPI :8088] --CF Tunnel--> [Web 访问]
|
||||
```
|
||||
|
||||
### 端口说明
|
||||
- **8088**: FastAPI HTTP API + 管理后台 UI
|
||||
- **5000**: TCP 服务器 (KKS 二进制协议)
|
||||
- **5001**: 远程服务器 FRP 映射端口 (152.69.205.186)
|
||||
- **7000**: FRP 服务端管理端口
|
||||
- **7500**: FRP Dashboard (admin/PassWord0325)
|
||||
|
||||
### 配置管理
|
||||
- `app/config.py` 使用 **pydantic-settings** (`BaseSettings`),支持 `.env` 文件覆盖默认值
|
||||
- `.env.example` 提供所有可配置项模板,复制为 `.env` 使用
|
||||
- DATABASE_URL 使用绝对路径 (基于 `__file__` 计算项目根目录)
|
||||
- 高德 API 密钥集中在 `config.py`,`geocoding.py` 从 `settings` 导入
|
||||
- 端口号有 pydantic 校验 (ge=1, le=65535)
|
||||
|
||||
## 启动服务
|
||||
|
||||
```bash
|
||||
# 启动服务
|
||||
cd /home/gpsystem
|
||||
nohup python3 -m uvicorn app.main:app --host 0.0.0.0 --port 8088 > server.log 2>&1 &
|
||||
|
||||
# 启动 FRP 客户端 (TCP隧道)
|
||||
nohup /tmp/frpc -c /home/gpsystem/frpc.toml > /tmp/frpc.log 2>&1 &
|
||||
|
||||
# 检查服务状态
|
||||
curl http://localhost:8088/health
|
||||
curl http://localhost:8088/api/devices
|
||||
```
|
||||
|
||||
## 管理后台 UI
|
||||
|
||||
访问 `/admin` 路径,包含以下页面:
|
||||
1. **仪表盘** - 设备统计、告警概览、在线状态
|
||||
2. **设备管理** - 添加/编辑/删除设备
|
||||
3. **位置追踪** - Leaflet 地图、轨迹回放、地址显示
|
||||
4. **告警管理** - 告警列表、确认处理、位置/地址显示
|
||||
5. **考勤记录** - 上下班打卡记录 (0xB0/0xB1 GPS+LBS+WiFi)
|
||||
6. **蓝牙记录** - 蓝牙打卡(0xB2)/定位(0xB3)记录,含信标MAC/UUID/RSSI等
|
||||
7. **信标管理** - 蓝牙信标注册、位置配置(MAC/UUID/楼层/区域/经纬度)
|
||||
8. **指令管理** - 发送指令/留言/TTS语音
|
||||
|
||||
## 协议详情
|
||||
|
||||
KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
||||
|
||||
### 已支持协议号
|
||||
| 协议号 | 名称 | 方向 |
|
||||
|--------|------|------|
|
||||
| 0x01 | Login 登录 | 终端→服务器 |
|
||||
| 0x13 | Heartbeat 心跳 | 双向 |
|
||||
| 0x1F | Time Sync 时间同步 | 双向 |
|
||||
| 0x22 | GPS 定位 | 终端→服务器 |
|
||||
| 0x26 | Alarm ACK 告警确认 | 服务器→终端 |
|
||||
| 0x28 | LBS 多基站 | 终端→服务器 |
|
||||
| 0x2C | WiFi 定位 | 终端→服务器 |
|
||||
| 0x36 | Heartbeat Extended 扩展心跳 | 双向 |
|
||||
| 0x80 | Online Command 在线指令 | 服务器→终端 |
|
||||
| 0x81 | Online Command Reply | 终端→服务器 |
|
||||
| 0x82 | Message 留言 | 服务器→终端 |
|
||||
| 0x94 | General Info (ICCID/IMSI等) | 双向 |
|
||||
| 0xA0 | 4G GPS 定位 | 终端→服务器 |
|
||||
| 0xA1 | 4G LBS 多基站 | 终端→服务器 |
|
||||
| 0xA2 | 4G WiFi 定位 | 终端→服务器 |
|
||||
| 0xA3-0xA5 | 4G 告警 (围栏/LBS) | 终端→服务器 |
|
||||
| 0xA9 | WiFi 告警 | 终端→服务器 |
|
||||
| 0xB0-0xB1 | 考勤打卡 | 双向 |
|
||||
| 0xB2 | 蓝牙打卡 | 双向 |
|
||||
| 0xB3 | 蓝牙定位 | 终端→服务器 |
|
||||
|
||||
### 数据包格式
|
||||
```
|
||||
[Start 0x7878/0x7979] [Length 1-2B] [Protocol 1B] [Content NB] [Serial 2B] [CRC 2B] [Stop 0x0D0A]
|
||||
```
|
||||
|
||||
## 当前设备
|
||||
|
||||
| 字段 | 值 |
|
||||
|------|-----|
|
||||
| IMEI | 868120334031363 |
|
||||
| 型号 | P241 |
|
||||
| 名称 | 测试 |
|
||||
| DB ID | 1 |
|
||||
| ICCID | 89860848102570005286 |
|
||||
| IMSI | 460240388355286 |
|
||||
| SIM卡 | 中国移动 IoT (MCC=460, MNC=0) |
|
||||
| 位置 | 成都地区 (~30.605°N, 103.936°E) |
|
||||
|
||||
## 重要实现细节
|
||||
|
||||
### IMEI 解析
|
||||
- BCD 编码: 8字节 = 16 hex digits, 首位为填充 0
|
||||
- 使用 `raw_hex[1:]` 只移除首位填充0 (不用 lstrip 避免移除多个)
|
||||
|
||||
### 设备登录
|
||||
- 登录时不覆盖用户设置的 device_type (避免被覆盖为 raw hex)
|
||||
- 重连时先关闭旧连接再建立新连接
|
||||
- 断开连接时检查 writer 是否匹配,避免误将新连接标记为 offline
|
||||
|
||||
### 告警处理
|
||||
- 告警响应使用 **0x26 ACK** (非原始协议号回复)
|
||||
- 告警类型常量使用 **snake_case** (如 `sos`, `vibration`, `enter_fence`)
|
||||
- 4G 告警包解析:
|
||||
- **0xA3/0xA4** (围栏告警): datetime(6) + gps(12) + lbs_length(1) + MCC/MNC + LAC(4) + CellID(8) + terminal_info(1) + voltage_level(**1**字节) + gsm_signal(1) + alarm_code(1) + language(1)
|
||||
- **0xA5** (LBS告警): **无datetime, 无GPS** — 直接从 MCC 开始
|
||||
- **0xA9** (WiFi告警): datetime(6) + MCC/MNC + cell_type(1) + cell_count(1) + 基站列表 + timing_advance(1) + WiFi列表 + alarm_code(1) + language(1)
|
||||
- MCC 高位 `0x8000` 标志: 表示 MNC 为 2 字节 (非默认1字节)
|
||||
- voltage_level 是 **1字节** (0x00-0x06 等级),不是2字节毫伏值
|
||||
- LBS/WiFi 报警自动执行前向地理编码(基站→经纬度)+ 逆地理编码(经纬度→地址)
|
||||
|
||||
### 位置数据与地理编码
|
||||
- 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`,需企业认证 (认证中,个人账号返回 10012)
|
||||
- WiFi+基站混合定位精度 ~30m,企业认证通过后自动生效
|
||||
- **逆地理编码** (`reverse_geocode`): 经纬度 → 中文地址
|
||||
- **高德**: `restapi.amap.com/v3/geocode/regeo`,需 WGS84→GCJ02 坐标转换 (服务端 Python 实现)
|
||||
- 缓存策略: 坐标四舍五入到3位小数 (~100m) 作为缓存key
|
||||
- 内置 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
|
||||
|
||||
### API 分页
|
||||
- page_size 最大限制为 100 (schema 层校验)
|
||||
- 前端设备选择器使用 page_size=100 (不能超过限制)
|
||||
|
||||
### CRC 算法
|
||||
- CRC-ITU / CRC-16/X-25
|
||||
- Reflected polynomial: 0x8408
|
||||
- Initial value: 0xFFFF
|
||||
- Final XOR: 0xFFFF
|
||||
|
||||
### TCP 指令下发
|
||||
- 0x80 (Online Command): ASCII 指令 + 2字节语言字段 (`0x0001`=中文)
|
||||
- 0x81 (Command Reply): 设备回复,格式 length(1) + server_flag(4) + content(n) + language(2)
|
||||
- 0x82 (Message): **UTF-16 BE** 编码的文本消息 (非UTF-8)
|
||||
- TTS: 通过 0x80 发送 `TTS,<文本>` 格式
|
||||
- 常用指令: `GPSON#` 开启GPS, `BTON#` 开启蓝牙, `BTSCAN,1#` 开启BLE扫描
|
||||
- 已验证可用指令: `BTON#`, `BTSCAN,1#`, `GPSON#`, `MODE,1/3#`, `PARAM#`, `CHECK#`, `VERSION#`, `TIMER#`, `WHERE#`, `STATUS#`, `RESET#`
|
||||
- **架构**: `tcp_command_service.py` 作为抽象层解耦 routers↔tcp_server 的循环导入,通过 lazy import 访问 tcp_manager
|
||||
- **重要**: TCP 层 (`send_command`/`send_message`) 只负责发送,不创建 CommandLog。CommandLog 由 API 层 (commands.py) 管理
|
||||
- **重要**: 服务器启动时自动将所有设备标记为 offline,等待设备重新登录
|
||||
|
||||
### 蓝牙信标管理
|
||||
- **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址
|
||||
- **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表
|
||||
- **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude
|
||||
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
||||
- **设备端配置**: 需通过 0x80 指令发送 `BTON#` 开启蓝牙、`BTSCAN,1#` 开启扫描
|
||||
- **已知有效指令**: `BTON#`(btON:1), `BTSCAN,1#`(btSCAN:1) — 设备确认设置成功
|
||||
- **当前状态**: ✅ 0xB2 蓝牙打卡已验证可用 (2026-03-18),设备成功检测信标并上报数据
|
||||
- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641
|
||||
- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空
|
||||
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
||||
|
||||
### 0x94 General Info 子协议
|
||||
- 子协议 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.gps_satellites` (非 `l.accuracy`)
|
||||
- 记录时间: `l.recorded_at` (非 `l.timestamp`)
|
||||
- 报警来源: `a.alarm_source` (非 `a.source`)
|
||||
- 报警信号: `a.gsm_signal` (非 `a.signal_strength`)
|
||||
- 报警类型: snake_case (如 `vibration`, `power_cut`, `voice_alarm`)
|
||||
|
||||
## FRP 配置
|
||||
|
||||
```toml
|
||||
# frpc.toml (容器端)
|
||||
serverAddr = "152.69.205.186"
|
||||
serverPort = 7000
|
||||
auth.method = "token"
|
||||
auth.token = "PassWord0325"
|
||||
|
||||
[[proxies]]
|
||||
name = "badge-tcp"
|
||||
type = "tcp"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = 5000
|
||||
remotePort = 5001
|
||||
```
|
||||
|
||||
## 外部服务器
|
||||
|
||||
- IP: 152.69.205.186
|
||||
- OS: Ubuntu 20.04, Python 3.11, 24GB RAM
|
||||
- FRP Server: frps v0.62.1 (1panel Docker, network_mode: host)
|
||||
- Config: /opt/1panel/apps/frps/frps/data/frps.toml
|
||||
|
||||
## 高德地图 API
|
||||
|
||||
### Key
|
||||
- **Web服务 Key**: `a9f4e04f5c8e739e5efb07175333f30b`
|
||||
- **安全密钥**: `bfc4e002c49ab5f47df71e0aeaa086a5`
|
||||
- **账号类型**: 个人认证开发者 (正在申请企业认证)
|
||||
|
||||
### 已接入服务
|
||||
- **✅ 逆地理编码** (`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,000,000 次/日)
|
||||
|
||||
## 已知限制
|
||||
|
||||
1. **IoT SIM 卡不支持 SMS** - 144 号段的物联网卡无法收发短信,需通过平台或 TCP 连接配置设备
|
||||
2. **Cloudflare Tunnel 仅代理 HTTP** - TCP 流量必须通过 FRP 转发
|
||||
3. **SQLite 单写** - 高并发场景需切换 PostgreSQL
|
||||
4. **设备最多 100 台列表** - 受 page_size 限制,超过需翻页查询
|
||||
5. **基站/WiFi定位需企业认证** - 高德智能硬件定位 API 已接入但个人账号返回 10012,企业认证通过后自动生效
|
||||
|
||||
## 已修复的问题 (Bug Fix 记录)
|
||||
|
||||
### 数据链路修复
|
||||
1. **IMEI 解析** - 修复 BCD 解码逻辑,确保正确提取 15 位 IMEI
|
||||
2. **设备类型覆盖** - 登录时不再覆盖用户设置的 device_type
|
||||
3. **设备重连** - 新连接建立前先关闭旧连接,断开时检查 writer 身份
|
||||
4. **告警类型提取** - 修正 alarm byte 位置,从 terminal_info+voltage+gsm_signal 之后读取
|
||||
|
||||
### 协议修复
|
||||
5. **0x80 指令包** - 添加缺失的 2 字节语言字段 (`0x0001`)
|
||||
6. **0x82 消息编码** - 从 UTF-8 改为 UTF-16 BE (协议要求)
|
||||
7. **0x94 通用信息** - 完善子协议 0x0A (IMEI+IMSI+ICCID) 和 0x09 (GPS卫星状态) 解析
|
||||
|
||||
### 前端修复
|
||||
8. **设备选择器为空** - `page_size=500` 超过 API 最大限制 100,返回 422 错误
|
||||
9. **位置页面崩溃** - API 返回 `data: null` 时未做空值检查
|
||||
10. **轨迹查询失败** - 缺少必填的 start_time/end_time 参数
|
||||
11. **字段名不匹配** - 修复 signal_strength→gsm_signal, response→response_content, source→alarm_source 等
|
||||
|
||||
### 定位功能修复
|
||||
12. **WiFi/LBS 无坐标** - 添加 wifi/wifi_4g 解析分支 (原代码缺失)
|
||||
13. **地理编码集成** - LBS/WiFi 定位数据自动转换为经纬度坐标
|
||||
14. **邻近基站和WiFi数据** - 存储到 LocationRecord 的 neighbor_cells 和 wifi_data 字段
|
||||
|
||||
### 告警功能修复
|
||||
15. **告警响应协议** - 改为使用 0x26 ACK 响应 (原来错误使用地址回复)
|
||||
16. **voltage_level 解析** - 从2字节改为1字节 (0x00-0x06等级)
|
||||
17. **0xA5 LBS告警格式** - 移除错误的 datetime/lbs_length 前缀,直接从 MCC 开始
|
||||
18. **0xA9 WiFi告警** - 独立解析器 (非GPS格式),支持 cell_type/cell_count/WiFi AP 列表
|
||||
19. **MNC 2字节标志** - `_parse_mcc_mnc_from_content` 正确处理 MCC 高位 0x8000 标志
|
||||
20. **告警类型常量** - 改为 snake_case,新增 voice_alarm(0x16), fake_base_station(0x17), cover_open(0x18), internal_low_battery(0x19)
|
||||
21. **LBS/WiFi 报警定位** - 为无GPS的报警添加前向地理编码
|
||||
|
||||
### 逆地理编码
|
||||
22. **逆地理编码集成** - 位置和报警记录自动获取中文地址 (高德逆地理编码)
|
||||
23. **地址字段** - LocationRecord 新增 address 字段,前端位置表/报警表/地图弹窗显示地址
|
||||
|
||||
### 蓝牙与考勤功能
|
||||
24. **0xB0/0xB1 考勤解析** - 完整解析 GPS+LBS+WiFi 考勤包,含基站邻区、WiFi AP、前向+逆地理编码
|
||||
25. **0xB2 蓝牙打卡解析** - 解析 iBeacon 数据 (RSSI/MAC/UUID/Major/Minor/电池/打卡类型)
|
||||
26. **0xB3 蓝牙定位解析** - 解析多信标数据,每信标 30 字节,支持电压(V)/百分比(%)电池单位
|
||||
27. **考勤类型检测** - 从 terminal_info 字节 bits[5:2] 判断 clock_in(0001)/clock_out(0010)
|
||||
28. **信标管理系统** - BeaconConfig CRUD,注册信标物理位置,TCP 自动关联信标经纬度
|
||||
|
||||
### 指令发送修复
|
||||
29. **server_flag_str 未定义** - send_command 中记录日志时变量未定义导致 NameError
|
||||
30. **TCP 层重复创建日志** - 简化 send_command/send_message 只负责发送,CommandLog 由 API 层管理
|
||||
31. **IMEI lstrip 问题** - `lstrip("0")` 可能删除多个前导零,改为 `raw_hex[1:]`
|
||||
32. **设备启动状态** - 服务器启动时重置所有设备为 offline,避免显示在线但实际未连接
|
||||
33. **0x81 回复解析** - 去除末尾 2 字节语言字段,正确提取回复文本
|
||||
|
||||
### 协议全面审计修复 (2026-03-16)
|
||||
34. **0x1F 时间同步格式** - 从6字节(YY MM DD HH MM SS)改为4字节Unix时间戳+2字节语言字段
|
||||
35. **地址回复完整格式** - 重写 `_send_address_reply`,实现完整格式: server_flag(4)+ADDRESS/ALARMSMS(7-8)+&&+UTF16BE地址+&&+电话(21)+##
|
||||
36. **报警地址回复** - 报警包在发送0x26 ACK后,额外发送0x17地址回复(含ALARMSMS标记)
|
||||
37. **0x82 留言格式** - 添加 server_flag(4字节) + language(2字节) 字段
|
||||
38. **0x22 GPS Cell ID** - 从2字节改为3字节解析 (2G基站CellID为3字节)
|
||||
39. **登录包时区/语言** - 解析 content[10:12] 提取时区(bit15-04)和语言(bit00),存入Device
|
||||
40. **0x94 不需回复** - 移除服务器响应,从 PROTOCOLS_REQUIRING_RESPONSE 中删除
|
||||
41. **WiFi缓存+LRU淘汰** - 新增 LRUCache(OrderedDict, maxsize=10000),替换所有dict缓存,修复WiFi缓存未写入
|
||||
42. **前端4G筛选** - 位置类型筛选添加 gps_4g/wifi_4g/lbs_4g 选项
|
||||
43. **DATA_REPORT_MODES** - 修正所有模式名称匹配协议文档
|
||||
|
||||
### 架构优化 (2026-03-17)
|
||||
44. **配置集中化** - API密钥从 geocoding.py 硬编码移至 config.py (pydantic-settings),支持 .env 覆盖
|
||||
45. **数据库绝对路径** - DATABASE_URL 从相对路径改为基于 `__file__` 的绝对路径,避免 CWD 依赖
|
||||
46. **tcp_command_service 抽象层** - 新建 services/tcp_command_service.py,通过 lazy import 解耦 routers↔tcp_server 循环依赖
|
||||
47. **commands.py 去重** - send_command/send_message/send_tts 提取 `_send_to_device()` 通用函数
|
||||
48. **协议常量扩展** - constants.py 新增 DEFAULT_DEVICE_TYPE, SERVER_FLAG_BYTES, ATTENDANCE_TYPES, COURSE_BIT_*, MCC_MNC2_FLAG, VOLTAGE_LEVELS
|
||||
49. **前端侧边面板** - 位置追踪/信标管理页面添加左侧设备/信标列表面板,自动选中最近活跃设备
|
||||
50. **前端面板解耦** - 提取 PANEL_IDS 配置 + _initPanelRender 通用函数,toggleSidePanel 仅在 locations 页调用 invalidateSize
|
||||
|
||||
### 连接修复 (2026-03-19)
|
||||
51. **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)
|
||||
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 层
|
||||
|
||||
### 全面改进 (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. **心跳扩展模块解析** - 计步器、外部电压等模块未解析
|
||||
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|Amap" --line-buffered
|
||||
|
||||
# 检查数据库
|
||||
python3 -c "
|
||||
import sqlite3
|
||||
conn = sqlite3.connect('/home/gpsystem/badge_admin.db')
|
||||
cur = conn.cursor()
|
||||
cur.execute('SELECT id, imei, device_type, status, battery_level FROM devices')
|
||||
for row in cur.fetchall(): print(row)
|
||||
cur.execute('SELECT id, location_type, latitude, longitude, address, recorded_at FROM location_records ORDER BY id DESC LIMIT 5')
|
||||
for row in cur.fetchall(): print(row)
|
||||
"
|
||||
|
||||
# 测试 API
|
||||
curl -s http://localhost:8088/api/devices | python3 -m json.tool
|
||||
curl -s http://localhost:8088/api/locations/latest/1 | python3 -m json.tool
|
||||
curl -s http://localhost:8088/health
|
||||
|
||||
# 发送指令
|
||||
curl -s -X POST http://localhost:8088/api/commands/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":1,"command_type":"online_cmd","command_content":"GPSON#"}' | python3 -m json.tool
|
||||
|
||||
# 开启蓝牙扫描
|
||||
curl -s -X POST http://localhost:8088/api/commands/send \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"device_id":1,"command_type":"online_cmd","command_content":"BTON#"}' | python3 -m json.tool
|
||||
|
||||
# 管理信标
|
||||
curl -s http://localhost:8088/api/beacons | python3 -m json.tool
|
||||
curl -s -X POST http://localhost:8088/api/beacons \
|
||||
-H "Content-Type: application/json" \
|
||||
-d '{"beacon_mac":"AA:BB:CC:DD:EE:FF","name":"前台","floor":"1F","latitude":30.27,"longitude":120.15}' | python3 -m json.tool
|
||||
|
||||
# 检查 FRP 连接
|
||||
ps aux | grep frpc
|
||||
# FRP Dashboard: http://152.69.205.186:7500 (admin/PassWord0325)
|
||||
|
||||
# 检查端口
|
||||
python3 -c "
|
||||
with open('/proc/net/tcp') as f:
|
||||
for line in f:
|
||||
parts = line.split()
|
||||
if len(parts)>=2 and ':' in parts[1]:
|
||||
port = int(parts[1].split(':')[1], 16)
|
||||
if port in (8088, 5000): print(f'Port {port}: listening')
|
||||
"
|
||||
|
||||
# 测试地理编码
|
||||
python3 -c "
|
||||
import asyncio
|
||||
from app.geocoding import geocode_location, reverse_geocode
|
||||
async def test():
|
||||
lat, lon = await geocode_location(mcc=460, mnc=0, lac=32775, cell_id=205098688)
|
||||
print(f'lat={lat}, lon={lon}')
|
||||
if lat: print(await reverse_geocode(lat, lon))
|
||||
asyncio.run(test())
|
||||
"
|
||||
```
|
||||
0
app/__init__.py
Normal file
0
app/__init__.py
Normal file
50
app/config.py
Normal file
50
app/config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Project root directory (where config.py lives → parent = app/ → parent = project root)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
_DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db"
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
APP_NAME: str = "KKS Badge Management System"
|
||||
DATABASE_URL: str = Field(
|
||||
default=f"sqlite+aiosqlite:///{_DEFAULT_DB_PATH}",
|
||||
description="Database connection URL (absolute path for SQLite)",
|
||||
)
|
||||
TCP_HOST: str = "0.0.0.0"
|
||||
TCP_PORT: int = Field(default=5000, ge=1, le=65535)
|
||||
API_HOST: str = "0.0.0.0"
|
||||
API_PORT: int = Field(default=8088, ge=1, le=65535)
|
||||
DEBUG: bool = Field(default=False, description="Enable debug mode (SQL echo, verbose errors)")
|
||||
|
||||
# API authentication
|
||||
API_KEY: str | None = Field(default=None, description="API key for authentication (None=disabled)")
|
||||
CORS_ORIGINS: str = Field(default="*", description="Comma-separated allowed CORS origins")
|
||||
|
||||
# Rate limiting
|
||||
RATE_LIMIT_DEFAULT: str = Field(default="60/minute", description="Default rate limit")
|
||||
RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations")
|
||||
|
||||
# 高德地图 API (geocoding)
|
||||
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key")
|
||||
AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥")
|
||||
|
||||
# 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"}
|
||||
|
||||
|
||||
settings = Settings()
|
||||
49
app/database.py
Normal file
49
app/database.py
Normal file
@@ -0,0 +1,49 @@
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
from app.config import settings
|
||||
|
||||
engine = create_async_engine(
|
||||
settings.DATABASE_URL,
|
||||
echo=settings.DEBUG,
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
expire_on_commit=False,
|
||||
)
|
||||
|
||||
|
||||
class Base(DeclarativeBase):
|
||||
pass
|
||||
|
||||
|
||||
async def get_db() -> AsyncSession:
|
||||
"""Dependency injection for async database sessions."""
|
||||
async with async_session() as session:
|
||||
try:
|
||||
yield session
|
||||
await session.commit()
|
||||
except Exception:
|
||||
await session.rollback()
|
||||
raise
|
||||
finally:
|
||||
await session.close()
|
||||
|
||||
|
||||
async def init_db() -> None:
|
||||
"""Create all database tables."""
|
||||
async with engine.begin() as conn:
|
||||
from app.models import ( # noqa: F401
|
||||
AlarmRecord,
|
||||
AttendanceRecord,
|
||||
BluetoothRecord,
|
||||
CommandLog,
|
||||
Device,
|
||||
HeartbeatRecord,
|
||||
LocationRecord,
|
||||
)
|
||||
|
||||
await conn.run_sync(Base.metadata.create_all)
|
||||
85
app/dependencies.py
Normal file
85
app/dependencies.py
Normal file
@@ -0,0 +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 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}
|
||||
|
||||
|
||||
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 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")
|
||||
22
app/extensions.py
Normal file
22
app/extensions.py
Normal file
@@ -0,0 +1,22 @@
|
||||
"""
|
||||
Shared extension instances (rate limiter, etc.) to avoid circular imports.
|
||||
"""
|
||||
|
||||
from starlette.requests import Request
|
||||
from slowapi import Limiter
|
||||
|
||||
from app.config import settings
|
||||
|
||||
|
||||
def _get_real_client_ip(request: Request) -> str:
|
||||
"""Extract real client IP from X-Forwarded-For (behind Cloudflare/nginx) or fallback."""
|
||||
forwarded = request.headers.get("X-Forwarded-For")
|
||||
if forwarded:
|
||||
return forwarded.split(",")[0].strip()
|
||||
cf_ip = request.headers.get("CF-Connecting-IP")
|
||||
if cf_ip:
|
||||
return cf_ip.strip()
|
||||
return request.client.host if request.client else "127.0.0.1"
|
||||
|
||||
|
||||
limiter = Limiter(key_func=_get_real_client_ip, default_limits=[settings.RATE_LIMIT_DEFAULT])
|
||||
308
app/geocoding.py
Normal file
308
app/geocoding.py
Normal file
@@ -0,0 +1,308 @@
|
||||
"""
|
||||
Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates,
|
||||
and reverse geocode coordinates to addresses.
|
||||
|
||||
All services use 高德 (Amap) API exclusively.
|
||||
- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位
|
||||
- Reverse geocoding (coords → address): 高德逆地理编码
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import logging
|
||||
import math
|
||||
from collections import OrderedDict
|
||||
from typing import Optional
|
||||
|
||||
import aiohttp
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
from app.config import settings as _settings
|
||||
|
||||
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||||
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
||||
|
||||
_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."""
|
||||
|
||||
def __init__(self, maxsize: int = _CACHE_MAX_SIZE):
|
||||
super().__init__()
|
||||
self._maxsize = maxsize
|
||||
|
||||
def get_cached(self, key):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
return self[key]
|
||||
return None
|
||||
|
||||
def put(self, key, value):
|
||||
if key in self:
|
||||
self.move_to_end(key)
|
||||
self[key] = value
|
||||
while len(self) > self._maxsize:
|
||||
self.popitem(last=False)
|
||||
|
||||
|
||||
_cell_cache: LRUCache = LRUCache()
|
||||
_wifi_cache: LRUCache = LRUCache()
|
||||
_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,
|
||||
lac: Optional[int] = None,
|
||||
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.
|
||||
|
||||
Uses 高德智能硬件定位 API exclusively.
|
||||
"""
|
||||
# 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
|
||||
|
||||
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
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_amap(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Use 高德智能硬件定位 API (apilocate.amap.com/position).
|
||||
|
||||
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"
|
||||
|
||||
# Build nearbts (neighbor cells)
|
||||
nearbts_parts = []
|
||||
if neighbor_cells:
|
||||
for nc in neighbor_cells:
|
||||
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}")
|
||||
|
||||
# Build macs (WiFi APs): mac,signal,ssid
|
||||
macs_parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
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.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("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:
|
||||
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("Amap geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
# ===========================================================================
|
||||
# Reverse Geocoding: coordinates → address
|
||||
# ===========================================================================
|
||||
|
||||
|
||||
async def reverse_geocode(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Convert lat/lon (WGS-84) to a human-readable address.
|
||||
|
||||
Uses 高德逆地理编码 API exclusively.
|
||||
"""
|
||||
cache_key = (round(lat, 3), round(lon, 3))
|
||||
cached = _address_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
if AMAP_KEY:
|
||||
result = await _reverse_geocode_amap(lat, lon)
|
||||
if result:
|
||||
_address_cache.put(cache_key, result)
|
||||
return result
|
||||
|
||||
return None
|
||||
|
||||
|
||||
async def _reverse_geocode_amap(
|
||||
lat: float, lon: float
|
||||
) -> Optional[str]:
|
||||
"""
|
||||
Use 高德逆地理编码 API.
|
||||
|
||||
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).
|
||||
"""
|
||||
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, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == "1":
|
||||
regeocode = data.get("regeocode", {})
|
||||
formatted = regeocode.get("formatted_address", "")
|
||||
if formatted and formatted != "[]":
|
||||
logger.info(
|
||||
"Amap reverse geocode: %.6f,%.6f -> %s",
|
||||
lat, lon, formatted,
|
||||
)
|
||||
return formatted
|
||||
else:
|
||||
logger.warning(
|
||||
"Amap reverse geocode error: info=%s, infocode=%s",
|
||||
data.get("info", ""), data.get("infocode", ""),
|
||||
)
|
||||
else:
|
||||
logger.warning("Amap reverse geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap reverse geocode error: %s", e)
|
||||
|
||||
return None
|
||||
238
app/main.py
Normal file
238
app/main.py
Normal file
@@ -0,0 +1,238 @@
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import Depends, FastAPI, Request
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from fastapi.responses import HTMLResponse, JSONResponse
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from contextlib import asynccontextmanager
|
||||
from slowapi import Limiter, _rate_limit_exceeded_handler
|
||||
from slowapi.middleware import SlowAPIMiddleware
|
||||
from slowapi.util import get_remote_address
|
||||
from slowapi.errors import RateLimitExceeded
|
||||
|
||||
from app.database import init_db, async_session, engine
|
||||
from app.tcp_server import tcp_manager
|
||||
from app.config import settings
|
||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding
|
||||
from app.dependencies import verify_api_key, require_write, require_admin
|
||||
|
||||
import asyncio
|
||||
import logging
|
||||
|
||||
logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(name)s - %(levelname)s - %(message)s')
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Rate limiter (shared instance via app.extensions for router access)
|
||||
from app.extensions import limiter
|
||||
|
||||
|
||||
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
|
||||
logger.info("Initializing database...")
|
||||
await init_db()
|
||||
# Reset all devices to offline on startup (stale state from previous run)
|
||||
try:
|
||||
from sqlalchemy import update
|
||||
from app.models import Device
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
await session.execute(update(Device).values(status="offline"))
|
||||
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()
|
||||
|
||||
app = FastAPI(
|
||||
title="KKS Badge Management System / KKS工牌管理系统",
|
||||
description="""
|
||||
## KKS P240 & P241 蓝牙工牌管理后台 API
|
||||
|
||||
### 功能模块 / Features:
|
||||
- **设备管理 / Device Management** - 设备注册、状态监控
|
||||
- **位置数据 / Location Data** - GPS/LBS/WIFI定位数据查询与轨迹回放
|
||||
- **报警管理 / Alarm Management** - SOS、围栏、低电等报警处理
|
||||
- **考勤管理 / Attendance** - 打卡记录查询与统计
|
||||
- **指令管理 / Commands** - 远程指令下发与留言
|
||||
- **蓝牙数据 / Bluetooth** - 蓝牙打卡与定位数据
|
||||
- **信标管理 / Beacons** - 蓝牙信标注册与位置配置
|
||||
- **心跳数据 / Heartbeats** - 设备心跳记录查询
|
||||
|
||||
### 认证 / Authentication:
|
||||
设置 `API_KEY` 环境变量后,所有 `/api/` 请求需携带 `X-API-Key` 请求头。
|
||||
|
||||
### 通讯协议 / Protocol:
|
||||
- TCP端口: {tcp_port} (设备连接)
|
||||
- 支持协议: KKS P240/P241 通讯协议
|
||||
""".format(tcp_port=settings.TCP_PORT),
|
||||
version="1.0.0",
|
||||
docs_url="/docs",
|
||||
redoc_url="/redoc",
|
||||
lifespan=lifespan,
|
||||
)
|
||||
|
||||
# Rate limiter — SlowAPIMiddleware applies default_limits to all routes
|
||||
app.state.limiter = limiter
|
||||
app.add_middleware(SlowAPIMiddleware)
|
||||
app.add_exception_handler(RateLimitExceeded, _rate_limit_exceeded_handler)
|
||||
|
||||
# CORS
|
||||
_origins = [o.strip() for o in settings.CORS_ORIGINS.split(",") if o.strip()]
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=_origins,
|
||||
allow_credentials="*" not in _origins,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
|
||||
# Global exception handler — prevent stack trace leaks
|
||||
@app.exception_handler(Exception)
|
||||
async def global_exception_handler(request: Request, exc: Exception):
|
||||
from fastapi.exceptions import RequestValidationError
|
||||
from starlette.exceptions import HTTPException as StarletteHTTPException
|
||||
# Let FastAPI handle its own exceptions
|
||||
if isinstance(exc, (StarletteHTTPException, RequestValidationError)):
|
||||
raise exc
|
||||
logger.exception("Unhandled exception on %s %s", request.method, request.url.path)
|
||||
return JSONResponse(
|
||||
status_code=500,
|
||||
content={"code": 500, "message": "Internal server error", "data": None},
|
||||
)
|
||||
|
||||
|
||||
# Include API routers (all under /api/ prefix, protected by API key if configured)
|
||||
_api_deps = [verify_api_key] if settings.API_KEY else []
|
||||
|
||||
app.include_router(devices.router, dependencies=[*_api_deps])
|
||||
app.include_router(locations.router, dependencies=[*_api_deps])
|
||||
app.include_router(alarms.router, dependencies=[*_api_deps])
|
||||
app.include_router(attendance.router, dependencies=[*_api_deps])
|
||||
app.include_router(commands.router, dependencies=[*_api_deps])
|
||||
app.include_router(bluetooth.router, dependencies=[*_api_deps])
|
||||
app.include_router(beacons.router, dependencies=[*_api_deps])
|
||||
app.include_router(heartbeats.router, dependencies=[*_api_deps])
|
||||
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")
|
||||
|
||||
|
||||
@app.get("/admin", response_class=HTMLResponse, tags=["Admin"])
|
||||
async def admin_page():
|
||||
"""管理后台页面 / Admin Dashboard"""
|
||||
html_path = _STATIC_DIR / "admin.html"
|
||||
return HTMLResponse(content=html_path.read_text(encoding="utf-8"))
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
async def root():
|
||||
return {
|
||||
"name": settings.APP_NAME,
|
||||
"version": "1.0.0",
|
||||
"docs": "/docs",
|
||||
"redoc": "/redoc",
|
||||
"admin": "/admin",
|
||||
"tcp_port": settings.TCP_PORT,
|
||||
"auth_enabled": settings.API_KEY is not None,
|
||||
}
|
||||
|
||||
|
||||
@app.get("/health", tags=["Root"])
|
||||
async def health():
|
||||
"""Health check with database connectivity test."""
|
||||
db_ok = False
|
||||
try:
|
||||
from sqlalchemy import text
|
||||
async with engine.connect() as conn:
|
||||
await conn.execute(text("SELECT 1"))
|
||||
db_ok = True
|
||||
except Exception:
|
||||
logger.warning("Health check: database unreachable")
|
||||
|
||||
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}
|
||||
342
app/models.py
Normal file
342
app/models.py
Normal file
@@ -0,0 +1,342 @@
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
DateTime,
|
||||
Float,
|
||||
ForeignKey,
|
||||
Index,
|
||||
Integer,
|
||||
String,
|
||||
Text,
|
||||
)
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _utcnow() -> datetime:
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""Registered Bluetooth badge devices."""
|
||||
|
||||
__tablename__ = "devices"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
imei: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||
device_type: Mapped[str] = mapped_column(String(10), nullable=False)
|
||||
name: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="offline", nullable=False)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
last_heartbeat: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_login: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
iccid: Mapped[str | None] = mapped_column(String(30), nullable=True)
|
||||
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
||||
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
locations: Mapped[list["LocationRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
alarms: Mapped[list["AlarmRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
heartbeats: Mapped[list["HeartbeatRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
attendance_records: Mapped[list["AttendanceRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
bluetooth_records: Mapped[list["BluetoothRecord"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
command_logs: Mapped[list["CommandLog"]] = relationship(
|
||||
back_populates="device", cascade="all, delete-orphan", lazy="noload"
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<Device(id={self.id}, imei={self.imei}, status={self.status})>"
|
||||
|
||||
|
||||
class LocationRecord(Base):
|
||||
"""GPS / LBS / WIFI location records."""
|
||||
|
||||
__tablename__ = "location_records"
|
||||
__table_args__ = (
|
||||
Index("ix_location_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
location_type: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False
|
||||
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
neighbor_cells: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
report_mode: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
is_realtime: Mapped[bool] = mapped_column(Boolean, default=True, nullable=False)
|
||||
mileage: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
raw_data: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="locations")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<LocationRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.location_type})>"
|
||||
)
|
||||
|
||||
|
||||
class AlarmRecord(Base):
|
||||
"""Alarm events raised by devices."""
|
||||
|
||||
__tablename__ = "alarm_records"
|
||||
__table_args__ = (
|
||||
Index("ix_alarm_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
alarm_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False
|
||||
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
||||
alarm_source: Mapped[str | None] = mapped_column(
|
||||
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)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
fence_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
acknowledged: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="alarms")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AlarmRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.alarm_type})>"
|
||||
)
|
||||
|
||||
|
||||
class HeartbeatRecord(Base):
|
||||
"""Heartbeat history from devices."""
|
||||
|
||||
__tablename__ = "heartbeat_records"
|
||||
__table_args__ = (
|
||||
Index("ix_heartbeat_device_time", "device_id", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36
|
||||
terminal_info: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
battery_level: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
gsm_signal: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
extension_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="heartbeats")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<HeartbeatRecord(id={self.id}, device_id={self.device_id})>"
|
||||
|
||||
|
||||
class AttendanceRecord(Base):
|
||||
"""Attendance punch records from badges."""
|
||||
|
||||
__tablename__ = "attendance_records"
|
||||
__table_args__ = (
|
||||
Index("ix_attendance_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
attendance_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # clock_in, clock_out
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
speed: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
course: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
gps_satellites: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
battery_level: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
gsm_signal: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mcc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
mnc: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
lac: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
cell_id: Mapped[int | None] = mapped_column(BigInteger, nullable=True)
|
||||
wifi_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
lbs_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="attendance_records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<AttendanceRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.attendance_type})>"
|
||||
)
|
||||
|
||||
|
||||
class BluetoothRecord(Base):
|
||||
"""Bluetooth punch card and location records."""
|
||||
|
||||
__tablename__ = "bluetooth_records"
|
||||
__table_args__ = (
|
||||
Index("ix_bluetooth_device_time", "device_id", "recorded_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
record_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # punch, location
|
||||
protocol_number: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
beacon_mac: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
rssi: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_battery: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
beacon_battery_unit: Mapped[str | None] = mapped_column(String(10), nullable=True)
|
||||
attendance_type: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
bluetooth_data: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
recorded_at: Mapped[datetime] = mapped_column(DateTime, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<BluetoothRecord(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.record_type})>"
|
||||
)
|
||||
|
||||
|
||||
class BeaconConfig(Base):
|
||||
"""Registered Bluetooth beacon configuration for indoor positioning."""
|
||||
|
||||
__tablename__ = "beacon_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
beacon_mac: Mapped[str] = mapped_column(String(20), unique=True, index=True, nullable=False)
|
||||
beacon_uuid: Mapped[str | None] = mapped_column(String(36), nullable=True)
|
||||
beacon_major: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
beacon_minor: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
floor: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
area: Mapped[str | None] = mapped_column(String(100), nullable=True)
|
||||
latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
address: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(String(20), default="active", nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
||||
|
||||
|
||||
class CommandLog(Base):
|
||||
"""Log of commands sent to devices."""
|
||||
|
||||
__tablename__ = "command_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_command_device_status", "device_id", "status"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
command_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
command_content: Mapped[str] = mapped_column(Text, nullable=False)
|
||||
server_flag: Mapped[str] = mapped_column(String(20), nullable=False)
|
||||
response_content: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
status: Mapped[str] = mapped_column(
|
||||
String(20), default="pending", nullable=False
|
||||
) # pending, sent, success, failed
|
||||
sent_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
response_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="command_logs")
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return (
|
||||
f"<CommandLog(id={self.id}, device_id={self.device_id}, "
|
||||
f"type={self.command_type}, status={self.status})>"
|
||||
)
|
||||
|
||||
|
||||
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"<ApiKey(id={self.id}, name={self.name}, permissions={self.permissions})>"
|
||||
20
app/protocol/__init__.py
Normal file
20
app/protocol/__init__.py
Normal file
@@ -0,0 +1,20 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol
|
||||
|
||||
Provides packet parsing, building, and CRC computation for the
|
||||
KKS Bluetooth badge communication protocol over TCP.
|
||||
"""
|
||||
|
||||
from .constants import * # noqa: F401,F403
|
||||
from .crc import crc_itu, verify_crc
|
||||
from .parser import PacketParser
|
||||
from .builder import PacketBuilder
|
||||
|
||||
__all__ = [
|
||||
# CRC
|
||||
"crc_itu",
|
||||
"verify_crc",
|
||||
# Classes
|
||||
"PacketParser",
|
||||
"PacketBuilder",
|
||||
]
|
||||
298
app/protocol/builder.py
Normal file
298
app/protocol/builder.py
Normal file
@@ -0,0 +1,298 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol Packet Builder
|
||||
|
||||
Constructs server response packets for the KKS badge protocol.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
PROTO_HEARTBEAT,
|
||||
PROTO_HEARTBEAT_EXT,
|
||||
PROTO_LBS_ADDRESS_REQ,
|
||||
PROTO_LBS_MULTI_REPLY,
|
||||
PROTO_LOGIN,
|
||||
PROTO_MESSAGE,
|
||||
PROTO_ONLINE_CMD,
|
||||
PROTO_TIME_SYNC,
|
||||
PROTO_TIME_SYNC_2,
|
||||
PROTO_ADDRESS_REPLY_EN,
|
||||
START_MARKER_LONG,
|
||||
START_MARKER_SHORT,
|
||||
STOP_MARKER,
|
||||
)
|
||||
from .crc import crc_itu
|
||||
|
||||
|
||||
class PacketBuilder:
|
||||
"""Builds server response packets for the KKS badge protocol."""
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Core builder
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@staticmethod
|
||||
def build_response(
|
||||
protocol_number: int,
|
||||
serial_number: int,
|
||||
info_content: bytes = b"",
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a complete response packet.
|
||||
|
||||
Packet layout (short form, 0x7878):
|
||||
START(2) + LENGTH(1) + PROTO(1) + INFO(N) + SERIAL(2) + CRC(2) + STOP(2)
|
||||
LENGTH = 1(proto) + N(info) + 2(serial) + 2(crc)
|
||||
|
||||
If the payload exceeds 255 bytes the long form (0x7979, 2-byte
|
||||
length) is used automatically.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
protocol_number : int
|
||||
Protocol number byte.
|
||||
serial_number : int
|
||||
Packet serial number (16-bit).
|
||||
info_content : bytes
|
||||
Information content (may be empty).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
The fully assembled packet.
|
||||
"""
|
||||
proto_byte = struct.pack("B", protocol_number)
|
||||
serial_bytes = struct.pack("!H", serial_number)
|
||||
|
||||
# Payload for length calculation: proto + info + serial + crc
|
||||
payload_len = 1 + len(info_content) + 2 + 2 # proto + info + serial + crc
|
||||
|
||||
if payload_len > 0xFF:
|
||||
# Long packet
|
||||
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 is computed over: length_bytes + proto + info + serial
|
||||
crc_input = length_bytes + proto_byte + info_content + serial_bytes
|
||||
crc_value = crc_itu(crc_input)
|
||||
crc_bytes = struct.pack("!H", crc_value)
|
||||
|
||||
return (
|
||||
start_marker
|
||||
+ length_bytes
|
||||
+ proto_byte
|
||||
+ info_content
|
||||
+ serial_bytes
|
||||
+ crc_bytes
|
||||
+ STOP_MARKER
|
||||
)
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Specific response builders
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
def build_login_response(self, serial_number: int) -> bytes:
|
||||
"""
|
||||
Build a login response (0x01).
|
||||
|
||||
The server responds with an empty info content to acknowledge login.
|
||||
"""
|
||||
return self.build_response(PROTO_LOGIN, serial_number)
|
||||
|
||||
def build_heartbeat_response(
|
||||
self,
|
||||
serial_number: int,
|
||||
protocol: int = PROTO_HEARTBEAT,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a heartbeat response.
|
||||
|
||||
Works for both standard heartbeat (0x13) and extended heartbeat (0x36).
|
||||
"""
|
||||
return self.build_response(protocol, serial_number)
|
||||
|
||||
def build_time_sync_response(
|
||||
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 + 2-byte language.
|
||||
For Chinese (0x0001), the timestamp is GMT+8.
|
||||
"""
|
||||
utc_now = int(time.time())
|
||||
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:
|
||||
"""
|
||||
Build a Time Sync 2 response (0x8A).
|
||||
|
||||
Returns the current UTC time as YY MM DD HH MM SS (6 bytes).
|
||||
"""
|
||||
now = datetime.now(timezone.utc)
|
||||
info = struct.pack(
|
||||
"BBBBBB",
|
||||
now.year - 2000,
|
||||
now.month,
|
||||
now.day,
|
||||
now.hour,
|
||||
now.minute,
|
||||
now.second,
|
||||
)
|
||||
return self.build_response(PROTO_TIME_SYNC_2, serial_number, info)
|
||||
|
||||
def build_lbs_multi_response(self, serial_number: int) -> bytes:
|
||||
"""
|
||||
Build an LBS Multi Reply response (0x2E).
|
||||
|
||||
The server acknowledges with an empty info content.
|
||||
"""
|
||||
return self.build_response(PROTO_LBS_MULTI_REPLY, serial_number)
|
||||
|
||||
def build_online_command(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
command: str,
|
||||
language: int = 0x0001,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build an online command packet (0x80).
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
command : str
|
||||
The command string to send (ASCII).
|
||||
language : int
|
||||
Language code (default 0x0001 = Chinese).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
cmd_bytes = command.encode("ascii")
|
||||
# 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
|
||||
info += cmd_bytes # N bytes command
|
||||
info += struct.pack("!H", language) # 2 bytes language
|
||||
|
||||
return self.build_response(PROTO_ONLINE_CMD, serial_number, info)
|
||||
|
||||
def build_message(
|
||||
self,
|
||||
serial_number: int,
|
||||
server_flag: int,
|
||||
message_text: str,
|
||||
language: int = 0x0001,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build a message packet (0x82).
|
||||
|
||||
The message is encoded in UTF-16 Big-Endian.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
serial_number : int
|
||||
Packet serial number.
|
||||
server_flag : int
|
||||
Server flag bits (32-bit).
|
||||
message_text : str
|
||||
The message string to send.
|
||||
language : int
|
||||
Language code (default 0x0001 = Chinese).
|
||||
|
||||
Returns
|
||||
-------
|
||||
bytes
|
||||
Complete packet.
|
||||
"""
|
||||
msg_bytes = message_text.encode("utf-16-be")
|
||||
# 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
|
||||
info += msg_bytes # N bytes message (UTF16BE)
|
||||
info += struct.pack("!H", language) # 2 bytes language
|
||||
|
||||
return self.build_response(PROTO_MESSAGE, serial_number, info)
|
||||
|
||||
def build_address_reply_cn(
|
||||
self,
|
||||
serial_number: int,
|
||||
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 (0x17).
|
||||
|
||||
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")
|
||||
# Phone field: 21 bytes ASCII, zero-padded
|
||||
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
|
||||
|
||||
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 = 0,
|
||||
address: str = "",
|
||||
phone: str = "",
|
||||
protocol: int = PROTO_ADDRESS_REPLY_EN,
|
||||
is_alarm: bool = False,
|
||||
) -> bytes:
|
||||
"""
|
||||
Build an English address reply packet (0x97).
|
||||
|
||||
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")
|
||||
phone_bytes = phone.encode("ascii", errors="ignore")[:21].ljust(21, b"0")
|
||||
|
||||
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)
|
||||
223
app/protocol/constants.py
Normal file
223
app/protocol/constants.py
Normal file
@@ -0,0 +1,223 @@
|
||||
"""
|
||||
KKS Bluetooth Badge Protocol Constants
|
||||
|
||||
Defines all protocol markers, protocol numbers, alarm types,
|
||||
signal strength levels, data report modes, and related mappings.
|
||||
"""
|
||||
|
||||
from typing import Dict, FrozenSet
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Start / Stop Markers
|
||||
# ---------------------------------------------------------------------------
|
||||
START_MARKER_SHORT: bytes = b'\x78\x78' # 1-byte packet length field
|
||||
START_MARKER_LONG: bytes = b'\x79\x79' # 2-byte packet length field
|
||||
STOP_MARKER: bytes = b'\x0D\x0A'
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Numbers
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTO_LOGIN: int = 0x01
|
||||
PROTO_HEARTBEAT: int = 0x13
|
||||
PROTO_LBS_ADDRESS_REQ: int = 0x17
|
||||
PROTO_ADDRESS_QUERY: int = 0x1A
|
||||
PROTO_TIME_SYNC: int = 0x1F
|
||||
PROTO_GPS: int = 0x22
|
||||
PROTO_LBS_MULTI: int = 0x28
|
||||
PROTO_LBS_MULTI_REPLY: int = 0x2E
|
||||
PROTO_WIFI: int = 0x2C
|
||||
PROTO_HEARTBEAT_EXT: int = 0x36
|
||||
PROTO_ONLINE_CMD: int = 0x80
|
||||
PROTO_ONLINE_CMD_REPLY: int = 0x81
|
||||
PROTO_MESSAGE: int = 0x82
|
||||
PROTO_TIME_SYNC_2: int = 0x8A
|
||||
PROTO_GENERAL_INFO: int = 0x94
|
||||
PROTO_ADDRESS_REPLY_EN: int = 0x97
|
||||
PROTO_GPS_4G: int = 0xA0
|
||||
PROTO_LBS_4G: int = 0xA1
|
||||
PROTO_WIFI_4G: int = 0xA2
|
||||
PROTO_ALARM_SINGLE_FENCE: int = 0xA3
|
||||
PROTO_ALARM_MULTI_FENCE: int = 0xA4
|
||||
PROTO_ALARM_LBS_4G: int = 0xA5
|
||||
PROTO_LBS_4G_ADDRESS_REQ: int = 0xA7
|
||||
PROTO_ALARM_ACK: int = 0x26
|
||||
PROTO_ALARM_WIFI: int = 0xA9
|
||||
PROTO_ATTENDANCE: int = 0xB0
|
||||
PROTO_ATTENDANCE_4G: int = 0xB1
|
||||
PROTO_BT_PUNCH: int = 0xB2
|
||||
PROTO_BT_LOCATION: int = 0xB3
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alarm Types (bit-pattern -> name)
|
||||
# ---------------------------------------------------------------------------
|
||||
ALARM_TYPES: Dict[int, str] = {
|
||||
0x00: "normal",
|
||||
0x01: "sos",
|
||||
0x02: "power_cut",
|
||||
0x03: "vibration",
|
||||
0x04: "enter_fence",
|
||||
0x05: "exit_fence",
|
||||
0x06: "over_speed",
|
||||
0x09: "displacement",
|
||||
0x0A: "enter_gps_dead_zone",
|
||||
0x0B: "exit_gps_dead_zone",
|
||||
0x0C: "power_on",
|
||||
0x0D: "gps_first_fix",
|
||||
0x0E: "low_battery",
|
||||
0x0F: "low_battery_protection",
|
||||
0x10: "sim_change",
|
||||
0x11: "power_off",
|
||||
0x12: "airplane_mode",
|
||||
0x13: "remove",
|
||||
0x14: "door",
|
||||
0x15: "shutdown",
|
||||
0x16: "voice_alarm",
|
||||
0x17: "fake_base_station",
|
||||
0x18: "cover_open",
|
||||
0x19: "internal_low_battery",
|
||||
0xFE: "acc_on",
|
||||
0xFF: "acc_off",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GSM Signal Strength Levels
|
||||
# ---------------------------------------------------------------------------
|
||||
GSM_SIGNAL_LEVELS: Dict[int, str] = {
|
||||
0x00: "No Signal",
|
||||
0x01: "Very Weak",
|
||||
0x02: "Weak",
|
||||
0x03: "Good",
|
||||
0x04: "Strong",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Data Report Mode (0x00 - 0x0F)
|
||||
# ---------------------------------------------------------------------------
|
||||
DATA_REPORT_MODES: Dict[int, str] = {
|
||||
0x00: "Timing Upload", # 定时上报
|
||||
0x01: "Distance Upload", # 定距上报
|
||||
0x02: "Turn Point Upload", # 拐点上传
|
||||
0x03: "ACC Status Changed", # ACC状态改变上传
|
||||
0x04: "Last Point After Stop", # 运动→静止补传最后定位点
|
||||
0x05: "Reconnect Upload", # 断网重连上报最后有效点
|
||||
0x06: "Ephemeris Force Upload", # 星历更新强制上传GPS点
|
||||
0x07: "Button Upload", # 按键上传定位点
|
||||
0x08: "Power On Upload", # 开机上报位置信息
|
||||
0x09: "Unused", # 未使用
|
||||
0x0A: "Static Update", # 设备静止后上报(时间更新)
|
||||
0x0B: "WiFi Parsed Upload", # WIFI解析经纬度上传
|
||||
0x0C: "LJDW Upload", # 立即定位指令上报
|
||||
0x0D: "Static Last Point", # 设备静止后上报最后经纬度
|
||||
0x0E: "GPSDUP Upload", # 静止状态定时上传
|
||||
0x0F: "Exit Tracking Mode", # 退出追踪模式
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Numbers That Require a Server Response
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTOCOLS_REQUIRING_RESPONSE: FrozenSet[int] = frozenset({
|
||||
PROTO_LOGIN,
|
||||
PROTO_HEARTBEAT,
|
||||
PROTO_LBS_ADDRESS_REQ,
|
||||
PROTO_ADDRESS_QUERY,
|
||||
PROTO_TIME_SYNC,
|
||||
# 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
|
||||
PROTO_ALARM_SINGLE_FENCE,
|
||||
PROTO_ALARM_MULTI_FENCE,
|
||||
PROTO_ALARM_LBS_4G,
|
||||
PROTO_LBS_4G_ADDRESS_REQ,
|
||||
PROTO_ALARM_WIFI,
|
||||
PROTO_ATTENDANCE,
|
||||
PROTO_ATTENDANCE_4G,
|
||||
PROTO_BT_PUNCH,
|
||||
# Note: PROTO_BT_LOCATION (0xB3) does NOT require a response per protocol spec
|
||||
})
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Defaults
|
||||
# ---------------------------------------------------------------------------
|
||||
DEFAULT_DEVICE_TYPE: str = "P240"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Language Codes (used in 0x80 / 0x82 packets)
|
||||
# ---------------------------------------------------------------------------
|
||||
LANG_CHINESE: int = 0x0001
|
||||
LANG_ENGLISH: int = 0x0002
|
||||
DEFAULT_LANGUAGE: int = LANG_CHINESE
|
||||
DEFAULT_LANGUAGE_BYTES: bytes = b"\x00\x01"
|
||||
|
||||
# Server flag placeholder (4 bytes, used in command/message packets)
|
||||
SERVER_FLAG_BYTES: bytes = b"\x00\x00\x00\x00"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attendance Status (from terminal_info byte, bits[5:2])
|
||||
# ---------------------------------------------------------------------------
|
||||
ATTENDANCE_STATUS_SHIFT: int = 2
|
||||
ATTENDANCE_STATUS_MASK: int = 0x0F
|
||||
ATTENDANCE_TYPES: Dict[int, str] = {
|
||||
0b0001: "clock_in",
|
||||
0b0010: "clock_out",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# GPS Course/Status Bit Fields
|
||||
# ---------------------------------------------------------------------------
|
||||
COURSE_BIT_REALTIME: int = 0x2000 # bit 13
|
||||
COURSE_BIT_POSITIONED: int = 0x1000 # bit 12
|
||||
COURSE_BIT_EAST: int = 0x0800 # bit 11
|
||||
COURSE_BIT_NORTH: int = 0x0400 # bit 10
|
||||
COURSE_MASK: int = 0x03FF # bits 9-0
|
||||
|
||||
# MCC high-bit flag: if set, MNC is 2 bytes instead of 1
|
||||
MCC_MNC2_FLAG: int = 0x8000
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Voltage Levels (0x00-0x06)
|
||||
# ---------------------------------------------------------------------------
|
||||
VOLTAGE_LEVELS: Dict[int, str] = {
|
||||
0x00: "shutdown",
|
||||
0x01: "very_low",
|
||||
0x02: "low",
|
||||
0x03: "medium",
|
||||
0x04: "good",
|
||||
0x05: "high",
|
||||
0x06: "full",
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Protocol Number -> Human-Readable Name
|
||||
# ---------------------------------------------------------------------------
|
||||
PROTOCOL_NAMES: Dict[int, str] = {
|
||||
PROTO_LOGIN: "Login",
|
||||
PROTO_HEARTBEAT: "Heartbeat",
|
||||
PROTO_LBS_ADDRESS_REQ: "LBS Address Request",
|
||||
PROTO_ADDRESS_QUERY: "Address Query",
|
||||
PROTO_TIME_SYNC: "Time Sync",
|
||||
PROTO_ALARM_ACK: "Alarm ACK",
|
||||
PROTO_GPS: "GPS",
|
||||
PROTO_LBS_MULTI: "LBS Multi",
|
||||
PROTO_LBS_MULTI_REPLY: "LBS Multi Reply",
|
||||
PROTO_WIFI: "WIFI",
|
||||
PROTO_HEARTBEAT_EXT: "Heartbeat Extended",
|
||||
PROTO_ONLINE_CMD: "Online Command",
|
||||
PROTO_ONLINE_CMD_REPLY: "Online Command Reply",
|
||||
PROTO_MESSAGE: "Message",
|
||||
PROTO_TIME_SYNC_2: "Time Sync 2",
|
||||
PROTO_GENERAL_INFO: "General Info",
|
||||
PROTO_ADDRESS_REPLY_EN: "Address Reply (EN)",
|
||||
PROTO_GPS_4G: "GPS 4G",
|
||||
PROTO_LBS_4G: "LBS 4G",
|
||||
PROTO_WIFI_4G: "WIFI 4G",
|
||||
PROTO_ALARM_SINGLE_FENCE: "Alarm Single Fence",
|
||||
PROTO_ALARM_MULTI_FENCE: "Alarm Multi Fence",
|
||||
PROTO_ALARM_LBS_4G: "Alarm LBS 4G",
|
||||
PROTO_LBS_4G_ADDRESS_REQ: "LBS 4G Address Request",
|
||||
PROTO_ALARM_WIFI: "Alarm WIFI",
|
||||
PROTO_ATTENDANCE: "Attendance",
|
||||
PROTO_ATTENDANCE_4G: "Attendance 4G",
|
||||
PROTO_BT_PUNCH: "BT Punch",
|
||||
PROTO_BT_LOCATION: "BT Location",
|
||||
}
|
||||
76
app/protocol/crc.py
Normal file
76
app/protocol/crc.py
Normal file
@@ -0,0 +1,76 @@
|
||||
"""
|
||||
CRC-ITU Implementation for KKS Badge Protocol
|
||||
|
||||
Uses CRC-16/X-25 (reflected CRC-CCITT):
|
||||
Polynomial: 0x8408 (reflected 0x1021)
|
||||
Initial value: 0xFFFF
|
||||
Final XOR: 0xFFFF
|
||||
"""
|
||||
|
||||
from typing import List
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Pre-computed CRC lookup table (256 entries, reflected polynomial 0x8408)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_CRC_TABLE: List[int] = []
|
||||
|
||||
|
||||
def _generate_crc_table() -> List[int]:
|
||||
"""Generate the CRC-16/X-25 lookup table for reflected polynomial 0x8408."""
|
||||
table: List[int] = []
|
||||
for i in range(256):
|
||||
crc = i
|
||||
for _ in range(8):
|
||||
if crc & 1:
|
||||
crc = (crc >> 1) ^ 0x8408
|
||||
else:
|
||||
crc >>= 1
|
||||
table.append(crc)
|
||||
return table
|
||||
|
||||
|
||||
_CRC_TABLE = _generate_crc_table()
|
||||
|
||||
|
||||
def crc_itu(data: bytes) -> int:
|
||||
"""
|
||||
Compute the CRC-ITU checksum for the given data.
|
||||
|
||||
Uses the CRC-16/X-25 algorithm (reflected CRC-CCITT with final XOR).
|
||||
For a KKS protocol packet this should be the bytes from (and including)
|
||||
the packet-length field through the serial-number field.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : bytes
|
||||
The data to compute the CRC over.
|
||||
|
||||
Returns
|
||||
-------
|
||||
int
|
||||
16-bit CRC value.
|
||||
"""
|
||||
crc: int = 0xFFFF
|
||||
for byte in data:
|
||||
crc = (crc >> 8) ^ _CRC_TABLE[(crc ^ byte) & 0xFF]
|
||||
return crc ^ 0xFFFF
|
||||
|
||||
|
||||
def verify_crc(data: bytes, expected_crc: int) -> bool:
|
||||
"""
|
||||
Verify that *data* produces the *expected_crc*.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
data : bytes
|
||||
The data slice to check (same range used when computing the CRC).
|
||||
expected_crc : int
|
||||
The 16-bit CRC value to compare against.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
``True`` if the computed CRC matches *expected_crc*.
|
||||
"""
|
||||
return crc_itu(data) == (expected_crc & 0xFFFF)
|
||||
1353
app/protocol/parser.py
Normal file
1353
app/protocol/parser.py
Normal file
File diff suppressed because it is too large
Load Diff
0
app/routers/__init__.py
Normal file
0
app/routers/__init__.py
Normal file
183
app/routers/alarms.py
Normal file
183
app/routers/alarms.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Alarms Router - 报警管理接口
|
||||
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 (
|
||||
AlarmAcknowledge,
|
||||
AlarmRecordResponse,
|
||||
APIResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
|
||||
summary="获取报警记录列表 / List 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, source, acknowledged status, and time range.
|
||||
"""
|
||||
query = select(AlarmRecord)
|
||||
count_query = select(func.count(AlarmRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(AlarmRecord.device_id == device_id)
|
||||
count_query = count_query.where(AlarmRecord.device_id == device_id)
|
||||
|
||||
if alarm_type:
|
||||
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)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AlarmRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AlarmRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AlarmRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * 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())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取报警统计 / Get alarm statistics",
|
||||
)
|
||||
async def alarm_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取报警统计:总数、未确认数、按类型分组统计。
|
||||
Get alarm statistics: total, unacknowledged count, and breakdown by type.
|
||||
"""
|
||||
# Total alarms
|
||||
total_result = await db.execute(select(func.count(AlarmRecord.id)))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Unacknowledged alarms
|
||||
unack_result = await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
|
||||
)
|
||||
unacknowledged = unack_result.scalar() or 0
|
||||
|
||||
# By type
|
||||
type_result = await db.execute(
|
||||
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
|
||||
.group_by(AlarmRecord.alarm_type)
|
||||
.order_by(func.count(AlarmRecord.id).desc())
|
||||
)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"unacknowledged": unacknowledged,
|
||||
"acknowledged": total - unacknowledged,
|
||||
"by_type": by_type,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{alarm_id}",
|
||||
response_model=APIResponse[AlarmRecordResponse],
|
||||
summary="获取报警详情 / Get alarm details",
|
||||
)
|
||||
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取报警记录详情。
|
||||
Get alarm record details by ID.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
|
||||
)
|
||||
alarm = result.scalar_one_or_none()
|
||||
if alarm is None:
|
||||
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
|
||||
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{alarm_id}/acknowledge",
|
||||
response_model=APIResponse[AlarmRecordResponse],
|
||||
summary="确认报警 / Acknowledge alarm",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def acknowledge_alarm(
|
||||
alarm_id: int,
|
||||
body: AlarmAcknowledge | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
确认(或取消确认)报警记录。
|
||||
Acknowledge (or un-acknowledge) an alarm record.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
|
||||
)
|
||||
alarm = result.scalar_one_or_none()
|
||||
if alarm is None:
|
||||
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
|
||||
|
||||
acknowledged = body.acknowledged if body else True
|
||||
alarm.acknowledged = acknowledged
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(alarm)
|
||||
return APIResponse(
|
||||
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
|
||||
data=AlarmRecordResponse.model_validate(alarm),
|
||||
)
|
||||
142
app/routers/api_keys.py
Normal file
142
app/routers/api_keys.py
Normal file
@@ -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 / 密钥已停用")
|
||||
204
app/routers/attendance.py
Normal file
204
app/routers/attendance.py
Normal file
@@ -0,0 +1,204 @@
|
||||
"""
|
||||
Attendance Router - 考勤管理接口
|
||||
API endpoints for attendance record queries and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AttendanceRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AttendanceRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
summary="获取考勤记录列表 / List attendance records",
|
||||
)
|
||||
async def list_attendance(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。
|
||||
List attendance records with filters for device, type, and time range.
|
||||
"""
|
||||
query = select(AttendanceRecord)
|
||||
count_query = select(func.count(AttendanceRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(AttendanceRecord.device_id == device_id)
|
||||
count_query = count_query.where(AttendanceRecord.device_id == device_id)
|
||||
|
||||
if attendance_type:
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AttendanceRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取考勤统计 / Get attendance statistics",
|
||||
)
|
||||
async def attendance_stats(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID (optional)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
|
||||
Get attendance statistics: total records, breakdown by type and by device.
|
||||
"""
|
||||
base_filter = []
|
||||
if device_id is not None:
|
||||
base_filter.append(AttendanceRecord.device_id == device_id)
|
||||
if start_time:
|
||||
base_filter.append(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
base_filter.append(AttendanceRecord.recorded_at <= end_time)
|
||||
|
||||
# Total count
|
||||
total_q = select(func.count(AttendanceRecord.id)).where(*base_filter) if base_filter else select(func.count(AttendanceRecord.id))
|
||||
total_result = await db.execute(total_q)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# By type
|
||||
type_q = select(
|
||||
AttendanceRecord.attendance_type, func.count(AttendanceRecord.id)
|
||||
).group_by(AttendanceRecord.attendance_type)
|
||||
if base_filter:
|
||||
type_q = type_q.where(*base_filter)
|
||||
type_result = await db.execute(type_q)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# By device (top 20)
|
||||
device_q = select(
|
||||
AttendanceRecord.device_id, func.count(AttendanceRecord.id)
|
||||
).group_by(AttendanceRecord.device_id).order_by(
|
||||
func.count(AttendanceRecord.id).desc()
|
||||
).limit(20)
|
||||
if base_filter:
|
||||
device_q = device_q.where(*base_filter)
|
||||
device_result = await db.execute(device_q)
|
||||
by_device = {str(row[0]): row[1] for row in device_result.all()}
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"by_type": by_type,
|
||||
"by_device": by_device,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||
summary="获取设备考勤记录 / Get device attendance records",
|
||||
)
|
||||
async def device_attendance(
|
||||
device_id: int,
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取指定设备的考勤记录。
|
||||
Get attendance records for a specific device.
|
||||
"""
|
||||
# Verify device exists
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
|
||||
query = select(AttendanceRecord).where(AttendanceRecord.device_id == device_id)
|
||||
count_query = select(func.count(AttendanceRecord.id)).where(AttendanceRecord.device_id == device_id)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(AttendanceRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[AttendanceRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{attendance_id}",
|
||||
response_model=APIResponse[AttendanceRecordResponse],
|
||||
summary="获取考勤记录详情 / Get attendance record",
|
||||
)
|
||||
async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取考勤记录详情 / Get attendance record details by ID."""
|
||||
result = await db.execute(
|
||||
select(AttendanceRecord).where(AttendanceRecord.id == attendance_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
||||
104
app/routers/beacons.py
Normal file
104
app/routers/beacons.py
Normal file
@@ -0,0 +1,104 @@
|
||||
"""
|
||||
Beacons Router - 蓝牙信标管理接口
|
||||
API endpoints for managing Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
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,
|
||||
BeaconConfigCreate,
|
||||
BeaconConfigResponse,
|
||||
BeaconConfigUpdate,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import beacon_service
|
||||
|
||||
router = APIRouter(prefix="/api/beacons", tags=["Beacons / 蓝牙信标"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[BeaconConfigResponse]],
|
||||
summary="获取信标列表 / List beacons",
|
||||
)
|
||||
async def list_beacons(
|
||||
status: str | None = Query(default=None, description="状态筛选 (active/inactive)"),
|
||||
search: str | None = Query(default=None, description="搜索 MAC/名称/区域"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
records, total = await beacon_service.get_beacons(
|
||||
db, page=page, page_size=page_size, status_filter=status, search=search
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BeaconConfigResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
summary="获取信标详情 / Get beacon",
|
||||
)
|
||||
async def get_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
beacon = await beacon_service.get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
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)
|
||||
if existing:
|
||||
raise HTTPException(status_code=400, detail=f"Beacon MAC {body.beacon_mac} already exists")
|
||||
beacon = await beacon_service.create_beacon(db, body)
|
||||
return APIResponse(message="Beacon created", data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{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)
|
||||
):
|
||||
beacon = await beacon_service.update_beacon(db, beacon_id, body)
|
||||
if beacon is None:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(message="Beacon updated", data=BeaconConfigResponse.model_validate(beacon))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{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)
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(message="Beacon deleted")
|
||||
159
app/routers/bluetooth.py
Normal file
159
app/routers/bluetooth.py
Normal file
@@ -0,0 +1,159 @@
|
||||
"""
|
||||
Bluetooth Router - 蓝牙数据接口
|
||||
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
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BluetoothRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BluetoothRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/bluetooth", tags=["Bluetooth / 蓝牙数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
|
||||
summary="获取蓝牙记录列表 / List bluetooth records",
|
||||
)
|
||||
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),
|
||||
):
|
||||
"""
|
||||
获取蓝牙数据记录列表,支持按设备、记录类型、信标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))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(BluetoothRecord.device_id == device_id)
|
||||
count_query = count_query.where(BluetoothRecord.device_id == device_id)
|
||||
|
||||
if record_type:
|
||||
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)
|
||||
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * 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())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BluetoothRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
|
||||
summary="获取设备蓝牙记录 / Get bluetooth records for device",
|
||||
)
|
||||
async def device_bluetooth_records(
|
||||
device_id: int,
|
||||
record_type: str | None = Query(default=None, description="记录类型 / Record type (punch/location)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time"),
|
||||
page: int = Query(default=1, ge=1, description="页码"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取指定设备的蓝牙数据记录。
|
||||
Get Bluetooth records for a specific device.
|
||||
"""
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
|
||||
query = select(BluetoothRecord).where(BluetoothRecord.device_id == device_id)
|
||||
count_query = select(func.count(BluetoothRecord.id)).where(BluetoothRecord.device_id == device_id)
|
||||
|
||||
if record_type:
|
||||
query = query.where(BluetoothRecord.record_type == record_type)
|
||||
count_query = count_query.where(BluetoothRecord.record_type == record_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(BluetoothRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[BluetoothRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
# NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
response_model=APIResponse[BluetoothRecordResponse],
|
||||
summary="获取蓝牙记录详情 / Get bluetooth record",
|
||||
)
|
||||
async def get_bluetooth_record(record_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取蓝牙记录详情 / Get bluetooth record details by ID."""
|
||||
result = await db.execute(
|
||||
select(BluetoothRecord).where(BluetoothRecord.id == record_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Bluetooth record {record_id} not found")
|
||||
return APIResponse(data=BluetoothRecordResponse.model_validate(record))
|
||||
330
app/routers/commands.py
Normal file
330
app/routers/commands.py
Normal file
@@ -0,0 +1,330 @@
|
||||
"""
|
||||
Commands Router - 指令管理接口
|
||||
API endpoints for sending commands / messages to devices and viewing command history.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.config import settings
|
||||
from app.extensions import limiter
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchCommandRequest,
|
||||
BatchCommandResponse,
|
||||
BatchCommandResult,
|
||||
CommandResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.dependencies import require_write
|
||||
from app.services import command_service, device_service
|
||||
from app.services import tcp_command_service
|
||||
|
||||
router = APIRouter(prefix="/api/commands", tags=["Commands / 指令管理"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Request schemas specific to this router
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SendCommandRequest(BaseModel):
|
||||
"""Request body for sending a command to a device."""
|
||||
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
||||
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
||||
command_type: str = Field(..., max_length=30, description="指令类型 / Command type (e.g. online_cmd)")
|
||||
command_content: str = Field(..., max_length=500, description="指令内容 / Command content")
|
||||
|
||||
|
||||
class SendMessageRequest(BaseModel):
|
||||
"""Request body for sending a message (0x82) to a device."""
|
||||
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
||||
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
||||
message: str = Field(..., max_length=500, description="消息内容 / Message content")
|
||||
|
||||
|
||||
class SendTTSRequest(BaseModel):
|
||||
"""Request body for sending a TTS voice broadcast to a device."""
|
||||
device_id: int | None = Field(None, description="设备ID / Device ID (provide device_id or imei)")
|
||||
imei: str | None = Field(None, description="IMEI号 / IMEI number (provide device_id or imei)")
|
||||
text: str = Field(..., min_length=1, max_length=200, description="语音播报文本 / TTS text content")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _resolve_device(
|
||||
db: AsyncSession,
|
||||
device_id: int | None,
|
||||
imei: str | None,
|
||||
):
|
||||
"""Resolve a device from either device_id or imei. Returns the Device ORM instance."""
|
||||
if device_id is None and imei is None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="Either device_id or imei must be provided / 必须提供 device_id 或 imei",
|
||||
)
|
||||
|
||||
if device_id is not None:
|
||||
device = await device_service.get_device(db, device_id)
|
||||
else:
|
||||
device = await device_service.get_device_by_imei(db, imei)
|
||||
|
||||
if device is None:
|
||||
identifier = f"ID={device_id}" if device_id else f"IMEI={imei}"
|
||||
raise HTTPException(
|
||||
status_code=404,
|
||||
detail=f"Device {identifier} not found / 未找到设备 {identifier}",
|
||||
)
|
||||
return device
|
||||
|
||||
|
||||
async def _send_to_device(
|
||||
db: AsyncSession,
|
||||
device,
|
||||
command_type: str,
|
||||
command_content: str,
|
||||
executor,
|
||||
success_msg: str,
|
||||
fail_msg: str,
|
||||
):
|
||||
"""Common logic for sending command/message/tts to a device via TCP.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
executor : async callable
|
||||
The actual send function, e.g. tcp_command_service.send_command(...)
|
||||
"""
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} is not online / 设备 {device.imei} 不在线",
|
||||
)
|
||||
|
||||
command_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
command_type=command_type,
|
||||
command_content=command_content,
|
||||
)
|
||||
|
||||
try:
|
||||
await executor()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("Command send failed: %s", e)
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=500,
|
||||
detail=fail_msg,
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
command_log.sent_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
return APIResponse(
|
||||
message=success_msg,
|
||||
data=CommandResponse.model_validate(command_log),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[CommandResponse]],
|
||||
summary="获取指令历史 / List command history",
|
||||
)
|
||||
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, command type and status filters.
|
||||
"""
|
||||
commands, total = await command_service.get_commands(
|
||||
db, device_id=device_id, command_type=command_type, status=status, page=page, page_size=page_size
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[CommandResponse.model_validate(c) for c in commands],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/send",
|
||||
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)):
|
||||
"""
|
||||
向设备发送指令(通过TCP连接下发)。
|
||||
Send a command to a device via the TCP connection.
|
||||
Requires the device to be online.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type=body.command_type,
|
||||
command_content=body.command_content,
|
||||
executor=lambda: tcp_command_service.send_command(
|
||||
device.imei, body.command_type, body.command_content
|
||||
),
|
||||
success_msg="Command sent successfully / 指令发送成功",
|
||||
fail_msg="Failed to send command / 指令发送失败",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/message",
|
||||
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)):
|
||||
"""
|
||||
向设备发送留言消息(协议号 0x82)。
|
||||
Send a text message to a device using protocol 0x82.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type="message",
|
||||
command_content=body.message,
|
||||
executor=lambda: tcp_command_service.send_message(device.imei, body.message),
|
||||
success_msg="Message sent successfully / 留言发送成功",
|
||||
fail_msg="Failed to send message / 留言发送失败",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/tts",
|
||||
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)):
|
||||
"""
|
||||
向设备发送 TTS 语音播报(通过 0x80 在线指令,TTS 命令格式)。
|
||||
Send a TTS voice broadcast to a device via online command (0x80).
|
||||
The device will use its built-in TTS engine to speak the text aloud.
|
||||
"""
|
||||
device = await _resolve_device(db, body.device_id, body.imei)
|
||||
tts_command = f"TTS,{body.text}"
|
||||
return await _send_to_device(
|
||||
db, device,
|
||||
command_type="tts",
|
||||
command_content=tts_command,
|
||||
executor=lambda: tcp_command_service.send_command(
|
||||
device.imei, "tts", tts_command
|
||||
),
|
||||
success_msg="TTS sent successfully / 语音下发成功",
|
||||
fail_msg="Failed to send TTS / 语音下发失败",
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch",
|
||||
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)):
|
||||
"""
|
||||
向多台设备批量发送同一指令,最多100台。
|
||||
Send the same command to multiple devices (up to 100). Skips offline devices.
|
||||
"""
|
||||
# Resolve devices in single query (mutual exclusion validated by schema)
|
||||
if body.device_ids:
|
||||
devices = await device_service.get_devices_by_ids(db, body.device_ids)
|
||||
else:
|
||||
devices = await device_service.get_devices_by_imeis(db, body.imeis)
|
||||
|
||||
results = []
|
||||
for device in devices:
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=False, error="Device offline",
|
||||
))
|
||||
continue
|
||||
|
||||
try:
|
||||
cmd_log = await command_service.create_command(
|
||||
db,
|
||||
device_id=device.id,
|
||||
command_type=body.command_type,
|
||||
command_content=body.command_content,
|
||||
)
|
||||
await tcp_command_service.send_command(
|
||||
device.imei, body.command_type, body.command_content
|
||||
)
|
||||
cmd_log.status = "sent"
|
||||
cmd_log.sent_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(cmd_log)
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=True, command_id=cmd_log.id,
|
||||
))
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("Batch cmd failed for %s: %s", device.imei, e)
|
||||
results.append(BatchCommandResult(
|
||||
device_id=device.id, imei=device.imei,
|
||||
success=False, error="Send failed",
|
||||
))
|
||||
|
||||
sent = sum(1 for r in results if r.success)
|
||||
failed = len(results) - sent
|
||||
return APIResponse(
|
||||
message=f"Batch command: {sent} sent, {failed} failed",
|
||||
data=BatchCommandResponse(
|
||||
total=len(results), sent=sent, failed=failed, results=results,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{command_id}",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
summary="获取指令详情 / Get command details",
|
||||
)
|
||||
async def get_command(command_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取指令详情。
|
||||
Get command log details by ID.
|
||||
"""
|
||||
command = await command_service.get_command(db, command_id)
|
||||
if command is None:
|
||||
raise HTTPException(status_code=404, detail=f"Command {command_id} not found / 未找到指令{command_id}")
|
||||
return APIResponse(data=CommandResponse.model_validate(command))
|
||||
253
app/routers/devices.py
Normal file
253
app/routers/devices.py
Normal file
@@ -0,0 +1,253 @@
|
||||
"""
|
||||
Devices Router - 设备管理接口
|
||||
API endpoints for device CRUD operations and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchDeviceCreateRequest,
|
||||
BatchDeviceCreateResponse,
|
||||
BatchDeviceCreateResult,
|
||||
BatchDeviceDeleteRequest,
|
||||
BatchDeviceUpdateRequest,
|
||||
DeviceCreate,
|
||||
DeviceResponse,
|
||||
DeviceUpdate,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.config import settings
|
||||
from app.dependencies import require_write
|
||||
from app.extensions import limiter
|
||||
from app.schemas import LocationRecordResponse
|
||||
from app.services import device_service, location_service
|
||||
|
||||
router = APIRouter(prefix="/api/devices", tags=["Devices / 设备管理"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[DeviceResponse]],
|
||||
summary="获取设备列表 / List devices",
|
||||
)
|
||||
async def list_devices(
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
status: str | None = Query(default=None, description="状态过滤 / Status filter (online/offline)"),
|
||||
search: str | None = Query(default=None, description="搜索IMEI或名称 / Search by IMEI or name"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取设备列表,支持分页、状态过滤和搜索。
|
||||
List devices with pagination, optional status filter, and search.
|
||||
"""
|
||||
devices, total = await device_service.get_devices(
|
||||
db, page=page, page_size=page_size, status_filter=status, search=search
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[DeviceResponse.model_validate(d) for d in devices],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取设备统计 / Get device statistics",
|
||||
)
|
||||
async def device_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取设备统计信息:总数、在线、离线。
|
||||
Get device statistics: total, online, offline counts.
|
||||
"""
|
||||
stats = await device_service.get_device_stats(db)
|
||||
return APIResponse(data=stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/imei/{imei}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
summary="按IMEI查询设备 / Get device by IMEI",
|
||||
)
|
||||
async def get_device_by_imei(imei: str, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按IMEI号查询设备信息。
|
||||
Get device details by IMEI number.
|
||||
"""
|
||||
device = await device_service.get_device_by_imei(db, imei)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device with IMEI {imei} not found / 未找到IMEI为{imei}的设备")
|
||||
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)):
|
||||
"""
|
||||
批量注册设备(最多500台),跳过IMEI重复的设备。
|
||||
Batch register devices (up to 500). Skips devices with duplicate IMEIs.
|
||||
"""
|
||||
results = await device_service.batch_create_devices(db, body.devices)
|
||||
created = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - created
|
||||
return APIResponse(
|
||||
message=f"Batch create: {created} created, {failed} failed",
|
||||
data=BatchDeviceCreateResponse(
|
||||
total=len(results),
|
||||
created=created,
|
||||
failed=failed,
|
||||
results=[BatchDeviceCreateResult(**r) for r in results],
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.put(
|
||||
"/batch",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量更新设备 / Batch update devices",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_update_devices(request: Request, body: BatchDeviceUpdateRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
批量更新设备信息(名称、状态等),最多500台。
|
||||
Batch update device fields (name, status, etc.) for up to 500 devices.
|
||||
"""
|
||||
results = await device_service.batch_update_devices(db, body.device_ids, body.update)
|
||||
updated = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - updated
|
||||
return APIResponse(
|
||||
message=f"Batch update: {updated} updated, {failed} failed",
|
||||
data={"total": len(results), "updated": updated, "failed": failed, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除设备 / Batch delete devices",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def batch_delete_devices(
|
||||
request: Request,
|
||||
body: BatchDeviceDeleteRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量删除设备(最多100台)。通过 POST body 传递 device_ids 列表。
|
||||
Batch delete devices (up to 100). Pass device_ids in request body.
|
||||
"""
|
||||
results = await device_service.batch_delete_devices(db, body.device_ids)
|
||||
deleted = sum(1 for r in results if r["success"])
|
||||
failed = len(results) - deleted
|
||||
return APIResponse(
|
||||
message=f"Batch delete: {deleted} deleted, {failed} failed",
|
||||
data={"total": len(results), "deleted": deleted, "failed": failed, "results": results},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
summary="获取设备详情 / Get device details",
|
||||
)
|
||||
async def get_device(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取设备详细信息。
|
||||
Get device details by ID.
|
||||
"""
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
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)):
|
||||
"""
|
||||
手动注册新设备。
|
||||
Manually register a new device.
|
||||
"""
|
||||
# Check for duplicate IMEI
|
||||
existing = await device_service.get_device_by_imei(db, device_data.imei)
|
||||
if existing is not None:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device with IMEI {device_data.imei} already exists / IMEI {device_data.imei} 已存在",
|
||||
)
|
||||
|
||||
device = await device_service.create_device(db, device_data)
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{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)
|
||||
):
|
||||
"""
|
||||
更新设备信息(名称、状态等)。
|
||||
Update device information (name, status, etc.).
|
||||
"""
|
||||
device = await device_service.update_device(db, device_id, device_data)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
return APIResponse(data=DeviceResponse.model_validate(device))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{device_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除设备 / Delete device",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_device(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
删除设备及其关联数据。
|
||||
Delete a device and all associated records.
|
||||
"""
|
||||
deleted = await device_service.delete_device(db, device_id)
|
||||
if not deleted:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
return APIResponse(message="Device deleted successfully / 设备删除成功")
|
||||
55
app/routers/geocoding.py
Normal file
55
app/routers/geocoding.py
Normal file
@@ -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 ""}
|
||||
92
app/routers/heartbeats.py
Normal file
92
app/routers/heartbeats.py
Normal file
@@ -0,0 +1,92 @@
|
||||
"""
|
||||
Heartbeats Router - 心跳数据接口
|
||||
API endpoints for querying device heartbeat records.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import HeartbeatRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
HeartbeatRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service
|
||||
|
||||
router = APIRouter(prefix="/api/heartbeats", tags=["Heartbeats / 心跳数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[HeartbeatRecordResponse]],
|
||||
summary="获取心跳记录列表 / List heartbeat records",
|
||||
)
|
||||
async def list_heartbeats(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取心跳记录列表,支持按设备和时间范围过滤。
|
||||
List heartbeat records with optional device and time range filters.
|
||||
"""
|
||||
query = select(HeartbeatRecord)
|
||||
count_query = select(func.count(HeartbeatRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(HeartbeatRecord.device_id == device_id)
|
||||
count_query = count_query.where(HeartbeatRecord.device_id == device_id)
|
||||
|
||||
if start_time:
|
||||
query = query.where(HeartbeatRecord.created_at >= start_time)
|
||||
count_query = count_query.where(HeartbeatRecord.created_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(HeartbeatRecord.created_at <= end_time)
|
||||
count_query = count_query.where(HeartbeatRecord.created_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(HeartbeatRecord.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[HeartbeatRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{heartbeat_id}",
|
||||
response_model=APIResponse[HeartbeatRecordResponse],
|
||||
summary="获取心跳详情 / Get heartbeat details",
|
||||
)
|
||||
async def get_heartbeat(heartbeat_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
按ID获取心跳记录详情。
|
||||
Get heartbeat record details by ID.
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(HeartbeatRecord).where(HeartbeatRecord.id == heartbeat_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Heartbeat {heartbeat_id} not found")
|
||||
return APIResponse(data=HeartbeatRecordResponse.model_validate(record))
|
||||
155
app/routers/locations.py
Normal file
155
app/routers/locations.py
Normal file
@@ -0,0 +1,155 @@
|
||||
"""
|
||||
Locations Router - 位置数据接口
|
||||
API endpoints for querying location records and device tracks.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import LocationRecord
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
LocationRecordResponse,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import device_service, location_service
|
||||
|
||||
router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[LocationRecordResponse]],
|
||||
summary="获取位置记录列表 / List location records",
|
||||
)
|
||||
async def list_locations(
|
||||
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
|
||||
location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
|
||||
page: int = Query(default=1, ge=1, description="页码 / Page number"),
|
||||
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取位置记录列表,支持按设备、定位类型、时间范围过滤。
|
||||
List location records with filters for device, location type, and time range.
|
||||
"""
|
||||
records, total = await location_service.get_locations(
|
||||
db,
|
||||
device_id=device_id,
|
||||
location_type=location_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[LocationRecordResponse.model_validate(r) for r in records],
|
||||
total=total,
|
||||
page=page,
|
||||
page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/latest/{device_id}",
|
||||
response_model=APIResponse[LocationRecordResponse | None],
|
||||
summary="获取设备最新位置 / Get latest location",
|
||||
)
|
||||
async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
获取指定设备的最新位置信息。
|
||||
Get the most recent location record for a device.
|
||||
"""
|
||||
# Verify device exists
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
|
||||
record = await location_service.get_latest_location(db, device_id)
|
||||
if record is None:
|
||||
return APIResponse(
|
||||
code=0,
|
||||
message="No location data available / 暂无位置数据",
|
||||
data=None,
|
||||
)
|
||||
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]],
|
||||
summary="获取设备轨迹 / Get device track",
|
||||
)
|
||||
async def device_track(
|
||||
device_id: int,
|
||||
start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"),
|
||||
end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"),
|
||||
max_points: int = Query(default=10000, ge=1, le=50000, description="最大轨迹点数 / Max track points"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取设备在指定时间范围内的运动轨迹(按时间正序排列)。
|
||||
Get device movement track within a time range (ordered chronologically).
|
||||
"""
|
||||
# Verify device exists
|
||||
device = await device_service.get_device(db, device_id)
|
||||
if device is None:
|
||||
raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}")
|
||||
|
||||
if start_time >= end_time:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail="start_time must be before end_time / 开始时间必须早于结束时间",
|
||||
)
|
||||
|
||||
records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points)
|
||||
return APIResponse(
|
||||
data=[LocationRecordResponse.model_validate(r) for r in records]
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse[LocationRecordResponse],
|
||||
summary="获取位置记录详情 / Get location record",
|
||||
)
|
||||
async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID获取位置记录详情 / Get location record details by ID."""
|
||||
result = await db.execute(
|
||||
select(LocationRecord).where(LocationRecord.id == location_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
81
app/routers/ws.py
Normal file
81
app/routers/ws.py
Normal file
@@ -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)
|
||||
515
app/schemas.py
Normal file
515
app/schemas.py
Normal file
@@ -0,0 +1,515 @@
|
||||
from datetime import datetime
|
||||
from typing import Any, Generic, Literal, TypeVar
|
||||
|
||||
from pydantic import BaseModel, ConfigDict, Field, model_validator
|
||||
|
||||
T = TypeVar("T")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Generic API response wrapper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class APIResponse(BaseModel, Generic[T]):
|
||||
"""Standard envelope for every API response."""
|
||||
|
||||
code: int = 0
|
||||
message: str = "success"
|
||||
data: T | None = None
|
||||
|
||||
|
||||
class PaginationParams(BaseModel):
|
||||
"""Query parameters for paginated list endpoints."""
|
||||
|
||||
page: int = Field(default=1, ge=1, description="Page number (1-indexed)")
|
||||
page_size: int = Field(default=20, ge=1, le=100, description="Items per page")
|
||||
|
||||
|
||||
class PaginatedList(BaseModel, Generic[T]):
|
||||
"""Paginated result set."""
|
||||
|
||||
items: list[T]
|
||||
total: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceBase(BaseModel):
|
||||
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number (digits only)")
|
||||
device_type: str = Field(..., max_length=10, description="Device type code (e.g. P240, P241)")
|
||||
name: str | None = Field(None, max_length=100, description="Friendly name")
|
||||
timezone: str = Field(default="+8", max_length=30)
|
||||
language: str = Field(default="cn", max_length=10)
|
||||
|
||||
|
||||
class DeviceCreate(DeviceBase):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
iccid: str | None = Field(None, max_length=30)
|
||||
imsi: str | None = Field(None, max_length=20)
|
||||
timezone: str | None = Field(None, max_length=30)
|
||||
language: str | None = Field(None, max_length=10)
|
||||
|
||||
|
||||
class DeviceResponse(DeviceBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
status: str
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
last_heartbeat: datetime | None = None
|
||||
last_login: datetime | None = None
|
||||
iccid: str | None = None
|
||||
imsi: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceListResponse(APIResponse[PaginatedList[DeviceResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
class DeviceSingleResponse(APIResponse[DeviceResponse]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Location Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class LocationRecordBase(BaseModel):
|
||||
device_id: int
|
||||
location_type: str = Field(..., max_length=10)
|
||||
latitude: float | None = Field(None, ge=-90, le=90)
|
||||
longitude: float | None = Field(None, ge=-180, le=180)
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
gps_satellites: int | None = None
|
||||
gps_positioned: bool = False
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
rssi: int | None = None
|
||||
neighbor_cells: list[dict[str, Any]] | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
report_mode: int | None = None
|
||||
is_realtime: bool = True
|
||||
mileage: float | None = None
|
||||
address: str | None = None
|
||||
raw_data: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class LocationRecordCreate(LocationRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class LocationRecordResponse(LocationRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class LocationRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
location_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class LocationListResponse(APIResponse[PaginatedList[LocationRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alarm Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlarmRecordBase(BaseModel):
|
||||
device_id: int
|
||||
alarm_type: str = Field(..., max_length=30)
|
||||
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)
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
fence_data: dict[str, Any] | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
address: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AlarmRecordCreate(AlarmRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class AlarmRecordResponse(AlarmRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
acknowledged: bool
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AlarmAcknowledge(BaseModel):
|
||||
acknowledged: bool = True
|
||||
|
||||
|
||||
class AlarmRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
alarm_type: str | None = None
|
||||
acknowledged: bool | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class AlarmListResponse(APIResponse[PaginatedList[AlarmRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Heartbeat Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class HeartbeatRecordBase(BaseModel):
|
||||
device_id: int
|
||||
protocol_number: int
|
||||
terminal_info: int
|
||||
battery_level: int
|
||||
gsm_signal: int
|
||||
extension_data: dict[str, Any] | None = None
|
||||
|
||||
|
||||
class HeartbeatRecordCreate(HeartbeatRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class HeartbeatRecordResponse(HeartbeatRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Attendance Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AttendanceRecordBase(BaseModel):
|
||||
device_id: int
|
||||
attendance_type: str = Field(..., max_length=20)
|
||||
protocol_number: int
|
||||
gps_positioned: bool = False
|
||||
latitude: float | None = Field(None, ge=-90, le=90)
|
||||
longitude: float | None = Field(None, ge=-180, le=180)
|
||||
speed: float | None = None
|
||||
course: float | None = None
|
||||
gps_satellites: int | None = None
|
||||
battery_level: int | None = None
|
||||
gsm_signal: int | None = None
|
||||
mcc: int | None = None
|
||||
mnc: int | None = None
|
||||
lac: int | None = None
|
||||
cell_id: int | None = None
|
||||
wifi_data: list[dict[str, Any]] | None = None
|
||||
lbs_data: list[dict[str, Any]] | None = None
|
||||
address: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class AttendanceRecordCreate(AttendanceRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class AttendanceRecordResponse(AttendanceRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class AttendanceRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
attendance_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class AttendanceListResponse(APIResponse[PaginatedList[AttendanceRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Bluetooth Record schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BluetoothRecordBase(BaseModel):
|
||||
device_id: int
|
||||
record_type: str = Field(..., max_length=20)
|
||||
protocol_number: int
|
||||
beacon_mac: str | None = None
|
||||
beacon_uuid: str | None = None
|
||||
beacon_major: int | None = None
|
||||
beacon_minor: int | None = None
|
||||
rssi: int | None = None
|
||||
beacon_battery: float | None = None
|
||||
beacon_battery_unit: str | None = None
|
||||
attendance_type: str | None = None
|
||||
bluetooth_data: dict[str, Any] | None = None
|
||||
latitude: float | None = Field(None, ge=-90, le=90)
|
||||
longitude: float | None = Field(None, ge=-180, le=180)
|
||||
recorded_at: datetime
|
||||
|
||||
|
||||
class BluetoothRecordCreate(BluetoothRecordBase):
|
||||
pass
|
||||
|
||||
|
||||
class BluetoothRecordResponse(BluetoothRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class BluetoothRecordFilters(BaseModel):
|
||||
device_id: int | None = None
|
||||
record_type: str | None = None
|
||||
start_time: datetime | None = None
|
||||
end_time: datetime | None = None
|
||||
|
||||
|
||||
class BluetoothListResponse(APIResponse[PaginatedList[BluetoothRecordResponse]]):
|
||||
pass
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Beacon Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
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, description="iBeacon UUID")
|
||||
beacon_major: int | None = Field(None, ge=0, le=65535, description="iBeacon Major")
|
||||
beacon_minor: int | None = Field(None, ge=0, le=65535, description="iBeacon Minor")
|
||||
name: str = Field(..., max_length=100, description="信标名称")
|
||||
floor: str | None = Field(None, max_length=20, description="楼层")
|
||||
area: str | None = Field(None, max_length=100, description="区域")
|
||||
latitude: float | None = Field(None, ge=-90, le=90, description="纬度")
|
||||
longitude: float | None = Field(None, ge=-180, le=180, description="经度")
|
||||
address: str | None = Field(None, description="详细地址")
|
||||
status: Literal["active", "inactive"] = Field(default="active", description="状态")
|
||||
|
||||
|
||||
class BeaconConfigCreate(BeaconConfigBase):
|
||||
pass
|
||||
|
||||
|
||||
class BeaconConfigUpdate(BaseModel):
|
||||
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)
|
||||
floor: str | None = Field(None, max_length=20)
|
||||
area: str | None = Field(None, max_length=100)
|
||||
latitude: float | None = Field(None, ge=-90, le=90)
|
||||
longitude: float | None = Field(None, ge=-180, le=180)
|
||||
address: str | None = None
|
||||
status: Literal["active", "inactive"] | None = None
|
||||
|
||||
|
||||
class BeaconConfigResponse(BaseModel):
|
||||
"""Response model — no pattern validation on output (existing data may not conform)."""
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
beacon_mac: str
|
||||
beacon_uuid: str | None = None
|
||||
beacon_major: int | None = None
|
||||
beacon_minor: int | None = None
|
||||
name: str
|
||||
floor: str | None = None
|
||||
area: str | None = None
|
||||
latitude: float | None = None
|
||||
longitude: float | None = None
|
||||
address: str | None = None
|
||||
status: str
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class CommandCreate(BaseModel):
|
||||
device_id: int
|
||||
command_type: str = Field(..., max_length=30)
|
||||
command_content: str = Field(..., max_length=500)
|
||||
server_flag: str = Field(..., max_length=20)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Batch operation schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class BatchDeviceCreateItem(BaseModel):
|
||||
"""Single device in a batch create request."""
|
||||
imei: str = Field(..., min_length=15, max_length=20, pattern=r"^\d{15,17}$", description="IMEI number")
|
||||
device_type: str = Field(default="P241", max_length=10, description="Device type (P240/P241)")
|
||||
name: str | None = Field(None, max_length=100, description="Friendly name")
|
||||
|
||||
|
||||
class BatchDeviceCreateRequest(BaseModel):
|
||||
"""Batch create devices."""
|
||||
devices: list[BatchDeviceCreateItem] = Field(..., min_length=1, max_length=500, description="List of devices to create")
|
||||
|
||||
|
||||
class BatchDeviceCreateResult(BaseModel):
|
||||
"""Result of a single device in batch create."""
|
||||
imei: str
|
||||
success: bool
|
||||
device_id: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BatchDeviceCreateResponse(BaseModel):
|
||||
"""Summary of batch create operation."""
|
||||
total: int
|
||||
created: int
|
||||
failed: int
|
||||
results: list[BatchDeviceCreateResult]
|
||||
|
||||
|
||||
class BatchCommandRequest(BaseModel):
|
||||
"""Send the same command to multiple devices."""
|
||||
device_ids: list[int] | None = Field(default=None, min_length=1, max_length=100, description="Device IDs (provide device_ids or imeis)")
|
||||
imeis: list[str] | None = Field(default=None, min_length=1, max_length=100, description="IMEI list (alternative to device_ids)")
|
||||
command_type: str = Field(..., max_length=30, description="Command type (e.g. online_cmd)")
|
||||
command_content: str = Field(..., max_length=500, description="Command content")
|
||||
|
||||
@model_validator(mode="after")
|
||||
def check_device_ids_or_imeis(self):
|
||||
if not self.device_ids and not self.imeis:
|
||||
raise ValueError("Must provide device_ids or imeis / 必须提供 device_ids 或 imeis")
|
||||
if self.device_ids and self.imeis:
|
||||
raise ValueError("Provide device_ids or imeis, not both / 不能同时提供 device_ids 和 imeis")
|
||||
return self
|
||||
|
||||
|
||||
class BatchCommandResult(BaseModel):
|
||||
"""Result of a single command in batch send."""
|
||||
device_id: int
|
||||
imei: str
|
||||
success: bool
|
||||
command_id: int | None = None
|
||||
error: str | None = None
|
||||
|
||||
|
||||
class BatchCommandResponse(BaseModel):
|
||||
"""Summary of batch command operation."""
|
||||
total: int
|
||||
sent: int
|
||||
failed: int
|
||||
results: list[BatchCommandResult]
|
||||
|
||||
|
||||
class BatchDeviceDeleteRequest(BaseModel):
|
||||
"""Batch delete devices."""
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="Device IDs to delete")
|
||||
|
||||
|
||||
class BatchDeviceUpdateRequest(BaseModel):
|
||||
"""Batch update devices with the same settings."""
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="Device IDs to update")
|
||||
update: DeviceUpdate = Field(..., description="Fields to update")
|
||||
|
||||
|
||||
class CommandUpdate(BaseModel):
|
||||
response_content: str | None = None
|
||||
status: str | None = Field(None, max_length=20)
|
||||
sent_at: datetime | None = None
|
||||
response_at: datetime | None = None
|
||||
|
||||
|
||||
class CommandResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
device_id: int
|
||||
command_type: str
|
||||
command_content: str
|
||||
server_flag: str
|
||||
response_content: str | None = None
|
||||
status: str
|
||||
sent_at: datetime | None = None
|
||||
response_at: datetime | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
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
|
||||
0
app/services/__init__.py
Normal file
0
app/services/__init__.py
Normal file
94
app/services/beacon_service.py
Normal file
94
app/services/beacon_service.py
Normal file
@@ -0,0 +1,94 @@
|
||||
"""
|
||||
Beacon Service - 蓝牙信标管理服务
|
||||
Provides CRUD operations for Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import BeaconConfig
|
||||
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
||||
|
||||
|
||||
async def get_beacons(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[BeaconConfig], int]:
|
||||
"""Get paginated beacon list with optional filters."""
|
||||
query = select(BeaconConfig)
|
||||
count_query = select(func.count(BeaconConfig.id))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(BeaconConfig.status == status_filter)
|
||||
count_query = count_query.where(BeaconConfig.status == status_filter)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = or_(
|
||||
BeaconConfig.beacon_mac.ilike(like),
|
||||
BeaconConfig.name.ilike(like),
|
||||
BeaconConfig.area.ilike(like),
|
||||
)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(BeaconConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_beacon(db: AsyncSession, beacon_id: int) -> BeaconConfig | None:
|
||||
result = await db.execute(
|
||||
select(BeaconConfig).where(BeaconConfig.id == beacon_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_beacon_by_mac(db: AsyncSession, mac: str) -> BeaconConfig | None:
|
||||
result = await db.execute(
|
||||
select(BeaconConfig).where(BeaconConfig.beacon_mac == mac)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_beacon(db: AsyncSession, data: BeaconConfigCreate) -> BeaconConfig:
|
||||
beacon = BeaconConfig(**data.model_dump())
|
||||
db.add(beacon)
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
return beacon
|
||||
|
||||
|
||||
async def update_beacon(
|
||||
db: AsyncSession, beacon_id: int, data: BeaconConfigUpdate
|
||||
) -> BeaconConfig | None:
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return None
|
||||
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(beacon, key, value)
|
||||
beacon.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
return beacon
|
||||
|
||||
|
||||
async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return False
|
||||
await db.delete(beacon)
|
||||
await db.flush()
|
||||
return True
|
||||
110
app/services/command_service.py
Normal file
110
app/services/command_service.py
Normal file
@@ -0,0 +1,110 @@
|
||||
"""
|
||||
Command Service - 指令管理服务
|
||||
Provides CRUD operations for device command logs.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
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.
|
||||
"""
|
||||
query = select(CommandLog)
|
||||
count_query = select(func.count(CommandLog.id))
|
||||
|
||||
if device_id is not None:
|
||||
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)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(CommandLog.created_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
commands = list(result.scalars().all())
|
||||
|
||||
return commands, total
|
||||
|
||||
|
||||
async def create_command(
|
||||
db: AsyncSession,
|
||||
device_id: int,
|
||||
command_type: str,
|
||||
command_content: str,
|
||||
server_flag: str = "badge_admin",
|
||||
) -> CommandLog:
|
||||
"""
|
||||
创建指令记录 / Create a new command log entry.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Target device ID.
|
||||
command_type : str
|
||||
Type of command.
|
||||
command_content : str
|
||||
Command payload content.
|
||||
server_flag : str
|
||||
Server flag identifier.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CommandLog
|
||||
The newly created command log.
|
||||
"""
|
||||
command = CommandLog(
|
||||
device_id=device_id,
|
||||
command_type=command_type,
|
||||
command_content=command_content,
|
||||
server_flag=server_flag,
|
||||
status="pending",
|
||||
)
|
||||
db.add(command)
|
||||
await db.flush()
|
||||
await db.refresh(command)
|
||||
return command
|
||||
|
||||
|
||||
async def get_command(db: AsyncSession, command_id: int) -> CommandLog | None:
|
||||
"""
|
||||
按ID获取指令 / Get command log by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
command_id : int
|
||||
Command log primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
CommandLog | None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(CommandLog).where(CommandLog.id == command_id)
|
||||
)
|
||||
return result.scalar_one_or_none()
|
||||
312
app/services/device_service.py
Normal file
312
app/services/device_service.py
Normal file
@@ -0,0 +1,312 @@
|
||||
"""
|
||||
Device Service - 设备管理服务
|
||||
Provides CRUD operations and statistics for badge devices.
|
||||
"""
|
||||
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device
|
||||
from app.schemas import DeviceCreate, DeviceUpdate, BatchDeviceCreateItem
|
||||
|
||||
|
||||
async def get_devices(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
status_filter: str | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[Device], int]:
|
||||
"""
|
||||
获取设备列表(分页)/ Get paginated device list.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
page : int
|
||||
Page number (1-indexed).
|
||||
page_size : int
|
||||
Number of items per page.
|
||||
status_filter : str, optional
|
||||
Filter by device status (online / offline).
|
||||
search : str, optional
|
||||
Search by IMEI or device name.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[Device], int]
|
||||
(list of devices, total count)
|
||||
"""
|
||||
query = select(Device)
|
||||
count_query = select(func.count(Device.id))
|
||||
|
||||
if status_filter:
|
||||
query = query.where(Device.status == status_filter)
|
||||
count_query = count_query.where(Device.status == status_filter)
|
||||
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
search_clause = or_(
|
||||
Device.imei.ilike(pattern),
|
||||
Device.name.ilike(pattern),
|
||||
)
|
||||
query = query.where(search_clause)
|
||||
count_query = count_query.where(search_clause)
|
||||
|
||||
# Total count
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
# Paginated results
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(Device.updated_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
return devices, total
|
||||
|
||||
|
||||
async def get_device(db: AsyncSession, device_id: int) -> Device | None:
|
||||
"""
|
||||
按ID获取设备 / Get device by ID.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
"""
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def get_device_by_imei(db: AsyncSession, imei: str) -> Device | None:
|
||||
"""
|
||||
按IMEI获取设备 / Get device by IMEI number.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
imei : str
|
||||
Device IMEI number.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
"""
|
||||
result = await db.execute(select(Device).where(Device.imei == imei))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_device(db: AsyncSession, device_data: DeviceCreate) -> Device:
|
||||
"""
|
||||
创建设备 / Create a new device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_data : DeviceCreate
|
||||
Device creation data.
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device
|
||||
The newly created device.
|
||||
"""
|
||||
device = Device(**device_data.model_dump())
|
||||
db.add(device)
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
async def update_device(
|
||||
db: AsyncSession, device_id: int, device_data: DeviceUpdate
|
||||
) -> Device | None:
|
||||
"""
|
||||
更新设备信息 / Update device information.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
device_data : DeviceUpdate
|
||||
Fields to update (only non-None fields are applied).
|
||||
|
||||
Returns
|
||||
-------
|
||||
Device | None
|
||||
The updated device, or None if not found.
|
||||
"""
|
||||
device = await get_device(db, device_id)
|
||||
if device is None:
|
||||
return None
|
||||
|
||||
update_fields = device_data.model_dump(exclude_unset=True)
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(timezone.utc)
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
|
||||
|
||||
async def delete_device(db: AsyncSession, device_id: int) -> bool:
|
||||
"""
|
||||
删除设备 / Delete a device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device primary key.
|
||||
|
||||
Returns
|
||||
-------
|
||||
bool
|
||||
True if the device was deleted, False if not found.
|
||||
"""
|
||||
device = await get_device(db, device_id)
|
||||
if device is None:
|
||||
return False
|
||||
|
||||
await db.delete(device)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
async def get_devices_by_ids(db: AsyncSession, device_ids: list[int]) -> list[Device]:
|
||||
"""Fetch multiple devices by IDs in a single query."""
|
||||
if not device_ids:
|
||||
return []
|
||||
result = await db.execute(select(Device).where(Device.id.in_(device_ids)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_devices_by_imeis(db: AsyncSession, imeis: list[str]) -> list[Device]:
|
||||
"""Fetch multiple devices by IMEIs in a single query."""
|
||||
if not imeis:
|
||||
return []
|
||||
result = await db.execute(select(Device).where(Device.imei.in_(imeis)))
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def batch_create_devices(
|
||||
db: AsyncSession, items: list[BatchDeviceCreateItem]
|
||||
) -> list[dict]:
|
||||
"""Batch create devices, skipping duplicates. Uses single query to check existing."""
|
||||
# One query to find all existing IMEIs
|
||||
imeis = [item.imei for item in items]
|
||||
existing_devices = await get_devices_by_imeis(db, imeis)
|
||||
existing_imeis = {d.imei for d in existing_devices}
|
||||
|
||||
results: list[dict] = [{} for _ in range(len(items))] # preserve input order
|
||||
new_device_indices: list[tuple[int, Device]] = []
|
||||
seen_imeis: set[str] = set()
|
||||
for i, item in enumerate(items):
|
||||
if item.imei in existing_imeis:
|
||||
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": f"IMEI {item.imei} already exists"}
|
||||
continue
|
||||
if item.imei in seen_imeis:
|
||||
results[i] = {"imei": item.imei, "success": False, "device_id": None, "error": "Duplicate IMEI in request"}
|
||||
continue
|
||||
seen_imeis.add(item.imei)
|
||||
device = Device(imei=item.imei, device_type=item.device_type, name=item.name)
|
||||
db.add(device)
|
||||
new_device_indices.append((i, device))
|
||||
|
||||
if new_device_indices:
|
||||
await db.flush()
|
||||
for idx, device in new_device_indices:
|
||||
await db.refresh(device)
|
||||
results[idx] = {"imei": device.imei, "success": True, "device_id": device.id, "error": None}
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def batch_update_devices(
|
||||
db: AsyncSession, device_ids: list[int], update_data: DeviceUpdate
|
||||
) -> list[dict]:
|
||||
"""Batch update devices with the same settings. Uses single query to fetch all."""
|
||||
devices = await get_devices_by_ids(db, device_ids)
|
||||
found_map = {d.id: d for d in devices}
|
||||
update_fields = update_data.model_dump(exclude_unset=True)
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
results = []
|
||||
for device_id in device_ids:
|
||||
device = found_map.get(device_id)
|
||||
if device is None:
|
||||
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
|
||||
continue
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
device.updated_at = now
|
||||
results.append({"device_id": device_id, "success": True, "error": None})
|
||||
|
||||
if any(r["success"] for r in results):
|
||||
await db.flush()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def batch_delete_devices(
|
||||
db: AsyncSession, device_ids: list[int]
|
||||
) -> list[dict]:
|
||||
"""Batch delete devices. Uses single query to fetch all."""
|
||||
devices = await get_devices_by_ids(db, device_ids)
|
||||
found_map = {d.id: d for d in devices}
|
||||
|
||||
results = []
|
||||
for device_id in device_ids:
|
||||
device = found_map.get(device_id)
|
||||
if device is None:
|
||||
results.append({"device_id": device_id, "success": False, "error": f"Device {device_id} not found"})
|
||||
continue
|
||||
await db.delete(device)
|
||||
results.append({"device_id": device_id, "success": True, "error": None})
|
||||
|
||||
if any(r["success"] for r in results):
|
||||
await db.flush()
|
||||
|
||||
return results
|
||||
|
||||
|
||||
async def get_device_stats(db: AsyncSession) -> dict:
|
||||
"""
|
||||
获取设备统计信息 / Get device statistics.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{"total": int, "online": int, "offline": int}
|
||||
"""
|
||||
total_result = await db.execute(select(func.count(Device.id)))
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
online_result = await db.execute(
|
||||
select(func.count(Device.id)).where(Device.status == "online")
|
||||
)
|
||||
online = online_result.scalar() or 0
|
||||
|
||||
offline = total - online
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
}
|
||||
183
app/services/location_service.py
Normal file
183
app/services/location_service.py
Normal file
@@ -0,0 +1,183 @@
|
||||
"""
|
||||
Location Service - 位置数据服务
|
||||
Provides query operations for GPS / LBS / WIFI location records.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import LocationRecord
|
||||
|
||||
|
||||
async def get_locations(
|
||||
db: AsyncSession,
|
||||
device_id: int | None = None,
|
||||
location_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
) -> tuple[list[LocationRecord], int]:
|
||||
"""
|
||||
获取位置记录列表(分页)/ Get paginated location records.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int, optional
|
||||
Filter by device ID.
|
||||
location_type : str, optional
|
||||
Filter by location type (gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g).
|
||||
start_time : datetime, optional
|
||||
Filter records after this time.
|
||||
end_time : datetime, optional
|
||||
Filter records before this time.
|
||||
page : int
|
||||
Page number (1-indexed).
|
||||
page_size : int
|
||||
Number of items per page.
|
||||
|
||||
Returns
|
||||
-------
|
||||
tuple[list[LocationRecord], int]
|
||||
(list of location records, total count)
|
||||
"""
|
||||
query = select(LocationRecord)
|
||||
count_query = select(func.count(LocationRecord.id))
|
||||
|
||||
if device_id is not None:
|
||||
query = query.where(LocationRecord.device_id == device_id)
|
||||
count_query = count_query.where(LocationRecord.device_id == device_id)
|
||||
|
||||
if location_type:
|
||||
query = query.where(LocationRecord.location_type == location_type)
|
||||
count_query = count_query.where(LocationRecord.location_type == location_type)
|
||||
|
||||
if start_time:
|
||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
|
||||
|
||||
if end_time:
|
||||
query = query.where(LocationRecord.recorded_at <= end_time)
|
||||
count_query = count_query.where(LocationRecord.recorded_at <= end_time)
|
||||
|
||||
total_result = await db.execute(count_query)
|
||||
total = total_result.scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
query = query.order_by(LocationRecord.recorded_at.desc()).offset(offset).limit(page_size)
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
return records, total
|
||||
|
||||
|
||||
async def get_latest_location(
|
||||
db: AsyncSession, device_id: int
|
||||
) -> LocationRecord | None:
|
||||
"""
|
||||
获取设备最新位置 / Get the most recent location for a device.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device ID.
|
||||
|
||||
Returns
|
||||
-------
|
||||
LocationRecord | None
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(LocationRecord)
|
||||
.where(LocationRecord.device_id == device_id)
|
||||
.order_by(LocationRecord.recorded_at.desc())
|
||||
.limit(1)
|
||||
)
|
||||
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,
|
||||
start_time: datetime,
|
||||
end_time: datetime,
|
||||
max_points: int = 10000,
|
||||
) -> list[LocationRecord]:
|
||||
"""
|
||||
获取设备轨迹 / Get device movement track within a time range.
|
||||
|
||||
Parameters
|
||||
----------
|
||||
db : AsyncSession
|
||||
Database session.
|
||||
device_id : int
|
||||
Device ID.
|
||||
start_time : datetime
|
||||
Start of time range.
|
||||
end_time : datetime
|
||||
End of time range.
|
||||
|
||||
Returns
|
||||
-------
|
||||
list[LocationRecord]
|
||||
Location records ordered by recorded_at ascending (chronological).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(LocationRecord)
|
||||
.where(
|
||||
LocationRecord.device_id == device_id,
|
||||
LocationRecord.recorded_at >= start_time,
|
||||
LocationRecord.recorded_at <= end_time,
|
||||
)
|
||||
.order_by(LocationRecord.recorded_at.asc())
|
||||
.limit(max_points)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
31
app/services/tcp_command_service.py
Normal file
31
app/services/tcp_command_service.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""
|
||||
TCP Command Service — Abstraction layer for sending commands to devices via TCP.
|
||||
|
||||
Breaks the circular import between routers/commands.py and tcp_server.py
|
||||
by lazily importing tcp_manager only when needed.
|
||||
"""
|
||||
|
||||
import logging
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def _get_tcp_manager():
|
||||
"""Lazily import tcp_manager to avoid circular imports."""
|
||||
from app.tcp_server import tcp_manager
|
||||
return tcp_manager
|
||||
|
||||
|
||||
def is_device_online(imei: str) -> bool:
|
||||
"""Check if a device is currently connected via TCP."""
|
||||
return imei in _get_tcp_manager().connections
|
||||
|
||||
|
||||
async def send_command(imei: str, command_type: str, command_content: str) -> bool:
|
||||
"""Send an online command (0x80) to a connected device."""
|
||||
return await _get_tcp_manager().send_command(imei, command_type, command_content)
|
||||
|
||||
|
||||
async def send_message(imei: str, message: str) -> bool:
|
||||
"""Send a text message (0x82) to a connected device."""
|
||||
return await _get_tcp_manager().send_message(imei, message)
|
||||
2625
app/static/admin.html
Normal file
2625
app/static/admin.html
Normal file
File diff suppressed because it is too large
Load Diff
2497
app/tcp_server.py
Normal file
2497
app/tcp_server.py
Normal file
File diff suppressed because it is too large
Load Diff
89
app/websocket_manager.py
Normal file
89
app/websocket_manager.py
Normal file
@@ -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()
|
||||
1475
doc/KKS_Protocol_P240_P241.md
Normal file
1475
doc/KKS_Protocol_P240_P241.md
Normal file
File diff suppressed because it is too large
Load Diff
12
frpc.toml
Normal file
12
frpc.toml
Normal file
@@ -0,0 +1,12 @@
|
||||
serverAddr = "152.69.205.186"
|
||||
serverPort = 7000
|
||||
|
||||
auth.method = "token"
|
||||
auth.token = "PassWord0325"
|
||||
|
||||
[[proxies]]
|
||||
name = "badge-tcp"
|
||||
type = "tcp"
|
||||
localIP = "127.0.0.1"
|
||||
localPort = 5000
|
||||
remotePort = 5001
|
||||
7
requirements.txt
Normal file
7
requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
||||
fastapi>=0.104.0
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.0
|
||||
aiosqlite>=0.19.0
|
||||
pydantic>=2.0.0
|
||||
pydantic-settings>=2.0.0
|
||||
slowapi
|
||||
10
run.py
Normal file
10
run.py
Normal file
@@ -0,0 +1,10 @@
|
||||
import uvicorn
|
||||
from app.config import settings
|
||||
|
||||
if __name__ == "__main__":
|
||||
uvicorn.run(
|
||||
"app.main:app",
|
||||
host=settings.API_HOST,
|
||||
port=settings.API_PORT,
|
||||
reload=settings.DEBUG,
|
||||
)
|
||||
Reference in New Issue
Block a user