Compare commits
13 Commits
ced836179c
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| 3c370721bb | |||
| 9cd9dd9d76 | |||
| 9daa81621c | |||
| 8157f9cb52 | |||
| b25eafc483 | |||
| b970b78136 | |||
| 61c300bad8 | |||
| a97dcd07a5 | |||
| 3437cd24ea | |||
| 891344bfa0 | |||
| 1d06cc5415 | |||
| cde5146bfe | |||
| d54e53e0b7 |
1023
.claude/CLAUDE.md
Normal file
1023
.claude/CLAUDE.md
Normal file
File diff suppressed because it is too large
Load Diff
20
.env.example
20
.env.example
@@ -25,19 +25,13 @@
|
||||
# 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
|
||||
# 高德地图 API
|
||||
# Web服务 key (逆地理编码 + v5 IoT定位, 企业订阅)
|
||||
# AMAP_KEY=your_amap_web_service_key
|
||||
# AMAP_SECRET=your_amap_web_service_secret
|
||||
# 智能硬件定位 key (旧版 apilocate.amap.com 回退, 可选)
|
||||
# AMAP_HARDWARE_KEY=your_amap_hardware_key
|
||||
# AMAP_HARDWARE_SECRET=your_amap_hardware_secret
|
||||
|
||||
# Geocoding cache size
|
||||
# GEOCODING_CACHE_SIZE=10000
|
||||
|
||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -5,3 +5,4 @@ __pycache__/
|
||||
nohup.out
|
||||
.env
|
||||
.claude/
|
||||
.idea/
|
||||
|
||||
559
CLAUDE.md
559
CLAUDE.md
@@ -1,559 +0,0 @@
|
||||
# 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())
|
||||
"
|
||||
```
|
||||
@@ -1,12 +1,16 @@
|
||||
from datetime import timezone, timedelta
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
from typing import Literal
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
# Beijing time (UTC+8)
|
||||
BEIJING_TZ = timezone(timedelta(hours=8))
|
||||
CST = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_cst() -> datetime:
|
||||
"""Return current time in CST (UTC+8) as naive datetime for SQLite."""
|
||||
return datetime.now(CST).replace(tzinfo=None)
|
||||
|
||||
# Project root directory (where config.py lives → parent = app/ → parent = project root)
|
||||
_PROJECT_ROOT = Path(__file__).resolve().parent.parent
|
||||
@@ -34,8 +38,10 @@ class Settings(BaseSettings):
|
||||
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="高德地图安全密钥")
|
||||
AMAP_KEY: str | None = Field(default=None, description="高德地图 Web服务 key (逆地理编码/POI搜索)")
|
||||
AMAP_SECRET: str | None = Field(default=None, description="高德地图 Web服务安全密钥")
|
||||
AMAP_HARDWARE_KEY: str | None = Field(default=None, description="高德地图智能硬件定位 key (基站/WiFi定位)")
|
||||
AMAP_HARDWARE_SECRET: str | None = Field(default=None, description="高德地图智能硬件定位安全密钥 (与 HARDWARE_KEY 配对)")
|
||||
|
||||
# Geocoding
|
||||
GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API")
|
||||
@@ -44,6 +50,15 @@ class Settings(BaseSettings):
|
||||
# Track query limit
|
||||
TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint")
|
||||
|
||||
# TCP connection
|
||||
TCP_IDLE_TIMEOUT: int = Field(default=600, description="Idle timeout in seconds for TCP connections (0=disabled)")
|
||||
|
||||
# Fence auto-attendance
|
||||
FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report")
|
||||
FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check")
|
||||
FENCE_WIFI_TOLERANCE_METERS: int = Field(default=100, description="Extra tolerance (meters) for WiFi locations in fence check")
|
||||
FENCE_MIN_INSIDE_SECONDS: int = Field(default=60, description="Minimum seconds between fence attendance transitions (debounce)")
|
||||
|
||||
# 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")
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from sqlalchemy import event
|
||||
from sqlalchemy.ext.asyncio import AsyncSession, async_sessionmaker, create_async_engine
|
||||
from sqlalchemy.orm import DeclarativeBase
|
||||
|
||||
@@ -9,6 +10,17 @@ engine = create_async_engine(
|
||||
connect_args={"check_same_thread": False},
|
||||
)
|
||||
|
||||
# Enable WAL mode for concurrent read/write performance
|
||||
@event.listens_for(engine.sync_engine, "connect")
|
||||
def _set_sqlite_pragma(dbapi_conn, connection_record):
|
||||
cursor = dbapi_conn.cursor()
|
||||
cursor.execute("PRAGMA journal_mode=WAL")
|
||||
cursor.execute("PRAGMA synchronous=NORMAL")
|
||||
cursor.execute("PRAGMA cache_size=-64000") # 64MB cache
|
||||
cursor.execute("PRAGMA busy_timeout=5000") # 5s wait on lock
|
||||
cursor.execute("PRAGMA foreign_keys=ON")
|
||||
cursor.close()
|
||||
|
||||
async_session = async_sessionmaker(
|
||||
bind=engine,
|
||||
class_=AsyncSession,
|
||||
@@ -39,6 +51,7 @@ async def init_db() -> None:
|
||||
from app.models import ( # noqa: F401
|
||||
AlarmRecord,
|
||||
AttendanceRecord,
|
||||
AuditLog,
|
||||
BluetoothRecord,
|
||||
CommandLog,
|
||||
Device,
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
"""
|
||||
Shared FastAPI dependencies.
|
||||
Supports master API key (env) and database-managed API keys with permission levels.
|
||||
Includes in-memory cache to avoid DB lookup on every request.
|
||||
"""
|
||||
|
||||
import hashlib
|
||||
import secrets
|
||||
from datetime import datetime
|
||||
import time
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
|
||||
from fastapi import Depends, HTTPException, Security
|
||||
from fastapi import Depends, HTTPException, Request, Security
|
||||
from fastapi.security import APIKeyHeader
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -22,6 +21,10 @@ _api_key_header = APIKeyHeader(name="X-API-Key", auto_error=False)
|
||||
# Permission hierarchy: admin > write > read
|
||||
_PERMISSION_LEVELS = {"read": 1, "write": 2, "admin": 3}
|
||||
|
||||
# In-memory auth cache: {key_hash: (result_dict, expire_timestamp)}
|
||||
_AUTH_CACHE: dict[str, tuple[dict, float]] = {}
|
||||
_AUTH_CACHE_TTL = 60 # seconds
|
||||
|
||||
|
||||
def _hash_key(key: str) -> str:
|
||||
"""SHA-256 hash of an API key."""
|
||||
@@ -29,12 +32,13 @@ def _hash_key(key: str) -> str:
|
||||
|
||||
|
||||
async def verify_api_key(
|
||||
request: Request,
|
||||
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.
|
||||
Checks master key first, then in-memory cache, then database keys.
|
||||
Returns {"permissions": "admin"|"write"|"read", "key_id": int|None, "name": str}.
|
||||
"""
|
||||
if settings.API_KEY is None:
|
||||
@@ -45,24 +49,39 @@ async def verify_api_key(
|
||||
|
||||
# Check master key
|
||||
if secrets.compare_digest(api_key, settings.API_KEY):
|
||||
return {"permissions": "admin", "key_id": None, "name": "master"}
|
||||
info = {"permissions": "admin", "key_id": None, "name": "master"}
|
||||
request.state.key_info = info
|
||||
return info
|
||||
|
||||
# Check in-memory cache first
|
||||
key_hash = _hash_key(api_key)
|
||||
now = time.monotonic()
|
||||
cached = _AUTH_CACHE.get(key_hash)
|
||||
if cached is not None:
|
||||
result, expires = cached
|
||||
if now < expires:
|
||||
return result
|
||||
|
||||
# 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:
|
||||
_AUTH_CACHE.pop(key_hash, None)
|
||||
raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key")
|
||||
|
||||
# Update last_used_at
|
||||
db_key.last_used_at = datetime.now(BEIJING_TZ)
|
||||
# Update last_used_at (deferred — only on cache miss, not every request)
|
||||
from app.config import now_cst
|
||||
db_key.last_used_at = now_cst()
|
||||
await db.flush()
|
||||
|
||||
return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
||||
key_info = {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name}
|
||||
_AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL)
|
||||
request.state.key_info = key_info
|
||||
return key_info
|
||||
|
||||
|
||||
def require_permission(min_level: str):
|
||||
|
||||
317
app/geocoding.py
317
app/geocoding.py
@@ -3,7 +3,7 @@ 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): 高德智能硬件定位
|
||||
- Forward geocoding (cell/WiFi → coords): 高德 IoT 定位 v5 API
|
||||
- Reverse geocoding (coords → address): 高德逆地理编码
|
||||
"""
|
||||
|
||||
@@ -21,10 +21,37 @@ from app.config import settings as _settings
|
||||
|
||||
AMAP_KEY: Optional[str] = _settings.AMAP_KEY
|
||||
AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET
|
||||
AMAP_HARDWARE_KEY: Optional[str] = _settings.AMAP_HARDWARE_KEY
|
||||
AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET
|
||||
|
||||
_CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Shared aiohttp session (reused across all geocoding calls)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_http_session: Optional[aiohttp.ClientSession] = None
|
||||
|
||||
|
||||
async def _get_http_session() -> aiohttp.ClientSession:
|
||||
"""Get or create the shared aiohttp session (lazy init)."""
|
||||
global _http_session
|
||||
if _http_session is None or _http_session.closed:
|
||||
_http_session = aiohttp.ClientSession(
|
||||
timeout=aiohttp.ClientTimeout(total=5),
|
||||
)
|
||||
return _http_session
|
||||
|
||||
|
||||
async def close_http_session() -> None:
|
||||
"""Close the shared session (call on app shutdown)."""
|
||||
global _http_session
|
||||
if _http_session and not _http_session.closed:
|
||||
await _http_session.close()
|
||||
_http_session = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# WGS-84 → GCJ-02 coordinate conversion (server-side)
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -68,6 +95,14 @@ def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]:
|
||||
return (lat + d_lat, lon + d_lon)
|
||||
|
||||
|
||||
def gcj02_to_wgs84(lat: float, lon: float) -> tuple[float, float]:
|
||||
"""Convert GCJ-02 to WGS-84 (reverse of wgs84_to_gcj02)."""
|
||||
if _out_of_china(lat, lon):
|
||||
return (lat, lon)
|
||||
gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon)
|
||||
return (lat * 2 - gcj_lat, lon * 2 - gcj_lon)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# LRU Cache
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -125,52 +160,237 @@ async def geocode_location(
|
||||
wifi_list: Optional[list[dict]] = None,
|
||||
neighbor_cells: Optional[list[dict]] = None,
|
||||
imei: Optional[str] = None,
|
||||
location_type: Optional[str] = None,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Convert cell tower and/or WiFi AP data to lat/lon.
|
||||
|
||||
Uses 高德智能硬件定位 API exclusively.
|
||||
Uses 高德 IoT 定位 v5 API (restapi.amap.com/v5/position/IoT).
|
||||
Falls back to legacy API (apilocate.amap.com/position) if v5 fails.
|
||||
|
||||
location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE).
|
||||
"""
|
||||
# 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)
|
||||
# Build cache key (include neighbor cells hash for accuracy)
|
||||
nb_hash = tuple(sorted((nc.get("lac", 0), nc.get("cell_id", 0)) for nc in neighbor_cells)) if neighbor_cells else ()
|
||||
|
||||
if wifi_list:
|
||||
wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list)))
|
||||
cached = _wifi_cache.get_cached(wifi_cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
elif mcc is not None and lac is not None and cell_id is not None:
|
||||
cache_key = (mcc, mnc or 0, lac, cell_id, nb_hash)
|
||||
cached = _cell_cache.get_cached(cache_key)
|
||||
if cached is not None:
|
||||
return cached
|
||||
|
||||
# Map location_type to v5 network parameter
|
||||
# Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!)
|
||||
_NETWORK_MAP = {"lbs_4g": "WCDMA", "wifi_4g": "WCDMA", "gps_4g": "WCDMA"}
|
||||
network = _NETWORK_MAP.get(location_type or "", "GSM")
|
||||
|
||||
result: tuple[Optional[float], Optional[float]] = (None, None)
|
||||
|
||||
# Try v5 API first (requires bts with cage field + network param)
|
||||
if AMAP_KEY:
|
||||
result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei)
|
||||
result = await _geocode_amap_v5(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
|
||||
imei=imei, api_key=AMAP_KEY, network=network,
|
||||
)
|
||||
|
||||
# Fallback to legacy API if v5 fails
|
||||
if result[0] is None and AMAP_HARDWARE_KEY:
|
||||
result = await _geocode_amap_legacy(
|
||||
mcc, mnc, lac, cell_id, wifi_list, neighbor_cells,
|
||||
imei=imei, api_key=AMAP_HARDWARE_KEY,
|
||||
)
|
||||
|
||||
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)
|
||||
if wifi_list:
|
||||
_wifi_cache.put(wifi_cache_key, result)
|
||||
elif mcc is not None and lac is not None and cell_id is not None:
|
||||
_cell_cache.put(cache_key, 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 = ""
|
||||
def _build_bts(
|
||||
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
|
||||
*, include_cage: bool = False,
|
||||
) -> str:
|
||||
"""Build bts parameter. v5 API uses cage field, legacy does not."""
|
||||
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"
|
||||
base = f"{mcc},{mnc or 0},{lac},{cell_id},-65"
|
||||
return f"{base},0" if include_cage else base
|
||||
return ""
|
||||
|
||||
# Build nearbts (neighbor cells)
|
||||
nearbts_parts = []
|
||||
|
||||
def _build_nearbts(
|
||||
neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int],
|
||||
*, include_cage: bool = False,
|
||||
) -> list[str]:
|
||||
"""Build nearbts (neighbor cell) parts."""
|
||||
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}")
|
||||
base = f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}"
|
||||
parts.append(f"{base},0" if include_cage else base)
|
||||
return parts
|
||||
|
||||
# Build macs (WiFi APs): mac,signal,ssid
|
||||
|
||||
def _build_wifi_parts(wifi_list: Optional[list[dict]]) -> list[str]:
|
||||
"""Build WiFi MAC parts: mac,signal,ssid,fresh"""
|
||||
parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
mac = ap.get("mac", "")
|
||||
# v5 API requires colon-separated lowercase MAC
|
||||
if ":" not in mac:
|
||||
# Convert raw hex to colon-separated
|
||||
mac_clean = mac.lower().replace("-", "")
|
||||
if len(mac_clean) == 12:
|
||||
mac = ":".join(mac_clean[i:i+2] for i in range(0, 12, 2))
|
||||
else:
|
||||
mac = mac.lower()
|
||||
else:
|
||||
mac = mac.lower()
|
||||
signal = -(ap.get("signal", 0)) if ap.get("signal") else -70
|
||||
ssid = ap.get("ssid", "")
|
||||
parts.append(f"{mac},{signal},{ssid},0")
|
||||
return parts
|
||||
|
||||
|
||||
def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]:
|
||||
"""Select strongest WiFi AP as mmac (connected WiFi), rest as macs.
|
||||
|
||||
v5 API requires mmac when accesstype=2.
|
||||
Returns (mmac_str, remaining_macs_parts).
|
||||
"""
|
||||
if not wifi_parts:
|
||||
return ("", [])
|
||||
# Find strongest signal (most negative = weakest, so max of negative values)
|
||||
# Parts format: "mac,signal,ssid,fresh"
|
||||
best_idx = 0
|
||||
best_signal = -999
|
||||
for i, part in enumerate(wifi_parts):
|
||||
fields = part.split(",")
|
||||
if len(fields) >= 2:
|
||||
try:
|
||||
sig = int(fields[1])
|
||||
if sig > best_signal:
|
||||
best_signal = sig
|
||||
best_idx = i
|
||||
except ValueError:
|
||||
pass
|
||||
mmac = wifi_parts[best_idx]
|
||||
remaining = [p for i, p in enumerate(wifi_parts) if i != best_idx]
|
||||
return (mmac, remaining)
|
||||
|
||||
|
||||
async def _geocode_amap_v5(
|
||||
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
|
||||
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
|
||||
*, imei: Optional[str] = None, api_key: str, network: str = "GSM",
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT).
|
||||
|
||||
Key requirements:
|
||||
- POST method, key in URL params, data in form body
|
||||
- bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage
|
||||
- network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!)
|
||||
- For 4G LTE, use WCDMA as network value
|
||||
- accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs)
|
||||
"""
|
||||
bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True)
|
||||
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True)
|
||||
wifi_parts = _build_wifi_parts(wifi_list)
|
||||
|
||||
if not bts and not wifi_parts:
|
||||
return (None, None)
|
||||
|
||||
# Determine accesstype: 2=WiFi (when we have WiFi data), 1=mobile network
|
||||
has_wifi = len(wifi_parts) >= 2 # v5 requires 2+ WiFi APs
|
||||
accesstype = "2" if has_wifi else "1"
|
||||
|
||||
# Build POST body
|
||||
body: dict[str, str] = {
|
||||
"accesstype": accesstype,
|
||||
"cdma": "0",
|
||||
"network": network,
|
||||
"diu": imei or _settings.GEOCODING_DEFAULT_IMEI,
|
||||
"show_fields": "formatted_address",
|
||||
}
|
||||
|
||||
if bts:
|
||||
body["bts"] = bts
|
||||
if nearbts_parts:
|
||||
body["nearbts"] = "|".join(nearbts_parts)
|
||||
|
||||
if has_wifi:
|
||||
mmac, remaining_macs = _select_mmac(wifi_parts)
|
||||
body["mmac"] = mmac
|
||||
if remaining_macs:
|
||||
body["macs"] = "|".join(remaining_macs)
|
||||
elif wifi_parts:
|
||||
# Less than 2 WiFi APs: include as macs anyway, use accesstype=1
|
||||
body["macs"] = "|".join(wifi_parts)
|
||||
|
||||
url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}"
|
||||
|
||||
logger.debug("Amap v5 request body: %s", body)
|
||||
|
||||
try:
|
||||
session = await _get_http_session()
|
||||
async with session.post(url, data=body) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
logger.debug("Amap v5 response: %s", data)
|
||||
if data.get("status") == "1":
|
||||
position = data.get("position", {})
|
||||
location = position.get("location", "") if isinstance(position, dict) else ""
|
||||
if location and "," in location:
|
||||
lon_str, lat_str = location.split(",")
|
||||
gcj_lat = float(lat_str)
|
||||
gcj_lon = float(lon_str)
|
||||
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
|
||||
radius = position.get("radius", "?") if isinstance(position, dict) else "?"
|
||||
logger.info(
|
||||
"Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s",
|
||||
gcj_lat, gcj_lon, lat, lon, radius,
|
||||
)
|
||||
return (lat, lon)
|
||||
else:
|
||||
infocode = data.get("infocode", "")
|
||||
logger.warning(
|
||||
"Amap v5 geocode error: %s (code=%s) body=%s",
|
||||
data.get("info", ""), infocode, body,
|
||||
)
|
||||
else:
|
||||
logger.warning("Amap v5 geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap v5 geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
|
||||
async def _geocode_amap_legacy(
|
||||
mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int],
|
||||
wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]],
|
||||
*, imei: Optional[str] = None, api_key: str,
|
||||
) -> tuple[Optional[float], Optional[float]]:
|
||||
"""
|
||||
Legacy 高德智能硬件定位 API (GET apilocate.amap.com/position).
|
||||
|
||||
Used as fallback when v5 API fails.
|
||||
"""
|
||||
bts = _build_bts(mcc, mnc, lac, cell_id)
|
||||
nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc)
|
||||
|
||||
# Build macs (legacy format without fresh field)
|
||||
macs_parts = []
|
||||
if wifi_list:
|
||||
for ap in wifi_list:
|
||||
@@ -182,7 +402,11 @@ async def _geocode_amap(
|
||||
if not bts and not macs_parts:
|
||||
return (None, None)
|
||||
|
||||
params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY}
|
||||
params: dict[str, str] = {
|
||||
"accesstype": "0",
|
||||
"imei": imei or _settings.GEOCODING_DEFAULT_IMEI,
|
||||
"key": api_key,
|
||||
}
|
||||
if bts:
|
||||
params["bts"] = bts
|
||||
if nearbts_parts:
|
||||
@@ -190,39 +414,46 @@ async def _geocode_amap(
|
||||
if macs_parts:
|
||||
params["macs"] = "|".join(macs_parts)
|
||||
|
||||
# Add digital signature
|
||||
sig = _amap_sign(params)
|
||||
if sig:
|
||||
# Only sign if using a key that has its own secret
|
||||
hw_secret = AMAP_HARDWARE_SECRET
|
||||
if hw_secret:
|
||||
sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys()))
|
||||
sig = hashlib.md5((sorted_str + hw_secret).encode()).hexdigest()
|
||||
params["sig"] = sig
|
||||
|
||||
url = "https://apilocate.amap.com/position"
|
||||
|
||||
logger.debug("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'})
|
||||
|
||||
try:
|
||||
async with aiohttp.ClientSession() as session:
|
||||
async with session.get(
|
||||
url, params=params, timeout=aiohttp.ClientTimeout(total=5)
|
||||
) as resp:
|
||||
session = await _get_http_session()
|
||||
async with session.get(url, params=params) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
logger.debug("Amap legacy response: %s", data)
|
||||
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)
|
||||
gcj_lat = float(lat_str)
|
||||
gcj_lon = float(lon_str)
|
||||
lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon)
|
||||
logger.info(
|
||||
"Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)",
|
||||
gcj_lat, gcj_lon, lat, lon,
|
||||
)
|
||||
return (lat, lon)
|
||||
else:
|
||||
infocode = data.get("infocode", "")
|
||||
if infocode == "10012":
|
||||
logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)")
|
||||
logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)")
|
||||
else:
|
||||
logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||||
logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode)
|
||||
else:
|
||||
logger.warning("Amap geocode HTTP %d", resp.status)
|
||||
logger.warning("Amap legacy geocode HTTP %d", resp.status)
|
||||
except Exception as e:
|
||||
logger.warning("Amap geocode error: %s", e)
|
||||
logger.warning("Amap legacy geocode error: %s", e)
|
||||
|
||||
return (None, None)
|
||||
|
||||
@@ -280,10 +511,8 @@ async def _reverse_geocode_amap(
|
||||
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:
|
||||
session = await _get_http_session()
|
||||
async with session.get(url, params=params) as resp:
|
||||
if resp.status == 200:
|
||||
data = await resp.json(content_type=None)
|
||||
if data.get("status") == "1":
|
||||
|
||||
117
app/main.py
117
app/main.py
@@ -13,7 +13,7 @@ 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.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, heartbeats, api_keys, ws, geocoding, device_groups, alert_rules, system
|
||||
from app.dependencies import verify_api_key, require_write, require_admin
|
||||
|
||||
import asyncio
|
||||
@@ -28,12 +28,12 @@ from app.extensions import limiter
|
||||
|
||||
async def run_data_cleanup():
|
||||
"""Delete records older than DATA_RETENTION_DAYS."""
|
||||
from datetime import datetime, timedelta
|
||||
from app.config import BEIJING_TZ
|
||||
from datetime import timedelta
|
||||
from sqlalchemy import delete
|
||||
from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord
|
||||
from app.config import now_cst
|
||||
|
||||
cutoff = datetime.now(BEIJING_TZ) - timedelta(days=settings.DATA_RETENTION_DAYS)
|
||||
cutoff = now_cst() - timedelta(days=settings.DATA_RETENTION_DAYS)
|
||||
total_deleted = 0
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
@@ -89,9 +89,14 @@ async def lifespan(app: FastAPI):
|
||||
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_alarm_source ON alarm_records(alarm_source)",
|
||||
"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)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_att_source ON attendance_records(attendance_source)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_att_device_type_time ON attendance_records(device_id, attendance_type, recorded_at)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_device_status ON devices(status)",
|
||||
"CREATE INDEX IF NOT EXISTS ix_fence_active ON fence_configs(is_active)",
|
||||
]:
|
||||
await conn.execute(sa_text(stmt))
|
||||
logger.info("Database indexes verified/created")
|
||||
@@ -107,6 +112,9 @@ async def lifespan(app: FastAPI):
|
||||
logger.info("Shutting down TCP server...")
|
||||
await tcp_manager.stop()
|
||||
tcp_task.cancel()
|
||||
# Close shared HTTP session
|
||||
from app.geocoding import close_http_session
|
||||
await close_http_session()
|
||||
|
||||
app = FastAPI(
|
||||
title="KKS Badge Management System / KKS工牌管理系统",
|
||||
@@ -151,6 +159,10 @@ app.add_middleware(
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Audit logging middleware (records POST/PUT/DELETE to audit_logs table)
|
||||
from app.middleware import AuditMiddleware
|
||||
app.add_middleware(AuditMiddleware)
|
||||
|
||||
|
||||
# Global exception handler — prevent stack trace leaks
|
||||
@app.exception_handler(Exception)
|
||||
@@ -177,20 +189,26 @@ 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(fences.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])
|
||||
app.include_router(device_groups.router, dependencies=[*_api_deps])
|
||||
app.include_router(alert_rules.router, dependencies=[*_api_deps])
|
||||
app.include_router(system.router, dependencies=[*_api_deps])
|
||||
|
||||
_STATIC_DIR = Path(__file__).parent / "static"
|
||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||
|
||||
# Cache admin.html in memory at startup (avoid disk read per request)
|
||||
_admin_html_cache: str = (_STATIC_DIR / "admin.html").read_text(encoding="utf-8")
|
||||
|
||||
|
||||
@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"))
|
||||
return HTMLResponse(content=_admin_html_cache)
|
||||
|
||||
|
||||
@app.get("/", tags=["Root"])
|
||||
@@ -228,6 +246,93 @@ async def health():
|
||||
}
|
||||
|
||||
|
||||
@app.get("/api/system/overview", tags=["System / 系统管理"])
|
||||
async def system_overview():
|
||||
"""
|
||||
系统总览:设备/在线率/今日告警/今日考勤/各表记录数/运行状态。
|
||||
System overview: devices, online rate, today stats, table sizes, uptime.
|
||||
"""
|
||||
from sqlalchemy import func, select, text
|
||||
from app.models import (
|
||||
Device, LocationRecord, AlarmRecord, AttendanceRecord,
|
||||
BluetoothRecord, HeartbeatRecord, CommandLog, BeaconConfig, FenceConfig,
|
||||
)
|
||||
from app.config import now_cst
|
||||
from app.websocket_manager import ws_manager
|
||||
|
||||
now = now_cst()
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
|
||||
async with async_session() as db:
|
||||
# Device stats
|
||||
total_devices = (await db.execute(select(func.count(Device.id)))).scalar() or 0
|
||||
online_devices = (await db.execute(
|
||||
select(func.count(Device.id)).where(Device.status == "online")
|
||||
)).scalar() or 0
|
||||
|
||||
# Today counts
|
||||
today_alarms = (await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
|
||||
)).scalar() or 0
|
||||
today_unack = (await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(
|
||||
AlarmRecord.recorded_at >= today_start,
|
||||
AlarmRecord.acknowledged == False, # noqa: E712
|
||||
)
|
||||
)).scalar() or 0
|
||||
today_attendance = (await db.execute(
|
||||
select(func.count(AttendanceRecord.id)).where(AttendanceRecord.recorded_at >= today_start)
|
||||
)).scalar() or 0
|
||||
today_locations = (await db.execute(
|
||||
select(func.count(LocationRecord.id)).where(LocationRecord.recorded_at >= today_start)
|
||||
)).scalar() or 0
|
||||
|
||||
# Table sizes
|
||||
table_sizes = {}
|
||||
for name, model in [
|
||||
("devices", Device), ("locations", LocationRecord),
|
||||
("alarms", AlarmRecord), ("attendance", AttendanceRecord),
|
||||
("bluetooth", BluetoothRecord), ("heartbeats", HeartbeatRecord),
|
||||
("commands", CommandLog), ("beacons", BeaconConfig), ("fences", FenceConfig),
|
||||
]:
|
||||
cnt = (await db.execute(select(func.count(model.id)))).scalar() or 0
|
||||
table_sizes[name] = cnt
|
||||
|
||||
# DB file size
|
||||
db_size_mb = None
|
||||
try:
|
||||
import os
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
if os.path.exists(db_path):
|
||||
db_size_mb = round(os.path.getsize(db_path) / 1024 / 1024, 2)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
return {
|
||||
"code": 0,
|
||||
"data": {
|
||||
"devices": {
|
||||
"total": total_devices,
|
||||
"online": online_devices,
|
||||
"offline": total_devices - online_devices,
|
||||
"online_rate": round(online_devices / total_devices * 100, 1) if total_devices else 0,
|
||||
},
|
||||
"today": {
|
||||
"alarms": today_alarms,
|
||||
"unacknowledged_alarms": today_unack,
|
||||
"attendance": today_attendance,
|
||||
"locations": today_locations,
|
||||
},
|
||||
"table_sizes": table_sizes,
|
||||
"connections": {
|
||||
"tcp_devices": len(tcp_manager.connections),
|
||||
"websocket_clients": ws_manager.connection_count,
|
||||
},
|
||||
"database_size_mb": db_size_mb,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@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)."""
|
||||
|
||||
101
app/middleware.py
Normal file
101
app/middleware.py
Normal file
@@ -0,0 +1,101 @@
|
||||
"""
|
||||
Audit logging middleware.
|
||||
Records POST/PUT/DELETE requests to the audit_logs table.
|
||||
"""
|
||||
|
||||
import json
|
||||
import time
|
||||
import logging
|
||||
|
||||
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||
from starlette.requests import Request
|
||||
from starlette.responses import Response
|
||||
|
||||
from app.database import async_session
|
||||
from app.models import AuditLog
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
# Methods to audit
|
||||
_AUDIT_METHODS = {"POST", "PUT", "DELETE"}
|
||||
|
||||
# Paths to skip (noisy or non-business endpoints)
|
||||
_SKIP_PREFIXES = ("/ws", "/health", "/docs", "/redoc", "/openapi.json")
|
||||
|
||||
# Max request body size to store (bytes)
|
||||
_MAX_BODY_SIZE = 4096
|
||||
|
||||
|
||||
def _get_client_ip(request: Request) -> str:
|
||||
"""Extract real client IP from proxy headers."""
|
||||
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
|
||||
return request.client.host if request.client else "unknown"
|
||||
|
||||
|
||||
def _get_operator(request: Request) -> str | None:
|
||||
"""Extract operator name from request state (set by verify_api_key)."""
|
||||
key_info = getattr(request.state, "key_info", None)
|
||||
if key_info and isinstance(key_info, dict):
|
||||
return key_info.get("name")
|
||||
return None
|
||||
|
||||
|
||||
class AuditMiddleware(BaseHTTPMiddleware):
|
||||
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||
if request.method not in _AUDIT_METHODS:
|
||||
return await call_next(request)
|
||||
|
||||
path = request.url.path
|
||||
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||
return await call_next(request)
|
||||
|
||||
# Only audit /api/ routes
|
||||
if not path.startswith("/api/"):
|
||||
return await call_next(request)
|
||||
|
||||
# Read request body for audit (cache it for downstream)
|
||||
body_bytes = await request.body()
|
||||
request_body = None
|
||||
if body_bytes and len(body_bytes) <= _MAX_BODY_SIZE:
|
||||
try:
|
||||
request_body = json.loads(body_bytes)
|
||||
# Redact sensitive fields
|
||||
if isinstance(request_body, dict):
|
||||
for key in ("password", "api_key", "key", "secret", "token"):
|
||||
if key in request_body:
|
||||
request_body[key] = "***REDACTED***"
|
||||
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||
request_body = None
|
||||
|
||||
start = time.monotonic()
|
||||
response = await call_next(request)
|
||||
duration_ms = int((time.monotonic() - start) * 1000)
|
||||
|
||||
# Extract operator from dependency injection result
|
||||
operator = _get_operator(request)
|
||||
|
||||
# Build response summary
|
||||
response_summary = f"HTTP {response.status_code}"
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
session.add(AuditLog(
|
||||
method=request.method,
|
||||
path=path,
|
||||
status_code=response.status_code,
|
||||
operator=operator,
|
||||
client_ip=_get_client_ip(request),
|
||||
request_body=request_body,
|
||||
response_summary=response_summary,
|
||||
duration_ms=duration_ms,
|
||||
))
|
||||
except Exception:
|
||||
logger.debug("Failed to write audit log for %s %s", request.method, path)
|
||||
|
||||
return response
|
||||
222
app/models.py
222
app/models.py
@@ -1,7 +1,5 @@
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
|
||||
from sqlalchemy import (
|
||||
BigInteger,
|
||||
Boolean,
|
||||
@@ -16,13 +14,10 @@ from sqlalchemy import (
|
||||
from sqlalchemy.dialects.sqlite import JSON
|
||||
from sqlalchemy.orm import Mapped, mapped_column, relationship
|
||||
|
||||
from app.config import now_cst as _utcnow
|
||||
from app.database import Base
|
||||
|
||||
|
||||
def _now_beijing() -> datetime:
|
||||
return datetime.now(BEIJING_TZ)
|
||||
|
||||
|
||||
class Device(Base):
|
||||
"""Registered Bluetooth badge devices."""
|
||||
|
||||
@@ -41,9 +36,10 @@ class Device(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
|
||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||
)
|
||||
|
||||
# Relationships
|
||||
@@ -82,6 +78,7 @@ class LocationRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
location_type: Mapped[str] = mapped_column(
|
||||
String(10), nullable=False
|
||||
) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g
|
||||
@@ -104,7 +101,7 @@ class LocationRecord(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="locations")
|
||||
|
||||
@@ -127,6 +124,7 @@ class AlarmRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
alarm_type: Mapped[str] = mapped_column(
|
||||
String(30), nullable=False
|
||||
) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ...
|
||||
@@ -149,7 +147,7 @@ class AlarmRecord(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="alarms")
|
||||
|
||||
@@ -172,12 +170,13 @@ class HeartbeatRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="heartbeats")
|
||||
|
||||
@@ -197,9 +196,14 @@ class AttendanceRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
attendance_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # clock_in, clock_out
|
||||
attendance_source: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False, default="device",
|
||||
server_default="device",
|
||||
) # device (0xB0/0xB1), bluetooth (0xB2), fence (auto)
|
||||
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)
|
||||
@@ -217,7 +221,7 @@ class AttendanceRecord(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="attendance_records")
|
||||
|
||||
@@ -240,6 +244,7 @@ class BluetoothRecord(Base):
|
||||
device_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
imei: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
record_type: Mapped[str] = mapped_column(
|
||||
String(20), nullable=False
|
||||
) # punch, location
|
||||
@@ -256,7 +261,7 @@ class BluetoothRecord(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="bluetooth_records")
|
||||
|
||||
@@ -284,15 +289,110 @@ class BeaconConfig(Base):
|
||||
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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
updated_at: Mapped[datetime | None] = mapped_column(
|
||||
DateTime, default=_now_beijing, onupdate=_now_beijing, nullable=True
|
||||
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 DeviceBeaconBinding(Base):
|
||||
"""Many-to-many binding between devices and beacons."""
|
||||
|
||||
__tablename__ = "device_beacon_bindings"
|
||||
__table_args__ = (
|
||||
Index("ix_dbb_device_beacon", "device_id", "beacon_id", unique=True),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
beacon_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("beacon_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceBeaconBinding(device_id={self.device_id}, beacon_id={self.beacon_id})>"
|
||||
|
||||
|
||||
class FenceConfig(Base):
|
||||
"""Geofence configuration for area monitoring."""
|
||||
|
||||
__tablename__ = "fence_configs"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
fence_type: Mapped[str] = mapped_column(String(20), nullable=False) # circle / polygon / rectangle
|
||||
# Circle center (WGS-84) or polygon centroid for display
|
||||
center_lat: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
center_lng: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
radius: Mapped[float | None] = mapped_column(Float, nullable=True) # meters, for circle
|
||||
# Polygon/rectangle vertices as JSON: [[lng,lat], [lng,lat], ...] (WGS-84)
|
||||
points: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False)
|
||||
fill_color: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||
fill_opacity: Mapped[float] = mapped_column(Float, default=0.2, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
is_active: Mapped[bool] = mapped_column(Integer, default=1, 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"<FenceConfig(id={self.id}, name={self.name}, type={self.fence_type})>"
|
||||
|
||||
|
||||
class DeviceFenceBinding(Base):
|
||||
"""Many-to-many binding between devices and geofences."""
|
||||
|
||||
__tablename__ = "device_fence_bindings"
|
||||
__table_args__ = (
|
||||
Index("ix_dfb_device_fence", "device_id", "fence_id", unique=True),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
fence_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceFenceBinding(device_id={self.device_id}, fence_id={self.fence_id})>"
|
||||
|
||||
|
||||
class DeviceFenceState(Base):
|
||||
"""Runtime state tracking: is a device currently inside a fence?"""
|
||||
|
||||
__tablename__ = "device_fence_states"
|
||||
__table_args__ = (
|
||||
Index("ix_dfs_device_fence", "device_id", "fence_id", unique=True),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
fence_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
is_inside: Mapped[bool] = mapped_column(Integer, default=0, nullable=False)
|
||||
last_transition_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True)
|
||||
last_latitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
last_longitude: Mapped[float | None] = mapped_column(Float, nullable=True)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceFenceState(device_id={self.device_id}, fence_id={self.fence_id}, inside={self.is_inside})>"
|
||||
|
||||
|
||||
class CommandLog(Base):
|
||||
"""Log of commands sent to devices."""
|
||||
|
||||
@@ -314,7 +414,7 @@ class CommandLog(Base):
|
||||
) # 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=_now_beijing, nullable=False)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
device: Mapped["Device"] = relationship(back_populates="command_logs")
|
||||
|
||||
@@ -325,6 +425,94 @@ class CommandLog(Base):
|
||||
)
|
||||
|
||||
|
||||
class DeviceGroup(Base):
|
||||
"""Device groups for organizing devices."""
|
||||
|
||||
__tablename__ = "device_groups"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", 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"<DeviceGroup(id={self.id}, name={self.name})>"
|
||||
|
||||
|
||||
class DeviceGroupMember(Base):
|
||||
"""Many-to-many: devices belong to groups."""
|
||||
|
||||
__tablename__ = "device_group_members"
|
||||
__table_args__ = (
|
||||
Index("ix_dgm_device_group", "device_id", "group_id", unique=True),
|
||||
)
|
||||
|
||||
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
|
||||
)
|
||||
group_id: Mapped[int] = mapped_column(
|
||||
Integer, ForeignKey("device_groups.id", ondelete="CASCADE"), index=True, nullable=False
|
||||
)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<DeviceGroupMember(device_id={self.device_id}, group_id={self.group_id})>"
|
||||
|
||||
|
||||
class AlertRule(Base):
|
||||
"""Configurable alert rules for custom thresholds."""
|
||||
|
||||
__tablename__ = "alert_rules"
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
name: Mapped[str] = mapped_column(String(100), nullable=False)
|
||||
rule_type: Mapped[str] = mapped_column(String(30), nullable=False)
|
||||
# rule_type values: low_battery, no_heartbeat, fence_stay, speed_limit, offline_duration
|
||||
conditions: Mapped[dict] = mapped_column(JSON, nullable=False)
|
||||
# e.g. {"threshold": 20} for low_battery, {"minutes": 30} for no_heartbeat
|
||||
is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
|
||||
device_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
# comma-separated device IDs, null = all devices
|
||||
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
# apply to a device group
|
||||
description: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
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"<AlertRule(id={self.id}, name={self.name}, type={self.rule_type})>"
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
"""Audit trail for write operations (POST/PUT/DELETE)."""
|
||||
|
||||
__tablename__ = "audit_logs"
|
||||
__table_args__ = (
|
||||
Index("ix_audit_created", "created_at"),
|
||||
)
|
||||
|
||||
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
|
||||
method: Mapped[str] = mapped_column(String(10), nullable=False) # POST, PUT, DELETE
|
||||
path: Mapped[str] = mapped_column(String(500), nullable=False)
|
||||
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
|
||||
operator: Mapped[str | None] = mapped_column(String(100), nullable=True) # API key name or "master"
|
||||
client_ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
|
||||
request_body: Mapped[dict | None] = mapped_column(JSON, nullable=True)
|
||||
response_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
|
||||
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||
|
||||
def __repr__(self) -> str:
|
||||
return f"<AuditLog(id={self.id}, {self.method} {self.path})>"
|
||||
|
||||
|
||||
class ApiKey(Base):
|
||||
"""API keys for external system authentication."""
|
||||
|
||||
@@ -338,7 +526,7 @@ class ApiKey(Base):
|
||||
) # 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=_now_beijing, nullable=False)
|
||||
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})>"
|
||||
|
||||
@@ -8,9 +8,7 @@ from __future__ import annotations
|
||||
|
||||
import struct
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from datetime import datetime, timezone
|
||||
from typing import Optional
|
||||
|
||||
from .constants import (
|
||||
@@ -143,9 +141,9 @@ class PacketBuilder:
|
||||
"""
|
||||
Build a Time Sync 2 response (0x8A).
|
||||
|
||||
Returns the current Beijing time as YY MM DD HH MM SS (6 bytes).
|
||||
Returns the current UTC time as YY MM DD HH MM SS (6 bytes).
|
||||
"""
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone.utc)
|
||||
info = struct.pack(
|
||||
"BBBBBB",
|
||||
now.year - 2000,
|
||||
|
||||
@@ -9,9 +9,7 @@ start markers.
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from datetime import datetime, timezone
|
||||
from typing import Any, Dict, List, Tuple
|
||||
|
||||
from .constants import (
|
||||
@@ -228,7 +226,7 @@ class PacketParser:
|
||||
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", data, offset)
|
||||
year = 2000 + yy
|
||||
try:
|
||||
dt = datetime(year, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ)
|
||||
dt = datetime(year, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
|
||||
except ValueError:
|
||||
dt = None
|
||||
return {
|
||||
|
||||
@@ -4,17 +4,20 @@ API endpoints for alarm record queries, acknowledgement, and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from fastapi.responses import Response
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, case, extract
|
||||
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.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
AlarmAcknowledge,
|
||||
AlarmRecordResponse,
|
||||
@@ -93,16 +96,65 @@ async def list_alarms(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出告警记录 CSV / Export alarm records CSV",
|
||||
)
|
||||
async def export_alarms(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
alarm_type: str | None = Query(default=None, description="告警类型"),
|
||||
alarm_source: str | None = Query(default=None, description="告警来源"),
|
||||
acknowledged: bool | None = Query(default=None, description="是否已确认"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出告警记录为 CSV,支持类型/状态/时间筛选。最多导出 50000 条。"""
|
||||
query = select(AlarmRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(AlarmRecord.device_id == device_id)
|
||||
if alarm_type:
|
||||
query = query.where(AlarmRecord.alarm_type == alarm_type)
|
||||
if alarm_source:
|
||||
query = query.where(AlarmRecord.alarm_source == alarm_source)
|
||||
if acknowledged is not None:
|
||||
query = query.where(AlarmRecord.acknowledged == acknowledged)
|
||||
if start_time:
|
||||
query = query.where(AlarmRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AlarmRecord.recorded_at <= end_time)
|
||||
query = query.order_by(AlarmRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "告警类型", "告警来源", "已确认", "纬度", "经度", "电量", "信号", "地址", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "alarm_type", "alarm_source", "acknowledged", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('alarms')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取报警统计 / Get alarm statistics",
|
||||
summary="获取报警统计(增强版)/ Get enhanced alarm statistics",
|
||||
)
|
||||
async def alarm_stats(db: AsyncSession = Depends(get_db)):
|
||||
async def alarm_stats(
|
||||
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取报警统计:总数、未确认数、按类型分组统计。
|
||||
Get alarm statistics: total, unacknowledged count, and breakdown by type.
|
||||
增强版报警统计:总数、未确认数、按类型分组、按天趋势、平均响应时间、TOP10设备。
|
||||
Enhanced alarm stats: totals, by type, daily trend, avg response time, top 10 devices.
|
||||
"""
|
||||
from app.config import now_cst
|
||||
now = now_cst()
|
||||
|
||||
# Total alarms
|
||||
total_result = await db.execute(select(func.count(AlarmRecord.id)))
|
||||
total = total_result.scalar() or 0
|
||||
@@ -121,16 +173,156 @@ async def alarm_stats(db: AsyncSession = Depends(get_db)):
|
||||
)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# By source
|
||||
source_result = await db.execute(
|
||||
select(AlarmRecord.alarm_source, func.count(AlarmRecord.id))
|
||||
.group_by(AlarmRecord.alarm_source)
|
||||
)
|
||||
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
|
||||
|
||||
# Daily trend (last N days)
|
||||
cutoff = now - timedelta(days=days)
|
||||
trend_result = await db.execute(
|
||||
select(
|
||||
func.date(AlarmRecord.recorded_at).label("day"),
|
||||
func.count(AlarmRecord.id),
|
||||
)
|
||||
.where(AlarmRecord.recorded_at >= cutoff)
|
||||
.group_by("day")
|
||||
.order_by("day")
|
||||
)
|
||||
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
|
||||
|
||||
# Today count
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_result = await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
|
||||
)
|
||||
today_count = today_result.scalar() or 0
|
||||
|
||||
# Top 10 devices by alarm count
|
||||
top_result = await db.execute(
|
||||
select(AlarmRecord.device_id, AlarmRecord.imei, func.count(AlarmRecord.id).label("cnt"))
|
||||
.group_by(AlarmRecord.device_id, AlarmRecord.imei)
|
||||
.order_by(func.count(AlarmRecord.id).desc())
|
||||
.limit(10)
|
||||
)
|
||||
top_devices = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in top_result.all()]
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
"total": total,
|
||||
"unacknowledged": unacknowledged,
|
||||
"acknowledged": total - unacknowledged,
|
||||
"today": today_count,
|
||||
"by_type": by_type,
|
||||
"by_source": by_source,
|
||||
"daily_trend": daily_trend,
|
||||
"top_devices": top_devices,
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
class BatchAcknowledgeRequest(BaseModel):
|
||||
alarm_ids: list[int] = Field(..., min_length=1, max_length=500, description="告警ID列表")
|
||||
acknowledged: bool = Field(default=True, description="确认状态")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-acknowledge",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量确认告警 / Batch acknowledge alarms",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def batch_acknowledge_alarms(
|
||||
body: BatchAcknowledgeRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量确认(或取消确认)告警记录,最多500条。
|
||||
Batch acknowledge (or un-acknowledge) alarm records (max 500).
|
||||
"""
|
||||
result = await db.execute(
|
||||
select(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
for r in records:
|
||||
r.acknowledged = body.acknowledged
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已{'确认' if body.acknowledged else '取消确认'} {len(records)} 条告警",
|
||||
data={"updated": len(records), "requested": len(body.alarm_ids)},
|
||||
)
|
||||
|
||||
|
||||
class BatchDeleteAlarmRequest(BaseModel):
|
||||
alarm_ids: list[int] | None = Field(default=None, max_length=500, description="告警ID列表 (与条件删除二选一)")
|
||||
device_id: int | None = Field(default=None, description="按设备ID删除")
|
||||
alarm_type: str | None = Field(default=None, description="按告警类型删除")
|
||||
acknowledged: bool | None = Field(default=None, description="按确认状态删除")
|
||||
start_time: datetime | None = Field(default=None, description="开始时间")
|
||||
end_time: datetime | None = Field(default=None, description="结束时间")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除告警记录 / Batch delete alarms",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def batch_delete_alarms(
|
||||
body: BatchDeleteAlarmRequest,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量删除告警记录。两种模式:
|
||||
1. 按ID删除: 传 alarm_ids (最多500条)
|
||||
2. 按条件删除: 传 device_id/alarm_type/acknowledged/start_time/end_time 组合
|
||||
"""
|
||||
from sqlalchemy import delete as sql_delete
|
||||
|
||||
if body.alarm_ids:
|
||||
# Mode 1: by IDs
|
||||
result = await db.execute(
|
||||
sql_delete(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
|
||||
)
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已删除 {result.rowcount} 条告警",
|
||||
data={"deleted": result.rowcount, "requested": len(body.alarm_ids)},
|
||||
)
|
||||
|
||||
# Mode 2: by filters (at least one filter required)
|
||||
conditions = []
|
||||
if body.device_id is not None:
|
||||
conditions.append(AlarmRecord.device_id == body.device_id)
|
||||
if body.alarm_type:
|
||||
conditions.append(AlarmRecord.alarm_type == body.alarm_type)
|
||||
if body.acknowledged is not None:
|
||||
conditions.append(AlarmRecord.acknowledged == body.acknowledged)
|
||||
if body.start_time:
|
||||
conditions.append(AlarmRecord.recorded_at >= body.start_time)
|
||||
if body.end_time:
|
||||
conditions.append(AlarmRecord.recorded_at <= body.end_time)
|
||||
|
||||
if not conditions:
|
||||
raise HTTPException(status_code=400, detail="需提供 alarm_ids 或至少一个筛选条件")
|
||||
|
||||
# Count first
|
||||
count = (await db.execute(
|
||||
select(func.count(AlarmRecord.id)).where(*conditions)
|
||||
)).scalar() or 0
|
||||
|
||||
if count > 0:
|
||||
await db.execute(sql_delete(AlarmRecord).where(*conditions))
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(
|
||||
message=f"已删除 {count} 条告警",
|
||||
data={"deleted": count},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{alarm_id}",
|
||||
response_model=APIResponse[AlarmRecordResponse],
|
||||
|
||||
109
app/routers/alert_rules.py
Normal file
109
app/routers/alert_rules.py
Normal file
@@ -0,0 +1,109 @@
|
||||
"""
|
||||
Alert Rules Router - 告警规则配置接口
|
||||
API endpoints for alert rule CRUD operations.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_write
|
||||
from app.models import AlertRule
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AlertRuleCreate,
|
||||
AlertRuleResponse,
|
||||
AlertRuleUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules / 告警规则"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[list[AlertRuleResponse]],
|
||||
summary="获取告警规则列表 / List alert rules",
|
||||
)
|
||||
async def list_rules(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有告警规则。"""
|
||||
result = await db.execute(select(AlertRule).order_by(AlertRule.id))
|
||||
rules = list(result.scalars().all())
|
||||
return APIResponse(data=[AlertRuleResponse.model_validate(r) for r in rules])
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
status_code=201,
|
||||
summary="创建告警规则 / Create alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def create_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""创建新告警规则。"""
|
||||
rule = AlertRule(
|
||||
name=body.name,
|
||||
rule_type=body.rule_type,
|
||||
conditions=body.conditions,
|
||||
is_active=body.is_active,
|
||||
device_ids=body.device_ids,
|
||||
group_id=body.group_id,
|
||||
description=body.description,
|
||||
)
|
||||
db.add(rule)
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
summary="获取告警规则详情 / Get alert rule",
|
||||
)
|
||||
async def get_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取告警规则详情。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse[AlertRuleResponse],
|
||||
summary="更新告警规则 / Update alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def update_rule(
|
||||
rule_id: int, body: AlertRuleUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新告警规则。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(rule, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(rule)
|
||||
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{rule_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除告警规则 / Delete alert rule",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除告警规则。"""
|
||||
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||
rule = result.scalar_one_or_none()
|
||||
if not rule:
|
||||
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||
await db.delete(rule)
|
||||
await db.flush()
|
||||
return APIResponse(message="规则已删除")
|
||||
@@ -4,14 +4,16 @@ API endpoints for attendance record queries and statistics.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy import func, select
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select, case
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import AttendanceRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AttendanceRecordResponse,
|
||||
@@ -30,6 +32,7 @@ router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"])
|
||||
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"),
|
||||
attendance_source: str | None = Query(default=None, description="考勤来源 / Source (device/bluetooth/fence)"),
|
||||
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"),
|
||||
@@ -37,8 +40,8 @@ async def list_attendance(
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。
|
||||
List attendance records with filters for device, type, and time range.
|
||||
获取考勤记录列表,支持按设备、考勤类型、来源、时间范围过滤。
|
||||
List attendance records with filters for device, type, source, and time range.
|
||||
"""
|
||||
query = select(AttendanceRecord)
|
||||
count_query = select(func.count(AttendanceRecord.id))
|
||||
@@ -51,6 +54,10 @@ async def list_attendance(
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
|
||||
if attendance_source:
|
||||
query = query.where(AttendanceRecord.attendance_source == attendance_source)
|
||||
count_query = count_query.where(AttendanceRecord.attendance_source == attendance_source)
|
||||
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
@@ -78,21 +85,65 @@ async def list_attendance(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出考勤记录 CSV / Export attendance records CSV",
|
||||
)
|
||||
async def export_attendance(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
attendance_type: str | None = Query(default=None, description="考勤类型 (clock_in/clock_out)"),
|
||||
attendance_source: str | None = Query(default=None, description="来源 (device/bluetooth/fence)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出考勤记录为 CSV,支持设备/类型/来源/时间筛选。最多导出 50000 条。"""
|
||||
query = select(AttendanceRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(AttendanceRecord.device_id == device_id)
|
||||
if attendance_type:
|
||||
query = query.where(AttendanceRecord.attendance_type == attendance_type)
|
||||
if attendance_source:
|
||||
query = query.where(AttendanceRecord.attendance_source == attendance_source)
|
||||
if start_time:
|
||||
query = query.where(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AttendanceRecord.recorded_at <= end_time)
|
||||
query = query.order_by(AttendanceRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "考勤类型", "来源", "纬度", "经度", "电量", "信号", "地址", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "attendance_type", "attendance_source", "latitude", "longitude", "battery_level", "gsm_signal", "address", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="获取考勤统计 / Get attendance statistics",
|
||||
summary="获取考勤统计(增强版)/ Get enhanced 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"),
|
||||
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取考勤统计:总记录数、按类型分组统计、按设备分组统计。
|
||||
Get attendance statistics: total records, breakdown by type and by device.
|
||||
增强版考勤统计:总数、按类型/来源/设备分组、按天趋势、今日统计。
|
||||
Enhanced: total, by type/source/device, daily trend, today count.
|
||||
"""
|
||||
from app.config import now_cst
|
||||
now = now_cst()
|
||||
|
||||
base_filter = []
|
||||
if device_id is not None:
|
||||
base_filter.append(AttendanceRecord.device_id == device_id)
|
||||
@@ -101,37 +152,210 @@ async def attendance_stats(
|
||||
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
|
||||
def _where(q):
|
||||
return q.where(*base_filter) if base_filter else q
|
||||
|
||||
# Total
|
||||
total = (await db.execute(_where(select(func.count(AttendanceRecord.id))))).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)
|
||||
type_result = await db.execute(_where(
|
||||
select(AttendanceRecord.attendance_type, func.count(AttendanceRecord.id))
|
||||
.group_by(AttendanceRecord.attendance_type)
|
||||
))
|
||||
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()}
|
||||
# By source
|
||||
source_result = await db.execute(_where(
|
||||
select(AttendanceRecord.attendance_source, func.count(AttendanceRecord.id))
|
||||
.group_by(AttendanceRecord.attendance_source)
|
||||
))
|
||||
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
|
||||
|
||||
return APIResponse(
|
||||
data={
|
||||
# By device (top 20)
|
||||
device_result = await db.execute(_where(
|
||||
select(AttendanceRecord.device_id, AttendanceRecord.imei, func.count(AttendanceRecord.id))
|
||||
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei)
|
||||
.order_by(func.count(AttendanceRecord.id).desc())
|
||||
.limit(20)
|
||||
))
|
||||
by_device = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in device_result.all()]
|
||||
|
||||
# Daily trend (last N days)
|
||||
cutoff = now - timedelta(days=days)
|
||||
trend_result = await db.execute(
|
||||
select(
|
||||
func.date(AttendanceRecord.recorded_at).label("day"),
|
||||
func.count(AttendanceRecord.id),
|
||||
)
|
||||
.where(AttendanceRecord.recorded_at >= cutoff)
|
||||
.group_by("day").order_by("day")
|
||||
)
|
||||
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
|
||||
|
||||
# Today
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_count = (await db.execute(
|
||||
select(func.count(AttendanceRecord.id)).where(AttendanceRecord.recorded_at >= today_start)
|
||||
)).scalar() or 0
|
||||
|
||||
return APIResponse(data={
|
||||
"total": total,
|
||||
"today": today_count,
|
||||
"by_type": by_type,
|
||||
"by_source": by_source,
|
||||
"by_device": by_device,
|
||||
}
|
||||
"daily_trend": daily_trend,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/report",
|
||||
response_model=APIResponse[dict],
|
||||
summary="考勤报表 / Attendance report",
|
||||
)
|
||||
async def attendance_report(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选,不传则所有设备)"),
|
||||
start_date: str = Query(..., description="开始日期 YYYY-MM-DD"),
|
||||
end_date: str = Query(..., description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
考勤报表:按设备+日期聚合,返回每个设备每天的签到次数、首次签到时间、末次签到时间。
|
||||
Attendance report: per device per day aggregation.
|
||||
"""
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
s_date = dt.strptime(start_date, "%Y-%m-%d")
|
||||
e_date = dt.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式需为 YYYY-MM-DD")
|
||||
|
||||
if s_date > e_date:
|
||||
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
|
||||
|
||||
filters = [
|
||||
AttendanceRecord.recorded_at >= s_date,
|
||||
AttendanceRecord.recorded_at <= e_date,
|
||||
]
|
||||
if device_id is not None:
|
||||
filters.append(AttendanceRecord.device_id == device_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
AttendanceRecord.device_id,
|
||||
AttendanceRecord.imei,
|
||||
func.date(AttendanceRecord.recorded_at).label("day"),
|
||||
func.count(AttendanceRecord.id).label("punch_count"),
|
||||
func.min(AttendanceRecord.recorded_at).label("first_punch"),
|
||||
func.max(AttendanceRecord.recorded_at).label("last_punch"),
|
||||
func.group_concat(AttendanceRecord.attendance_source).label("sources"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei, "day")
|
||||
.order_by(AttendanceRecord.device_id, "day")
|
||||
)
|
||||
|
||||
rows = result.all()
|
||||
report = []
|
||||
for row in rows:
|
||||
report.append({
|
||||
"device_id": row[0],
|
||||
"imei": row[1],
|
||||
"date": str(row[2]),
|
||||
"punch_count": row[3],
|
||||
"first_punch": str(row[4]) if row[4] else None,
|
||||
"last_punch": str(row[5]) if row[5] else None,
|
||||
"sources": list(set(row[6].split(","))) if row[6] else [],
|
||||
})
|
||||
|
||||
# Summary: total days in range, devices with records, attendance rate
|
||||
total_days = (e_date - s_date).days + 1
|
||||
unique_devices = len({r["device_id"] for r in report})
|
||||
device_days = len(report)
|
||||
|
||||
from app.models import Device
|
||||
if device_id is not None:
|
||||
total_device_count = 1
|
||||
else:
|
||||
total_device_count = (await db.execute(select(func.count(Device.id)))).scalar() or 1
|
||||
|
||||
expected_device_days = total_days * total_device_count
|
||||
attendance_rate = round(device_days / expected_device_days * 100, 1) if expected_device_days else 0
|
||||
|
||||
return APIResponse(data={
|
||||
"start_date": start_date,
|
||||
"end_date": end_date,
|
||||
"total_days": total_days,
|
||||
"total_devices": unique_devices,
|
||||
"attendance_rate": attendance_rate,
|
||||
"records": report,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/report/export",
|
||||
summary="导出考勤报表 CSV / Export attendance report CSV",
|
||||
)
|
||||
async def export_attendance_report(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选)"),
|
||||
start_date: str = Query(..., description="开始日期 YYYY-MM-DD"),
|
||||
end_date: str = Query(..., description="结束日期 YYYY-MM-DD"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出考勤日报表 CSV(每设备每天汇总)。"""
|
||||
from datetime import datetime as dt
|
||||
|
||||
try:
|
||||
s_date = dt.strptime(start_date, "%Y-%m-%d")
|
||||
e_date = dt.strptime(end_date, "%Y-%m-%d").replace(hour=23, minute=59, second=59)
|
||||
except ValueError:
|
||||
raise HTTPException(status_code=400, detail="日期格式需为 YYYY-MM-DD")
|
||||
|
||||
if s_date > e_date:
|
||||
raise HTTPException(status_code=400, detail="start_date must be <= end_date")
|
||||
|
||||
filters = [
|
||||
AttendanceRecord.recorded_at >= s_date,
|
||||
AttendanceRecord.recorded_at <= e_date,
|
||||
]
|
||||
if device_id is not None:
|
||||
filters.append(AttendanceRecord.device_id == device_id)
|
||||
|
||||
result = await db.execute(
|
||||
select(
|
||||
AttendanceRecord.device_id,
|
||||
AttendanceRecord.imei,
|
||||
func.date(AttendanceRecord.recorded_at).label("day"),
|
||||
func.count(AttendanceRecord.id).label("punch_count"),
|
||||
func.min(AttendanceRecord.recorded_at).label("first_punch"),
|
||||
func.max(AttendanceRecord.recorded_at).label("last_punch"),
|
||||
func.group_concat(AttendanceRecord.attendance_source).label("sources"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by(AttendanceRecord.device_id, AttendanceRecord.imei, "day")
|
||||
.order_by(AttendanceRecord.device_id, "day")
|
||||
)
|
||||
|
||||
rows = result.all()
|
||||
|
||||
headers = ["设备ID", "IMEI", "日期", "打卡次数", "首次打卡", "末次打卡", "来源"]
|
||||
extractors = [
|
||||
lambda r: r[0],
|
||||
lambda r: r[1],
|
||||
lambda r: str(r[2]),
|
||||
lambda r: r[3],
|
||||
lambda r: r[4],
|
||||
lambda r: r[5],
|
||||
lambda r: ",".join(set(r[6].split(","))) if r[6] else "",
|
||||
]
|
||||
|
||||
content = build_csv_content(headers, rows, extractors)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('attendance_report')}"},
|
||||
)
|
||||
|
||||
|
||||
@@ -187,6 +411,36 @@ async def device_attendance(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除考勤记录 / Batch delete attendance records",
|
||||
)
|
||||
async def batch_delete_attendance(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
批量删除考勤记录,通过 body 传递 attendance_ids 列表,最多 500 条。
|
||||
Batch delete attendance records by IDs (max 500).
|
||||
"""
|
||||
attendance_ids = body.get("attendance_ids", [])
|
||||
if not attendance_ids:
|
||||
raise HTTPException(status_code=400, detail="attendance_ids is required")
|
||||
if len(attendance_ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
||||
|
||||
result = await db.execute(
|
||||
select(AttendanceRecord).where(AttendanceRecord.id.in_(attendance_ids))
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
for r in records:
|
||||
await db.delete(r)
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(data={"deleted": len(records)})
|
||||
|
||||
|
||||
# NOTE: /{attendance_id} must be after /stats and /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{attendance_id}",
|
||||
@@ -202,3 +456,21 @@ async def get_attendance(attendance_id: int, db: AsyncSession = Depends(get_db))
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Attendance {attendance_id} not found")
|
||||
return APIResponse(data=AttendanceRecordResponse.model_validate(record))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{attendance_id}",
|
||||
response_model=APIResponse[dict],
|
||||
summary="删除单条考勤记录 / Delete attendance record",
|
||||
)
|
||||
async def delete_attendance(attendance_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID删除单条考勤记录 / Delete a single attendance record 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")
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return APIResponse(data={"deleted": 1})
|
||||
|
||||
@@ -16,6 +16,8 @@ from app.schemas import (
|
||||
BeaconConfigCreate,
|
||||
BeaconConfigResponse,
|
||||
BeaconConfigUpdate,
|
||||
BeaconDeviceDetail,
|
||||
DeviceBeaconBindRequest,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import beacon_service
|
||||
@@ -49,6 +51,76 @@ async def list_beacons(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/setup-bluetooth-mode",
|
||||
response_model=APIResponse,
|
||||
summary="批量配置蓝牙打卡模式 / Setup BT clock-in mode for devices",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def setup_bluetooth_mode(
|
||||
device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.setup_bluetooth_mode(db, device_ids)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"共 {result['total']} 台: {result['sent']} 台已配置, {result['failed']} 台失败",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/restore-normal-mode",
|
||||
response_model=APIResponse,
|
||||
summary="恢复正常模式 / Restore devices to normal (smart) mode",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def restore_normal_mode(
|
||||
device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.restore_normal_mode(db, device_ids)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"共 {result['total']} 台: {result['sent']} 台已恢复, {result['failed']} 台失败",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/reverse-sync",
|
||||
response_model=APIResponse,
|
||||
summary="从设备反向同步信标配置 / Query devices and update DB bindings",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def reverse_sync_beacons(db: AsyncSession = Depends(get_db)):
|
||||
result = await beacon_service.reverse_sync_from_devices(db)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"查询 {result['queried']} 台设备,{result['responded']} 台响应,{result['updated']} 台有变更",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/sync-device/{device_id}",
|
||||
response_model=APIResponse,
|
||||
summary="同步信标配置到设备 / Sync beacon MACs to device via BTMACSET",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def sync_device_beacons(device_id: int, db: AsyncSession = Depends(get_db)):
|
||||
result = await beacon_service.sync_device_beacons(db, device_id)
|
||||
if result["error"]:
|
||||
return APIResponse(code=1, message=result["error"], data=result)
|
||||
return APIResponse(
|
||||
message=f"已发送 {len(result['commands'])} 条指令,共 {result['mac_count']} 个信标MAC",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{beacon_id}",
|
||||
response_model=APIResponse[BeaconConfigResponse],
|
||||
@@ -102,3 +174,52 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not success:
|
||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||
return APIResponse(message="Beacon deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse[list[BeaconDeviceDetail]],
|
||||
summary="获取信标绑定的设备 / Get beacon devices",
|
||||
)
|
||||
async def get_beacon_devices(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")
|
||||
items = await beacon_service.get_beacon_devices(db, beacon_id)
|
||||
return APIResponse(data=[BeaconDeviceDetail(**item) for item in items])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse,
|
||||
summary="绑定设备到信标 / Bind devices to beacon",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def bind_devices(
|
||||
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
result = await beacon_service.bind_devices_to_beacon(db, beacon_id, body.device_ids)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return APIResponse(
|
||||
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{beacon_id}/devices",
|
||||
response_model=APIResponse,
|
||||
summary="解绑设备 / Unbind devices from beacon",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def unbind_devices(
|
||||
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
count = await beacon_service.unbind_devices_from_beacon(db, beacon_id, body.device_ids)
|
||||
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||
|
||||
@@ -8,11 +8,13 @@ from datetime import datetime
|
||||
from typing import Literal
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import BluetoothRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BluetoothRecordResponse,
|
||||
@@ -86,6 +88,115 @@ async def list_bluetooth_records(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出蓝牙记录 CSV / Export bluetooth records CSV",
|
||||
)
|
||||
async def export_bluetooth(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
record_type: str | None = Query(default=None, description="记录类型 (punch/location)"),
|
||||
beacon_mac: str | None = Query(default=None, description="信标MAC"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出蓝牙记录为 CSV,支持设备/类型/信标/时间筛选。最多导出 50000 条。"""
|
||||
query = select(BluetoothRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(BluetoothRecord.device_id == device_id)
|
||||
if record_type:
|
||||
query = query.where(BluetoothRecord.record_type == record_type)
|
||||
if beacon_mac:
|
||||
query = query.where(BluetoothRecord.beacon_mac == beacon_mac)
|
||||
if start_time:
|
||||
query = query.where(BluetoothRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(BluetoothRecord.recorded_at <= end_time)
|
||||
query = query.order_by(BluetoothRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "记录类型", "信标MAC", "UUID", "Major", "Minor", "RSSI", "电量", "考勤类型", "纬度", "经度", "记录时间"]
|
||||
fields = ["id", "device_id", "imei", "record_type", "beacon_mac", "beacon_uuid", "beacon_major", "beacon_minor", "rssi", "beacon_battery", "attendance_type", "latitude", "longitude", "recorded_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('bluetooth')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="蓝牙数据统计 / Bluetooth statistics",
|
||||
)
|
||||
async def bluetooth_stats(
|
||||
start_time: datetime | None = Query(default=None, description="开始时间"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
蓝牙数据统计:总记录数、按类型分布、按信标MAC分组TOP20、RSSI分布。
|
||||
Bluetooth stats: total, by type, top beacons, RSSI distribution.
|
||||
"""
|
||||
from sqlalchemy import case
|
||||
|
||||
filters = []
|
||||
if start_time:
|
||||
filters.append(BluetoothRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
filters.append(BluetoothRecord.recorded_at <= end_time)
|
||||
|
||||
def _where(q):
|
||||
return q.where(*filters) if filters else q
|
||||
|
||||
total = (await db.execute(_where(select(func.count(BluetoothRecord.id))))).scalar() or 0
|
||||
|
||||
# By record_type
|
||||
type_result = await db.execute(_where(
|
||||
select(BluetoothRecord.record_type, func.count(BluetoothRecord.id))
|
||||
.group_by(BluetoothRecord.record_type)
|
||||
))
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# Top 20 beacons by record count
|
||||
beacon_result = await db.execute(_where(
|
||||
select(BluetoothRecord.beacon_mac, func.count(BluetoothRecord.id).label("cnt"))
|
||||
.where(BluetoothRecord.beacon_mac.is_not(None))
|
||||
.group_by(BluetoothRecord.beacon_mac)
|
||||
.order_by(func.count(BluetoothRecord.id).desc())
|
||||
.limit(20)
|
||||
))
|
||||
top_beacons = [{"beacon_mac": row[0], "count": row[1]} for row in beacon_result.all()]
|
||||
|
||||
# RSSI distribution
|
||||
rssi_result = await db.execute(_where(
|
||||
select(
|
||||
func.sum(case(((BluetoothRecord.rssi.is_not(None)) & (BluetoothRecord.rssi >= -50), 1), else_=0)).label("strong"),
|
||||
func.sum(case(((BluetoothRecord.rssi < -50) & (BluetoothRecord.rssi >= -70), 1), else_=0)).label("medium"),
|
||||
func.sum(case(((BluetoothRecord.rssi < -70) & (BluetoothRecord.rssi.is_not(None)), 1), else_=0)).label("weak"),
|
||||
func.sum(case((BluetoothRecord.rssi.is_(None), 1), else_=0)).label("unknown"),
|
||||
)
|
||||
))
|
||||
rrow = rssi_result.one()
|
||||
rssi_distribution = {
|
||||
"strong_above_-50": int(rrow.strong or 0),
|
||||
"medium_-50_-70": int(rrow.medium or 0),
|
||||
"weak_below_-70": int(rrow.weak or 0),
|
||||
"unknown": int(rrow.unknown or 0),
|
||||
}
|
||||
|
||||
return APIResponse(data={
|
||||
"total": total,
|
||||
"by_type": by_type,
|
||||
"top_beacons": top_beacons,
|
||||
"rssi_distribution": rssi_distribution,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/device/{device_id}",
|
||||
response_model=APIResponse[PaginatedList[BluetoothRecordResponse]],
|
||||
@@ -142,6 +253,31 @@ async def device_bluetooth_records(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除蓝牙记录 / Batch delete bluetooth records",
|
||||
)
|
||||
async def batch_delete_bluetooth(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""批量删除蓝牙记录,最多500条。 / Batch delete bluetooth records (max 500)."""
|
||||
record_ids = body.get("record_ids", [])
|
||||
if not record_ids:
|
||||
raise HTTPException(status_code=400, detail="record_ids is required")
|
||||
if len(record_ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
||||
result = await db.execute(
|
||||
select(BluetoothRecord).where(BluetoothRecord.id.in_(record_ids))
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
for r in records:
|
||||
await db.delete(r)
|
||||
await db.flush()
|
||||
return APIResponse(data={"deleted": len(records)})
|
||||
|
||||
|
||||
# NOTE: /{record_id} must be after /device/{device_id} to avoid route conflicts
|
||||
@router.get(
|
||||
"/{record_id}",
|
||||
|
||||
@@ -5,9 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from app.config import now_cst
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -105,12 +103,6 @@ async def _send_to_device(
|
||||
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,
|
||||
@@ -119,7 +111,7 @@ async def _send_to_device(
|
||||
)
|
||||
|
||||
try:
|
||||
await executor()
|
||||
result = await executor()
|
||||
except Exception as e:
|
||||
logging.getLogger(__name__).error("Command send failed: %s", e)
|
||||
command_log.status = "failed"
|
||||
@@ -130,8 +122,17 @@ async def _send_to_device(
|
||||
detail=fail_msg,
|
||||
)
|
||||
|
||||
if result is False:
|
||||
command_log.status = "failed"
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"Device {device.imei} TCP not connected / 设备 {device.imei} TCP未连接,请等待设备重连",
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
command_log.sent_at = datetime.now(BEIJING_TZ)
|
||||
command_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
@@ -146,6 +147,74 @@ async def _send_to_device(
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="指令统计 / Command statistics",
|
||||
)
|
||||
async def command_stats(
|
||||
days: int = Query(default=7, ge=1, le=90, description="趋势天数"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
指令统计:总数、按状态分布、按类型分布、成功率、按天趋势。
|
||||
Command stats: total, by status, by type, success rate, daily trend.
|
||||
"""
|
||||
from sqlalchemy import func, select
|
||||
from datetime import timedelta
|
||||
from app.models import CommandLog
|
||||
|
||||
total = (await db.execute(select(func.count(CommandLog.id)))).scalar() or 0
|
||||
|
||||
# By status
|
||||
status_result = await db.execute(
|
||||
select(CommandLog.status, func.count(CommandLog.id))
|
||||
.group_by(CommandLog.status)
|
||||
)
|
||||
by_status = {row[0]: row[1] for row in status_result.all()}
|
||||
|
||||
# By type
|
||||
type_result = await db.execute(
|
||||
select(CommandLog.command_type, func.count(CommandLog.id))
|
||||
.group_by(CommandLog.command_type)
|
||||
)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# Success rate
|
||||
sent = by_status.get("sent", 0) + by_status.get("success", 0)
|
||||
failed = by_status.get("failed", 0)
|
||||
total_attempted = sent + failed
|
||||
success_rate = round(sent / total_attempted * 100, 1) if total_attempted else 0
|
||||
|
||||
# Daily trend
|
||||
now = now_cst()
|
||||
cutoff = now - timedelta(days=days)
|
||||
trend_result = await db.execute(
|
||||
select(
|
||||
func.date(CommandLog.created_at).label("day"),
|
||||
func.count(CommandLog.id),
|
||||
)
|
||||
.where(CommandLog.created_at >= cutoff)
|
||||
.group_by("day").order_by("day")
|
||||
)
|
||||
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
|
||||
|
||||
# Today
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_count = (await db.execute(
|
||||
select(func.count(CommandLog.id)).where(CommandLog.created_at >= today_start)
|
||||
)).scalar() or 0
|
||||
|
||||
return APIResponse(data={
|
||||
"total": total,
|
||||
"today": today_count,
|
||||
"by_status": by_status,
|
||||
"by_type": by_type,
|
||||
"success_rate": success_rate,
|
||||
"daily_trend": daily_trend,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[PaginatedList[CommandResponse]],
|
||||
@@ -292,7 +361,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
|
||||
device.imei, body.command_type, body.command_content
|
||||
)
|
||||
cmd_log.status = "sent"
|
||||
cmd_log.sent_at = datetime.now(BEIJING_TZ)
|
||||
cmd_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(cmd_log)
|
||||
results.append(BatchCommandResult(
|
||||
@@ -316,6 +385,81 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
|
||||
)
|
||||
|
||||
|
||||
class BroadcastCommandRequest(BaseModel):
|
||||
"""Request body for broadcasting a command to all devices."""
|
||||
command_type: str = Field(default="online_cmd", max_length=30, description="指令类型")
|
||||
command_content: str = Field(..., max_length=500, description="指令内容")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/broadcast",
|
||||
response_model=APIResponse[BatchCommandResponse],
|
||||
status_code=201,
|
||||
summary="广播指令给所有设备 / Broadcast command to all devices",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
@limiter.limit(settings.RATE_LIMIT_WRITE)
|
||||
async def broadcast_command(request: Request, body: BroadcastCommandRequest, db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
向所有设备广播同一指令。尝试通过 TCP 发送给每台设备,TCP 未连接的自动跳过。
|
||||
Broadcast the same command to all devices. Attempts TCP send for each, skips those without active TCP connection.
|
||||
"""
|
||||
from sqlalchemy import select
|
||||
from app.models import Device
|
||||
|
||||
result = await db.execute(select(Device))
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return APIResponse(
|
||||
message="No devices / 没有设备",
|
||||
data=BatchCommandResponse(total=0, sent=0, failed=0, results=[]),
|
||||
)
|
||||
|
||||
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="TCP not connected",
|
||||
))
|
||||
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 = now_cst()
|
||||
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("Broadcast 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"Broadcast: {sent} sent, {failed} skipped (total: {len(devices)})",
|
||||
data=BatchCommandResponse(
|
||||
total=len(results), sent=sent, failed=failed, results=results,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{command_id}",
|
||||
response_model=APIResponse[CommandResponse],
|
||||
|
||||
210
app/routers/device_groups.py
Normal file
210
app/routers/device_groups.py
Normal file
@@ -0,0 +1,210 @@
|
||||
"""
|
||||
Device Groups Router - 设备分组管理接口
|
||||
API endpoints for device group CRUD and membership management.
|
||||
"""
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from pydantic import BaseModel, Field
|
||||
from sqlalchemy import func, select, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_write
|
||||
from app.models import Device, DeviceGroup, DeviceGroupMember
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceGroupCreate,
|
||||
DeviceGroupResponse,
|
||||
DeviceGroupUpdate,
|
||||
DeviceGroupWithCount,
|
||||
DeviceResponse,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/groups", tags=["Device Groups / 设备分组"])
|
||||
|
||||
|
||||
@router.get(
|
||||
"",
|
||||
response_model=APIResponse[list[DeviceGroupWithCount]],
|
||||
summary="获取分组列表 / List device groups",
|
||||
)
|
||||
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||
"""获取所有设备分组及各组设备数。"""
|
||||
result = await db.execute(
|
||||
select(
|
||||
DeviceGroup,
|
||||
func.count(DeviceGroupMember.id).label("cnt"),
|
||||
)
|
||||
.outerjoin(DeviceGroupMember, DeviceGroup.id == DeviceGroupMember.group_id)
|
||||
.group_by(DeviceGroup.id)
|
||||
.order_by(DeviceGroup.name)
|
||||
)
|
||||
groups = []
|
||||
for row in result.all():
|
||||
g = DeviceGroupResponse.model_validate(row[0])
|
||||
groups.append(DeviceGroupWithCount(**g.model_dump(), device_count=row[1]))
|
||||
return APIResponse(data=groups)
|
||||
|
||||
|
||||
@router.post(
|
||||
"",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
status_code=201,
|
||||
summary="创建分组 / Create group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def create_group(body: DeviceGroupCreate, db: AsyncSession = Depends(get_db)):
|
||||
"""创建新设备分组。"""
|
||||
existing = await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.name == body.name)
|
||||
)
|
||||
if existing.scalar_one_or_none():
|
||||
raise HTTPException(status_code=400, detail=f"分组名 '{body.name}' 已存在")
|
||||
group = DeviceGroup(name=body.name, description=body.description, color=body.color)
|
||||
db.add(group)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse[DeviceGroupResponse],
|
||||
summary="更新分组 / Update group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def update_group(
|
||||
group_id: int, body: DeviceGroupUpdate, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""更新分组信息。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for k, v in update_data.items():
|
||||
setattr(group, k, v)
|
||||
await db.flush()
|
||||
await db.refresh(group)
|
||||
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除分组 / Delete group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""删除分组(级联删除成员关系,不删除设备)。"""
|
||||
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||
group = result.scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
# Clear group_id on devices
|
||||
devices_result = await db.execute(select(Device).where(Device.group_id == group_id))
|
||||
for d in devices_result.scalars().all():
|
||||
d.group_id = None
|
||||
await db.delete(group)
|
||||
await db.flush()
|
||||
return APIResponse(message="分组已删除")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[list[DeviceResponse]],
|
||||
summary="获取分组设备 / Get group devices",
|
||||
)
|
||||
async def get_group_devices(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""获取分组内的设备列表。"""
|
||||
result = await db.execute(
|
||||
select(Device)
|
||||
.join(DeviceGroupMember, Device.id == DeviceGroupMember.device_id)
|
||||
.where(DeviceGroupMember.group_id == group_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
return APIResponse(data=[DeviceResponse.model_validate(d) for d in devices])
|
||||
|
||||
|
||||
class GroupMemberRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="设备ID列表")
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="添加设备到分组 / Add devices to group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def add_devices_to_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""添加设备到分组(幂等,已存在的跳过)。"""
|
||||
# Verify group exists
|
||||
group = (await db.execute(
|
||||
select(DeviceGroup).where(DeviceGroup.id == group_id)
|
||||
)).scalar_one_or_none()
|
||||
if not group:
|
||||
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||
|
||||
# Get existing members
|
||||
existing = await db.execute(
|
||||
select(DeviceGroupMember.device_id).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
existing_ids = {row[0] for row in existing.all()}
|
||||
|
||||
added = 0
|
||||
for did in body.device_ids:
|
||||
if did not in existing_ids:
|
||||
db.add(DeviceGroupMember(device_id=did, group_id=group_id))
|
||||
added += 1
|
||||
# Also update device.group_id
|
||||
if body.device_ids:
|
||||
devices = await db.execute(
|
||||
select(Device).where(Device.id.in_(body.device_ids))
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = group_id
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已添加 {added} 台设备到分组",
|
||||
data={"added": added, "already_in_group": len(existing_ids)},
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{group_id}/devices",
|
||||
response_model=APIResponse[dict],
|
||||
summary="从分组移除设备 / Remove devices from group",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def remove_devices_from_group(
|
||||
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||
):
|
||||
"""从分组移除设备。"""
|
||||
result = await db.execute(
|
||||
delete(DeviceGroupMember).where(
|
||||
DeviceGroupMember.group_id == group_id,
|
||||
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||
)
|
||||
)
|
||||
# Clear group_id on removed devices
|
||||
devices = await db.execute(
|
||||
select(Device).where(
|
||||
Device.id.in_(body.device_ids),
|
||||
Device.group_id == group_id,
|
||||
)
|
||||
)
|
||||
for d in devices.scalars().all():
|
||||
d.group_id = None
|
||||
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已移除 {result.rowcount} 台设备",
|
||||
data={"removed": result.rowcount},
|
||||
)
|
||||
@@ -6,9 +6,13 @@ API endpoints for device CRUD operations and statistics.
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.database import get_db
|
||||
from app.models import Device
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
BatchDeviceCreateRequest,
|
||||
@@ -74,6 +78,40 @@ async def device_stats(db: AsyncSession = Depends(get_db)):
|
||||
return APIResponse(data=stats)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出设备列表 CSV / Export devices CSV",
|
||||
)
|
||||
async def export_devices(
|
||||
status: str | None = Query(default=None, description="状态过滤 (online/offline)"),
|
||||
search: str | None = Query(default=None, description="搜索IMEI或名称"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出设备列表为 CSV 文件,支持状态/搜索筛选。"""
|
||||
from sqlalchemy import or_
|
||||
|
||||
query = select(Device)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
if search:
|
||||
pattern = f"%{search}%"
|
||||
query = query.where(or_(Device.imei.ilike(pattern), Device.name.ilike(pattern)))
|
||||
query = query.order_by(Device.id)
|
||||
|
||||
result = await db.execute(query)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "IMEI", "名称", "类型", "状态", "电量%", "信号", "ICCID", "IMSI", "最后心跳", "最后登录", "创建时间"]
|
||||
fields = ["id", "imei", "name", "device_type", "status", "battery_level", "gsm_signal", "iccid", "imsi", "last_heartbeat", "last_login", "created_at"]
|
||||
|
||||
content = build_csv_content(headers, devices, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('devices')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/imei/{imei}",
|
||||
response_model=APIResponse[DeviceResponse],
|
||||
|
||||
256
app/routers/fences.py
Normal file
256
app/routers/fences.py
Normal file
@@ -0,0 +1,256 @@
|
||||
"""Fences Router - geofence management API endpoints."""
|
||||
|
||||
import math
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
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 AttendanceRecord, DeviceFenceBinding, DeviceFenceState, FenceConfig
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceFenceBindRequest,
|
||||
FenceConfigCreate,
|
||||
FenceConfigResponse,
|
||||
FenceConfigUpdate,
|
||||
FenceDeviceDetail,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import fence_service
|
||||
|
||||
router = APIRouter(prefix="/api/fences", tags=["Fences"])
|
||||
|
||||
|
||||
@router.get("", response_model=APIResponse[PaginatedList[FenceConfigResponse]])
|
||||
async def list_fences(
|
||||
is_active: bool | None = Query(default=None),
|
||||
search: str | None = Query(default=None),
|
||||
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 fence_service.get_fences(db, page, page_size, is_active, search)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[FenceConfigResponse.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="围栏统计 / Fence statistics",
|
||||
)
|
||||
async def fence_stats(db: AsyncSession = Depends(get_db)):
|
||||
"""
|
||||
围栏统计:总数、活跃数、绑定设备总数、各围栏当前在内设备数、今日进出事件数。
|
||||
Fence stats: totals, bindings, devices currently inside, today's events.
|
||||
"""
|
||||
from app.config import now_cst
|
||||
now = now_cst()
|
||||
|
||||
total = (await db.execute(select(func.count(FenceConfig.id)))).scalar() or 0
|
||||
active = (await db.execute(
|
||||
select(func.count(FenceConfig.id)).where(FenceConfig.is_active == True) # noqa: E712
|
||||
)).scalar() or 0
|
||||
total_bindings = (await db.execute(select(func.count(DeviceFenceBinding.id)))).scalar() or 0
|
||||
|
||||
# Devices currently inside fences
|
||||
inside_result = await db.execute(
|
||||
select(
|
||||
DeviceFenceState.fence_id,
|
||||
func.count(DeviceFenceState.id),
|
||||
)
|
||||
.where(DeviceFenceState.is_inside == True) # noqa: E712
|
||||
.group_by(DeviceFenceState.fence_id)
|
||||
)
|
||||
devices_inside = {row[0]: row[1] for row in inside_result.all()}
|
||||
total_inside = sum(devices_inside.values())
|
||||
|
||||
# Today's fence attendance events
|
||||
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
|
||||
today_events = (await db.execute(
|
||||
select(func.count(AttendanceRecord.id))
|
||||
.where(
|
||||
AttendanceRecord.attendance_source == "fence",
|
||||
AttendanceRecord.recorded_at >= today_start,
|
||||
)
|
||||
)).scalar() or 0
|
||||
|
||||
# Per-fence summary
|
||||
fence_result = await db.execute(
|
||||
select(FenceConfig.id, FenceConfig.name, FenceConfig.fence_type, FenceConfig.is_active)
|
||||
)
|
||||
fence_summary = []
|
||||
for row in fence_result.all():
|
||||
fid = row[0]
|
||||
fence_summary.append({
|
||||
"fence_id": fid,
|
||||
"name": row[1],
|
||||
"type": row[2],
|
||||
"is_active": bool(row[3]),
|
||||
"devices_inside": devices_inside.get(fid, 0),
|
||||
})
|
||||
|
||||
return APIResponse(data={
|
||||
"total": total,
|
||||
"active": active,
|
||||
"inactive": total - active,
|
||||
"total_bindings": total_bindings,
|
||||
"total_devices_inside": total_inside,
|
||||
"today_events": today_events,
|
||||
"fences": fence_summary,
|
||||
})
|
||||
|
||||
|
||||
@router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]])
|
||||
async def get_all_active(db: AsyncSession = Depends(get_db)):
|
||||
fences = await fence_service.get_all_active_fences(db)
|
||||
return APIResponse(data=[FenceConfigResponse.model_validate(f) for f in fences])
|
||||
|
||||
|
||||
@router.get("/{fence_id}", response_model=APIResponse[FenceConfigResponse])
|
||||
async def get_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.post("", response_model=APIResponse[FenceConfigResponse], status_code=201, dependencies=[Depends(require_write)])
|
||||
async def create_fence(body: FenceConfigCreate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.create_fence(db, body)
|
||||
return APIResponse(message="Fence created", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.put("/{fence_id}", response_model=APIResponse[FenceConfigResponse], dependencies=[Depends(require_write)])
|
||||
async def update_fence(fence_id: int, body: FenceConfigUpdate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.update_fence(db, fence_id, body)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence updated", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.delete("/{fence_id}", response_model=APIResponse, dependencies=[Depends(require_write)])
|
||||
async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not await fence_service.delete_fence(db, fence_id):
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence deleted")
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{fence_id}/events",
|
||||
response_model=APIResponse[PaginatedList[dict]],
|
||||
summary="围栏进出事件历史 / Fence events history",
|
||||
)
|
||||
async def fence_events(
|
||||
fence_id: int,
|
||||
start_time: datetime | None = Query(default=None, description="开始时间"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间"),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取围栏的进出事件历史(来源为 fence 的考勤记录)。
|
||||
Get fence entry/exit events (attendance records with source=fence).
|
||||
"""
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
|
||||
from sqlalchemy.dialects.sqlite import JSON as _JSON
|
||||
|
||||
filters = [
|
||||
AttendanceRecord.attendance_source == "fence",
|
||||
]
|
||||
if start_time:
|
||||
filters.append(AttendanceRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
filters.append(AttendanceRecord.recorded_at <= end_time)
|
||||
|
||||
# Filter by fence_id in lbs_data JSON (fence_auto records store fence_id there)
|
||||
# Since SQLite JSON support is limited, we use LIKE for the fence_id match
|
||||
filters.append(AttendanceRecord.lbs_data.like(f'%"fence_id": {fence_id}%'))
|
||||
|
||||
count_q = select(func.count(AttendanceRecord.id)).where(*filters)
|
||||
total = (await db.execute(count_q)).scalar() or 0
|
||||
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
select(AttendanceRecord)
|
||||
.where(*filters)
|
||||
.order_by(AttendanceRecord.recorded_at.desc())
|
||||
.offset(offset).limit(page_size)
|
||||
)
|
||||
records = result.scalars().all()
|
||||
|
||||
items = []
|
||||
for r in records:
|
||||
items.append({
|
||||
"id": r.id,
|
||||
"device_id": r.device_id,
|
||||
"imei": r.imei,
|
||||
"event_type": r.attendance_type,
|
||||
"latitude": r.latitude,
|
||||
"longitude": r.longitude,
|
||||
"address": r.address,
|
||||
"recorded_at": str(r.recorded_at),
|
||||
})
|
||||
|
||||
return APIResponse(data=PaginatedList(
|
||||
items=items,
|
||||
total=total, page=page, page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse[list[FenceDeviceDetail]],
|
||||
summary="获取围栏绑定的设备列表",
|
||||
)
|
||||
async def get_fence_devices(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
items = await fence_service.get_fence_devices(db, fence_id)
|
||||
return APIResponse(data=[FenceDeviceDetail(**item) for item in items])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="绑定设备到围栏",
|
||||
)
|
||||
async def bind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await fence_service.bind_devices_to_fence(db, fence_id, body.device_ids)
|
||||
if result.get("error"):
|
||||
raise HTTPException(status_code=404, detail=result["error"])
|
||||
return APIResponse(
|
||||
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
|
||||
data=result,
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="解绑设备与围栏",
|
||||
)
|
||||
async def unbind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
count = await fence_service.unbind_devices_from_fence(db, fence_id, body.device_ids)
|
||||
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||
@@ -73,6 +73,124 @@ async def list_heartbeats(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="心跳统计 / Heartbeat statistics",
|
||||
)
|
||||
async def heartbeat_stats(
|
||||
hours: int = Query(default=24, ge=1, le=168, description="统计时间范围(小时)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
心跳数据统计:总记录数、活跃设备数、平均电量、按设备心跳间隔分析。
|
||||
Heartbeat stats: total, active devices, avg battery, interval analysis.
|
||||
"""
|
||||
from app.config import now_cst
|
||||
from app.models import Device
|
||||
from datetime import timedelta
|
||||
|
||||
now = now_cst()
|
||||
cutoff = now - timedelta(hours=hours)
|
||||
|
||||
# Total in range
|
||||
total = (await db.execute(
|
||||
select(func.count(HeartbeatRecord.id)).where(HeartbeatRecord.created_at >= cutoff)
|
||||
)).scalar() or 0
|
||||
|
||||
# Unique devices with heartbeats in range
|
||||
active_devices = (await db.execute(
|
||||
select(func.count(func.distinct(HeartbeatRecord.device_id)))
|
||||
.where(HeartbeatRecord.created_at >= cutoff)
|
||||
)).scalar() or 0
|
||||
|
||||
# Total registered devices
|
||||
total_devices = (await db.execute(select(func.count(Device.id)))).scalar() or 0
|
||||
|
||||
# Avg battery and signal in range
|
||||
avg_result = await db.execute(
|
||||
select(
|
||||
func.avg(HeartbeatRecord.battery_level),
|
||||
func.avg(HeartbeatRecord.gsm_signal),
|
||||
).where(HeartbeatRecord.created_at >= cutoff)
|
||||
)
|
||||
avg_row = avg_result.one()
|
||||
avg_battery = round(float(avg_row[0]), 1) if avg_row[0] else None
|
||||
avg_signal = round(float(avg_row[1]), 1) if avg_row[1] else None
|
||||
|
||||
# Per-device heartbeat count in range (for interval estimation)
|
||||
# Devices with < expected heartbeats may be anomalous
|
||||
per_device_result = await db.execute(
|
||||
select(
|
||||
HeartbeatRecord.device_id,
|
||||
HeartbeatRecord.imei,
|
||||
func.count(HeartbeatRecord.id).label("hb_count"),
|
||||
func.min(HeartbeatRecord.created_at).label("first_hb"),
|
||||
func.max(HeartbeatRecord.created_at).label("last_hb"),
|
||||
)
|
||||
.where(HeartbeatRecord.created_at >= cutoff)
|
||||
.group_by(HeartbeatRecord.device_id, HeartbeatRecord.imei)
|
||||
.order_by(func.count(HeartbeatRecord.id).desc())
|
||||
)
|
||||
device_intervals = []
|
||||
anomalous_devices = []
|
||||
for row in per_device_result.all():
|
||||
hb_count = row[2]
|
||||
first_hb = row[3]
|
||||
last_hb = row[4]
|
||||
if hb_count > 1 and first_hb and last_hb:
|
||||
span_min = (last_hb - first_hb).total_seconds() / 60
|
||||
avg_interval_min = round(span_min / (hb_count - 1), 1) if hb_count > 1 else 0
|
||||
else:
|
||||
avg_interval_min = 0
|
||||
entry = {
|
||||
"device_id": row[0], "imei": row[1],
|
||||
"heartbeat_count": hb_count,
|
||||
"avg_interval_minutes": avg_interval_min,
|
||||
}
|
||||
device_intervals.append(entry)
|
||||
# Flag devices with very few heartbeats (expected ~12/h * hours)
|
||||
if hb_count < max(1, hours * 2):
|
||||
anomalous_devices.append(entry)
|
||||
|
||||
return APIResponse(data={
|
||||
"period_hours": hours,
|
||||
"total_heartbeats": total,
|
||||
"active_devices": active_devices,
|
||||
"total_devices": total_devices,
|
||||
"inactive_devices": total_devices - active_devices,
|
||||
"avg_battery_level": avg_battery,
|
||||
"avg_gsm_signal": avg_signal,
|
||||
"device_intervals": device_intervals[:20],
|
||||
"anomalous_devices": anomalous_devices[:10],
|
||||
})
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除心跳记录 / Batch delete heartbeats",
|
||||
)
|
||||
async def batch_delete_heartbeats(
|
||||
body: dict,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""批量删除心跳记录,最多500条。 / Batch delete heartbeat records (max 500)."""
|
||||
record_ids = body.get("record_ids", [])
|
||||
if not record_ids:
|
||||
raise HTTPException(status_code=400, detail="record_ids is required")
|
||||
if len(record_ids) > 500:
|
||||
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
||||
result = await db.execute(
|
||||
select(HeartbeatRecord).where(HeartbeatRecord.id.in_(record_ids))
|
||||
)
|
||||
records = list(result.scalars().all())
|
||||
for r in records:
|
||||
await db.delete(r)
|
||||
await db.flush()
|
||||
return APIResponse(data={"deleted": len(records)})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{heartbeat_id}",
|
||||
response_model=APIResponse[HeartbeatRecordResponse],
|
||||
|
||||
@@ -4,14 +4,17 @@ API endpoints for querying location records and device tracks.
|
||||
"""
|
||||
|
||||
import math
|
||||
from datetime import datetime
|
||||
from datetime import datetime, timedelta
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from fastapi.responses import Response
|
||||
from sqlalchemy import func, select, delete, case, extract
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_write
|
||||
from app.database import get_db
|
||||
from app.models import LocationRecord
|
||||
from app.services.export_utils import build_csv_content, csv_filename
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
LocationRecordResponse,
|
||||
@@ -30,6 +33,7 @@ router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"])
|
||||
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)"),
|
||||
exclude_type: str | None = Query(default=None, description="排除定位类型前缀 / Exclude location type prefix (e.g. lbs)"),
|
||||
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"),
|
||||
@@ -44,6 +48,7 @@ async def list_locations(
|
||||
db,
|
||||
device_id=device_id,
|
||||
location_type=location_type,
|
||||
exclude_type=exclude_type,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
page=page,
|
||||
@@ -60,6 +65,241 @@ async def list_locations(
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/stats",
|
||||
response_model=APIResponse[dict],
|
||||
summary="位置数据统计 / Location statistics",
|
||||
)
|
||||
async def location_stats(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
位置数据统计:总记录数、按定位类型分布、有坐标率、按小时分布(24h)。
|
||||
Location statistics: total, by type, coordinate rate, hourly distribution.
|
||||
"""
|
||||
filters = []
|
||||
if device_id is not None:
|
||||
filters.append(LocationRecord.device_id == device_id)
|
||||
if start_time:
|
||||
filters.append(LocationRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
filters.append(LocationRecord.recorded_at <= end_time)
|
||||
|
||||
where = filters if filters else []
|
||||
|
||||
# Total
|
||||
q = select(func.count(LocationRecord.id))
|
||||
if where:
|
||||
q = q.where(*where)
|
||||
total = (await db.execute(q)).scalar() or 0
|
||||
|
||||
# With coordinates
|
||||
q2 = select(func.count(LocationRecord.id)).where(
|
||||
LocationRecord.latitude.is_not(None), LocationRecord.longitude.is_not(None)
|
||||
)
|
||||
if where:
|
||||
q2 = q2.where(*where)
|
||||
with_coords = (await db.execute(q2)).scalar() or 0
|
||||
|
||||
# By type
|
||||
q3 = select(LocationRecord.location_type, func.count(LocationRecord.id)).group_by(LocationRecord.location_type)
|
||||
if where:
|
||||
q3 = q3.where(*where)
|
||||
type_result = await db.execute(q3)
|
||||
by_type = {row[0]: row[1] for row in type_result.all()}
|
||||
|
||||
# Hourly distribution (hour 0-23)
|
||||
q4 = select(
|
||||
extract("hour", LocationRecord.recorded_at).label("hour"),
|
||||
func.count(LocationRecord.id),
|
||||
).group_by("hour").order_by("hour")
|
||||
if where:
|
||||
q4 = q4.where(*where)
|
||||
hour_result = await db.execute(q4)
|
||||
hourly = {int(row[0]): row[1] for row in hour_result.all()}
|
||||
|
||||
return APIResponse(data={
|
||||
"total": total,
|
||||
"with_coordinates": with_coords,
|
||||
"without_coordinates": total - with_coords,
|
||||
"coordinate_rate": round(with_coords / total * 100, 1) if total else 0,
|
||||
"by_type": by_type,
|
||||
"hourly_distribution": hourly,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/export",
|
||||
summary="导出位置记录 CSV / Export location records CSV",
|
||||
)
|
||||
async def export_locations(
|
||||
device_id: int | None = Query(default=None, description="设备ID"),
|
||||
location_type: str | None = Query(default=None, description="定位类型 (gps/lbs/wifi)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""导出位置记录为 CSV,支持设备/类型/时间筛选。最多导出 50000 条。"""
|
||||
query = select(LocationRecord)
|
||||
if device_id is not None:
|
||||
query = query.where(LocationRecord.device_id == device_id)
|
||||
if location_type:
|
||||
query = query.where(LocationRecord.location_type == location_type)
|
||||
if start_time:
|
||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(LocationRecord.recorded_at <= end_time)
|
||||
query = query.order_by(LocationRecord.recorded_at.desc()).limit(50000)
|
||||
|
||||
result = await db.execute(query)
|
||||
records = list(result.scalars().all())
|
||||
|
||||
headers = ["ID", "设备ID", "IMEI", "定位类型", "纬度", "经度", "速度", "航向", "卫星数", "地址", "记录时间", "创建时间"]
|
||||
fields = ["id", "device_id", "imei", "location_type", "latitude", "longitude", "speed", "course", "gps_satellites", "address", "recorded_at", "created_at"]
|
||||
|
||||
content = build_csv_content(headers, records, fields)
|
||||
return Response(
|
||||
content=content,
|
||||
media_type="text/csv; charset=utf-8",
|
||||
headers={"Content-Disposition": f"attachment; filename={csv_filename('locations')}"},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/heatmap",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="热力图数据 / Heatmap data",
|
||||
)
|
||||
async def location_heatmap(
|
||||
device_id: int | None = Query(default=None, description="设备ID (可选)"),
|
||||
start_time: datetime | None = Query(default=None, description="开始时间"),
|
||||
end_time: datetime | None = Query(default=None, description="结束时间"),
|
||||
precision: int = Query(default=3, ge=1, le=6, description="坐标精度 (小数位数, 3≈100m)"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
返回位置热力图数据:按坐标网格聚合,返回 [{lat, lng, weight}]。
|
||||
precision=3 约100m网格,precision=4 约10m网格。
|
||||
"""
|
||||
filters = [
|
||||
LocationRecord.latitude.is_not(None),
|
||||
LocationRecord.longitude.is_not(None),
|
||||
]
|
||||
if device_id is not None:
|
||||
filters.append(LocationRecord.device_id == device_id)
|
||||
if start_time:
|
||||
filters.append(LocationRecord.recorded_at >= start_time)
|
||||
if end_time:
|
||||
filters.append(LocationRecord.recorded_at <= end_time)
|
||||
|
||||
factor = 10 ** precision
|
||||
result = await db.execute(
|
||||
select(
|
||||
func.round(LocationRecord.latitude * factor).label("lat_grid"),
|
||||
func.round(LocationRecord.longitude * factor).label("lng_grid"),
|
||||
func.count(LocationRecord.id).label("weight"),
|
||||
)
|
||||
.where(*filters)
|
||||
.group_by("lat_grid", "lng_grid")
|
||||
.order_by(func.count(LocationRecord.id).desc())
|
||||
.limit(10000)
|
||||
)
|
||||
points = [
|
||||
{
|
||||
"lat": round(row[0] / factor, precision),
|
||||
"lng": round(row[1] / factor, precision),
|
||||
"weight": row[2],
|
||||
}
|
||||
for row in result.all()
|
||||
]
|
||||
return APIResponse(data=points)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/track-summary/{device_id}",
|
||||
response_model=APIResponse[dict],
|
||||
summary="轨迹摘要 / Track summary",
|
||||
)
|
||||
async def track_summary(
|
||||
device_id: int,
|
||||
start_time: datetime = Query(..., description="开始时间"),
|
||||
end_time: datetime = Query(..., description="结束时间"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
轨迹统计摘要:总距离(km)、运动时长、最高速度、平均速度、轨迹点数。
|
||||
Track summary: total distance, duration, max/avg speed, point count.
|
||||
"""
|
||||
import math as _math
|
||||
|
||||
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")
|
||||
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=50000)
|
||||
|
||||
if not records:
|
||||
return APIResponse(data={
|
||||
"device_id": device_id,
|
||||
"point_count": 0,
|
||||
"total_distance_km": 0,
|
||||
"duration_minutes": 0,
|
||||
"max_speed_kmh": 0,
|
||||
"avg_speed_kmh": 0,
|
||||
"by_type": {},
|
||||
})
|
||||
|
||||
# Haversine distance calculation
|
||||
def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
R = 6371.0 # km
|
||||
dlat = _math.radians(lat2 - lat1)
|
||||
dlon = _math.radians(lon2 - lon1)
|
||||
a = _math.sin(dlat / 2) ** 2 + _math.cos(_math.radians(lat1)) * _math.cos(_math.radians(lat2)) * _math.sin(dlon / 2) ** 2
|
||||
return R * 2 * _math.atan2(_math.sqrt(a), _math.sqrt(1 - a))
|
||||
|
||||
total_distance = 0.0
|
||||
max_speed = 0.0
|
||||
speeds = []
|
||||
type_counts: dict[str, int] = {}
|
||||
prev = None
|
||||
|
||||
for r in records:
|
||||
t = r.location_type or "unknown"
|
||||
type_counts[t] = type_counts.get(t, 0) + 1
|
||||
|
||||
if r.speed is not None and r.speed > max_speed:
|
||||
max_speed = r.speed
|
||||
|
||||
if prev is not None and r.latitude is not None and r.longitude is not None and prev.latitude is not None and prev.longitude is not None:
|
||||
d = _haversine(prev.latitude, prev.longitude, r.latitude, r.longitude)
|
||||
total_distance += d
|
||||
|
||||
if r.latitude is not None and r.longitude is not None:
|
||||
prev = r
|
||||
|
||||
first_time = records[0].recorded_at
|
||||
last_time = records[-1].recorded_at
|
||||
duration_min = (last_time - first_time).total_seconds() / 60 if last_time > first_time else 0
|
||||
avg_speed = (total_distance / (duration_min / 60)) if duration_min > 0 else 0
|
||||
|
||||
return APIResponse(data={
|
||||
"device_id": device_id,
|
||||
"point_count": len(records),
|
||||
"total_distance_km": round(total_distance, 2),
|
||||
"duration_minutes": round(duration_min, 1),
|
||||
"max_speed_kmh": round(max_speed, 1),
|
||||
"avg_speed_kmh": round(avg_speed, 1),
|
||||
"start_time": str(first_time),
|
||||
"end_time": str(last_time),
|
||||
"by_type": type_counts,
|
||||
})
|
||||
|
||||
|
||||
@router.get(
|
||||
"/latest/{device_id}",
|
||||
response_model=APIResponse[LocationRecordResponse | None],
|
||||
@@ -139,6 +379,105 @@ async def device_track(
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/batch-delete",
|
||||
response_model=APIResponse[dict],
|
||||
summary="批量删除位置记录 / Batch delete location records",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def batch_delete_locations(
|
||||
location_ids: list[int] = Body(..., min_length=1, max_length=500, embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""批量删除位置记录(最多500条)。"""
|
||||
result = await db.execute(
|
||||
delete(LocationRecord).where(LocationRecord.id.in_(location_ids))
|
||||
)
|
||||
await db.flush()
|
||||
return APIResponse(
|
||||
message=f"已删除 {result.rowcount} 条位置记录",
|
||||
data={"deleted": result.rowcount, "requested": len(location_ids)},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/delete-no-coords",
|
||||
response_model=APIResponse[dict],
|
||||
summary="删除无坐标位置记录 / Delete location records without coordinates",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_no_coord_locations(
|
||||
device_id: int | None = Body(default=None, description="设备ID (可选,不传则所有设备)"),
|
||||
start_time: str | None = Body(default=None, description="开始时间 ISO 8601"),
|
||||
end_time: str | None = Body(default=None, description="结束时间 ISO 8601"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除经纬度为空的位置记录,可按设备和时间范围过滤。"""
|
||||
from datetime import datetime as dt
|
||||
|
||||
conditions = [
|
||||
(LocationRecord.latitude.is_(None)) | (LocationRecord.longitude.is_(None))
|
||||
]
|
||||
if device_id is not None:
|
||||
conditions.append(LocationRecord.device_id == device_id)
|
||||
if start_time:
|
||||
conditions.append(LocationRecord.recorded_at >= dt.fromisoformat(start_time))
|
||||
if end_time:
|
||||
conditions.append(LocationRecord.recorded_at <= dt.fromisoformat(end_time))
|
||||
|
||||
# Count first
|
||||
count_result = await db.execute(
|
||||
select(func.count(LocationRecord.id)).where(*conditions)
|
||||
)
|
||||
count = count_result.scalar() or 0
|
||||
|
||||
if count > 0:
|
||||
await db.execute(delete(LocationRecord).where(*conditions))
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(
|
||||
message=f"已删除 {count} 条无坐标记录",
|
||||
data={"deleted": count},
|
||||
)
|
||||
|
||||
|
||||
@router.post(
|
||||
"/cleanup",
|
||||
response_model=APIResponse[dict],
|
||||
summary="清理旧位置记录 / Cleanup old location records",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def cleanup_locations(
|
||||
days: int = Body(..., ge=1, le=3650, description="删除N天前的记录", embed=True),
|
||||
device_id: int | None = Body(default=None, description="限定设备ID (可选)", embed=True),
|
||||
location_type: str | None = Body(default=None, description="限定定位类型 (可选, 如 lbs/wifi)", embed=True),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
删除 N 天前的旧位置记录,支持按设备和定位类型筛选。
|
||||
Delete location records older than N days, with optional device/type filters.
|
||||
"""
|
||||
cutoff = datetime.now() - timedelta(days=days)
|
||||
conditions = [LocationRecord.created_at < cutoff]
|
||||
if device_id is not None:
|
||||
conditions.append(LocationRecord.device_id == device_id)
|
||||
if location_type:
|
||||
conditions.append(LocationRecord.location_type == location_type)
|
||||
|
||||
count = (await db.execute(
|
||||
select(func.count(LocationRecord.id)).where(*conditions)
|
||||
)).scalar() or 0
|
||||
|
||||
if count > 0:
|
||||
await db.execute(delete(LocationRecord).where(*conditions))
|
||||
await db.flush()
|
||||
|
||||
return APIResponse(
|
||||
message=f"已清理 {count} 条 {days} 天前的位置记录",
|
||||
data={"deleted": count, "cutoff_days": days},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse[LocationRecordResponse],
|
||||
@@ -153,3 +492,22 @@ async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除位置记录 / Delete location record",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID删除位置记录 / Delete location record 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")
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return APIResponse(message="Location record deleted")
|
||||
|
||||
299
app/routers/system.py
Normal file
299
app/routers/system.py
Normal file
@@ -0,0 +1,299 @@
|
||||
"""
|
||||
System Management Router - 系统管理接口
|
||||
Audit logs, runtime config, database backup, firmware info.
|
||||
"""
|
||||
|
||||
import shutil
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Depends, Query, Request
|
||||
from fastapi.responses import FileResponse
|
||||
from sqlalchemy import select, func, delete
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import settings, now_cst
|
||||
from app.database import get_db
|
||||
from app.dependencies import require_admin
|
||||
from app.models import AuditLog, Device
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
AuditLogResponse,
|
||||
PaginatedList,
|
||||
SystemConfigResponse,
|
||||
SystemConfigUpdate,
|
||||
)
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["System / 系统管理"])
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Logs
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/audit-logs",
|
||||
response_model=APIResponse[PaginatedList[AuditLogResponse]],
|
||||
summary="获取审计日志 / List audit logs",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_audit_logs(
|
||||
page: int = Query(1, ge=1),
|
||||
page_size: int = Query(20, ge=1, le=100),
|
||||
method: str | None = Query(None, description="Filter by HTTP method (POST/PUT/DELETE)"),
|
||||
path_contains: str | None = Query(None, description="Filter by path keyword"),
|
||||
operator: str | None = Query(None, description="Filter by operator name"),
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""查询操作审计日志 (admin only)。"""
|
||||
query = select(AuditLog)
|
||||
count_query = select(func.count(AuditLog.id))
|
||||
|
||||
if method:
|
||||
query = query.where(AuditLog.method == method.upper())
|
||||
count_query = count_query.where(AuditLog.method == method.upper())
|
||||
if path_contains:
|
||||
query = query.where(AuditLog.path.contains(path_contains))
|
||||
count_query = count_query.where(AuditLog.path.contains(path_contains))
|
||||
if operator:
|
||||
query = query.where(AuditLog.operator == operator)
|
||||
count_query = count_query.where(AuditLog.operator == operator)
|
||||
if start_time:
|
||||
query = query.where(AuditLog.created_at >= start_time)
|
||||
count_query = count_query.where(AuditLog.created_at >= start_time)
|
||||
if end_time:
|
||||
query = query.where(AuditLog.created_at <= end_time)
|
||||
count_query = count_query.where(AuditLog.created_at <= end_time)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
total_pages = (total + page_size - 1) // page_size
|
||||
|
||||
rows = await db.execute(
|
||||
query.order_by(AuditLog.id.desc())
|
||||
.offset((page - 1) * page_size)
|
||||
.limit(page_size)
|
||||
)
|
||||
items = [AuditLogResponse.model_validate(r) for r in rows.scalars().all()]
|
||||
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=items, total=total, page=page,
|
||||
page_size=page_size, total_pages=total_pages,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/audit-logs",
|
||||
response_model=APIResponse,
|
||||
summary="清理审计日志 / Clean audit logs",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def clean_audit_logs(
|
||||
days: int = Query(..., ge=1, le=3650, description="Delete logs older than N days"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""删除N天前的审计日志 (admin only)。"""
|
||||
cutoff = now_cst() - timedelta(days=days)
|
||||
result = await db.execute(
|
||||
delete(AuditLog).where(AuditLog.created_at < cutoff)
|
||||
)
|
||||
return APIResponse(
|
||||
message=f"已删除 {result.rowcount} 条 {days} 天前的审计日志",
|
||||
data={"deleted": result.rowcount},
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System Config
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/config",
|
||||
response_model=APIResponse[SystemConfigResponse],
|
||||
summary="获取运行时配置 / Get runtime config",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def get_config():
|
||||
"""获取当前运行时配置参数 (admin only)。"""
|
||||
return APIResponse(data=SystemConfigResponse(
|
||||
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||
track_max_points=settings.TRACK_MAX_POINTS,
|
||||
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||
))
|
||||
|
||||
|
||||
@router.put(
|
||||
"/config",
|
||||
response_model=APIResponse[SystemConfigResponse],
|
||||
summary="更新运行时配置 / Update runtime config",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def update_config(body: SystemConfigUpdate):
|
||||
"""更新运行时配置参数 (admin only)。仅影响当前进程,重启后恢复 .env 值。"""
|
||||
update_data = body.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
attr_name = key.upper()
|
||||
if hasattr(settings, attr_name):
|
||||
object.__setattr__(settings, attr_name, value)
|
||||
|
||||
return APIResponse(
|
||||
message=f"已更新 {len(update_data)} 项配置 (进程级,重启后恢复)",
|
||||
data=SystemConfigResponse(
|
||||
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||
track_max_points=settings.TRACK_MAX_POINTS,
|
||||
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Database Backup
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.post(
|
||||
"/backup",
|
||||
summary="创建数据库备份 / Create database backup",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def create_backup():
|
||||
"""
|
||||
触发 SQLite 数据库备份,返回备份文件下载 (admin only)。
|
||||
使用 SQLite 原生 backup API 确保一致性。
|
||||
"""
|
||||
import aiosqlite
|
||||
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
if not Path(db_path).exists():
|
||||
return APIResponse(code=500, message="Database file not found")
|
||||
|
||||
backup_dir = Path(db_path).parent / "backups"
|
||||
backup_dir.mkdir(exist_ok=True)
|
||||
|
||||
timestamp = now_cst().strftime("%Y%m%d_%H%M%S")
|
||||
backup_name = f"badge_admin_backup_{timestamp}.db"
|
||||
backup_path = backup_dir / backup_name
|
||||
|
||||
# Use SQLite online backup API for consistency
|
||||
async with aiosqlite.connect(db_path) as source:
|
||||
async with aiosqlite.connect(str(backup_path)) as dest:
|
||||
await source.backup(dest)
|
||||
|
||||
size_mb = round(backup_path.stat().st_size / 1024 / 1024, 2)
|
||||
|
||||
return FileResponse(
|
||||
path=str(backup_path),
|
||||
filename=backup_name,
|
||||
media_type="application/octet-stream",
|
||||
headers={
|
||||
"X-Backup-Size-MB": str(size_mb),
|
||||
"X-Backup-Timestamp": timestamp,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
@router.get(
|
||||
"/backups",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="列出数据库备份 / List backups",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_backups():
|
||||
"""列出所有已有的数据库备份文件 (admin only)。"""
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
backup_dir = Path(db_path).parent / "backups"
|
||||
|
||||
if not backup_dir.exists():
|
||||
return APIResponse(data=[])
|
||||
|
||||
backups = []
|
||||
for f in sorted(backup_dir.glob("*.db"), reverse=True):
|
||||
backups.append({
|
||||
"filename": f.name,
|
||||
"size_mb": round(f.stat().st_size / 1024 / 1024, 2),
|
||||
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||
})
|
||||
return APIResponse(data=backups)
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/backups/{filename}",
|
||||
response_model=APIResponse,
|
||||
summary="删除数据库备份 / Delete backup",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def delete_backup(filename: str):
|
||||
"""删除指定的数据库备份文件 (admin only)。"""
|
||||
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||
backup_path = Path(db_path).parent / "backups" / filename
|
||||
|
||||
if not backup_path.exists() or not backup_path.suffix == ".db":
|
||||
return APIResponse(code=404, message="Backup not found")
|
||||
|
||||
# Prevent path traversal
|
||||
if ".." in filename or "/" in filename:
|
||||
return APIResponse(code=400, message="Invalid filename")
|
||||
|
||||
backup_path.unlink()
|
||||
return APIResponse(message=f"已删除备份 {filename}")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Firmware Info
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
@router.get(
|
||||
"/firmware",
|
||||
response_model=APIResponse[list[dict]],
|
||||
summary="设备固件信息 / Device firmware info",
|
||||
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||
)
|
||||
async def list_firmware_info(
|
||||
status: str | None = Query(None, description="Filter by device status"),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
"""
|
||||
获取所有设备的固件相关信息 (型号、ICCID、IMSI等)。
|
||||
实际固件版本需通过 VERSION# 指令查询 (admin only)。
|
||||
"""
|
||||
query = select(Device).order_by(Device.id)
|
||||
if status:
|
||||
query = query.where(Device.status == status)
|
||||
|
||||
result = await db.execute(query)
|
||||
devices = result.scalars().all()
|
||||
|
||||
firmware_list = []
|
||||
for d in devices:
|
||||
firmware_list.append({
|
||||
"device_id": d.id,
|
||||
"imei": d.imei,
|
||||
"name": d.name,
|
||||
"device_type": d.device_type,
|
||||
"status": d.status,
|
||||
"iccid": d.iccid,
|
||||
"imsi": d.imsi,
|
||||
"last_login": d.last_login.isoformat() if d.last_login else None,
|
||||
"last_heartbeat": d.last_heartbeat.isoformat() if d.last_heartbeat else None,
|
||||
})
|
||||
|
||||
return APIResponse(data=firmware_list)
|
||||
247
app/schemas.py
247
app/schemas.py
@@ -59,6 +59,7 @@ class DeviceUpdate(BaseModel):
|
||||
imsi: str | None = Field(None, max_length=20)
|
||||
timezone: str | None = Field(None, max_length=30)
|
||||
language: str | None = Field(None, max_length=10)
|
||||
group_id: int | None = None
|
||||
|
||||
|
||||
class DeviceResponse(DeviceBase):
|
||||
@@ -72,6 +73,7 @@ class DeviceResponse(DeviceBase):
|
||||
last_login: datetime | None = None
|
||||
iccid: str | None = None
|
||||
imsi: str | None = None
|
||||
group_id: int | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
@@ -121,6 +123,7 @@ class LocationRecordResponse(LocationRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -169,6 +172,7 @@ class AlarmRecordResponse(AlarmRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
acknowledged: bool
|
||||
created_at: datetime
|
||||
|
||||
@@ -211,6 +215,7 @@ class HeartbeatRecordResponse(HeartbeatRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -226,6 +231,7 @@ class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]])
|
||||
class AttendanceRecordBase(BaseModel):
|
||||
device_id: int
|
||||
attendance_type: str = Field(..., max_length=20)
|
||||
attendance_source: str = Field(default="device", max_length=20) # device, bluetooth, fence
|
||||
protocol_number: int
|
||||
gps_positioned: bool = False
|
||||
latitude: float | None = Field(None, ge=-90, le=90)
|
||||
@@ -240,7 +246,7 @@ class AttendanceRecordBase(BaseModel):
|
||||
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
|
||||
lbs_data: list[dict[str, Any]] | dict[str, Any] | None = None
|
||||
address: str | None = None
|
||||
recorded_at: datetime
|
||||
|
||||
@@ -253,6 +259,7 @@ class AttendanceRecordResponse(AttendanceRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -298,6 +305,7 @@ class BluetoothRecordResponse(BluetoothRecordBase):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
imei: str | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
@@ -368,6 +376,117 @@ class BeaconConfigResponse(BaseModel):
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceBeaconBindRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表")
|
||||
|
||||
|
||||
class BeaconDeviceDetail(BaseModel):
|
||||
"""Binding detail with device info."""
|
||||
binding_id: int
|
||||
device_id: int
|
||||
device_name: str | None = None
|
||||
imei: str | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Fence Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class FenceConfigCreate(BaseModel):
|
||||
name: str = Field(..., max_length=100, description="围栏名称")
|
||||
fence_type: Literal["circle", "polygon", "rectangle"] = Field(..., description="围栏类型")
|
||||
center_lat: float | None = Field(None, ge=-90, le=90, description="中心纬度 (WGS-84)")
|
||||
center_lng: float | None = Field(None, ge=-180, le=180, description="中心经度 (WGS-84)")
|
||||
radius: float | None = Field(None, ge=0, description="半径 (米)")
|
||||
points: str | None = Field(None, description="多边形顶点 JSON [[lng,lat],...]")
|
||||
color: str = Field(default="#3b82f6", max_length=20, description="边框颜色")
|
||||
fill_color: str | None = Field(None, max_length=20, description="填充颜色")
|
||||
fill_opacity: float = Field(default=0.2, ge=0, le=1, description="填充透明度")
|
||||
description: str | None = Field(None, description="描述")
|
||||
is_active: bool = Field(default=True, description="是否启用")
|
||||
|
||||
|
||||
class FenceConfigUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
fence_type: Literal["circle", "polygon", "rectangle"] | None = None
|
||||
center_lat: float | None = Field(None, ge=-90, le=90)
|
||||
center_lng: float | None = Field(None, ge=-180, le=180)
|
||||
radius: float | None = Field(None, ge=0)
|
||||
points: str | None = None
|
||||
color: str | None = Field(None, max_length=20)
|
||||
fill_color: str | None = Field(None, max_length=20)
|
||||
fill_opacity: float | None = Field(None, ge=0, le=1)
|
||||
description: str | None = None
|
||||
is_active: bool | None = None
|
||||
|
||||
|
||||
class FenceConfigResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
fence_type: str
|
||||
center_lat: float | None = None
|
||||
center_lng: float | None = None
|
||||
radius: float | None = None
|
||||
points: str | None = None
|
||||
color: str
|
||||
fill_color: str | None = None
|
||||
fill_opacity: float
|
||||
description: str | None = None
|
||||
is_active: bool
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceFenceBindRequest(BaseModel):
|
||||
device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表")
|
||||
|
||||
|
||||
class FenceBindForDeviceRequest(BaseModel):
|
||||
fence_ids: list[int] = Field(..., min_length=1, max_length=100, description="围栏ID列表")
|
||||
|
||||
|
||||
class DeviceFenceBindingResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
device_id: int
|
||||
fence_id: int
|
||||
created_at: datetime
|
||||
|
||||
|
||||
class FenceDeviceDetail(BaseModel):
|
||||
"""Binding detail with device info."""
|
||||
binding_id: int
|
||||
device_id: int
|
||||
device_name: str | None = None
|
||||
imei: str | None = None
|
||||
is_inside: bool = False
|
||||
last_check_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceFenceDetail(BaseModel):
|
||||
"""Binding detail with fence info."""
|
||||
binding_id: int
|
||||
fence_id: int
|
||||
fence_name: str | None = None
|
||||
fence_type: str | None = None
|
||||
is_inside: bool = False
|
||||
last_check_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Command Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
@@ -488,6 +607,132 @@ class CommandListResponse(APIResponse[PaginatedList[CommandResponse]]):
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device Group schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class DeviceGroupCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="分组名称")
|
||||
description: str | None = Field(None, max_length=500, description="描述")
|
||||
color: str = Field(default="#3b82f6", max_length=20, description="颜色")
|
||||
|
||||
|
||||
class DeviceGroupUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
description: str | None = None
|
||||
color: str | None = Field(None, max_length=20)
|
||||
|
||||
|
||||
class DeviceGroupResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
description: str | None = None
|
||||
color: str
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
class DeviceGroupWithCount(DeviceGroupResponse):
|
||||
device_count: int = 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Alert Rule schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AlertRuleCreate(BaseModel):
|
||||
name: str = Field(..., min_length=1, max_length=100, description="规则名称")
|
||||
rule_type: Literal["low_battery", "no_heartbeat", "fence_stay", "speed_limit", "offline_duration"] = Field(
|
||||
..., description="规则类型"
|
||||
)
|
||||
conditions: dict = Field(..., description="条件参数, 如 {\"threshold\": 20}")
|
||||
is_active: bool = Field(default=True)
|
||||
device_ids: str | None = Field(None, description="适用设备ID (逗号分隔), null=全部")
|
||||
group_id: int | None = Field(None, description="适用分组ID")
|
||||
description: str | None = Field(None, max_length=500)
|
||||
|
||||
|
||||
class AlertRuleUpdate(BaseModel):
|
||||
name: str | None = Field(None, max_length=100)
|
||||
rule_type: Literal["low_battery", "no_heartbeat", "fence_stay", "speed_limit", "offline_duration"] | None = None
|
||||
conditions: dict | None = None
|
||||
is_active: bool | None = None
|
||||
device_ids: str | None = None
|
||||
group_id: int | None = None
|
||||
description: str | None = None
|
||||
|
||||
|
||||
class AlertRuleResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
name: str
|
||||
rule_type: str
|
||||
conditions: dict
|
||||
is_active: bool
|
||||
device_ids: str | None = None
|
||||
group_id: int | None = None
|
||||
description: str | None = None
|
||||
created_at: datetime
|
||||
updated_at: datetime | None = None
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Audit Log schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class AuditLogResponse(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
|
||||
id: int
|
||||
method: str
|
||||
path: str
|
||||
status_code: int
|
||||
operator: str | None = None
|
||||
client_ip: str | None = None
|
||||
request_body: dict[str, Any] | None = None
|
||||
response_summary: str | None = None
|
||||
duration_ms: int | None = None
|
||||
created_at: datetime
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# System Config schemas
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
class SystemConfigResponse(BaseModel):
|
||||
"""Current runtime configuration (read-only and writable fields)."""
|
||||
data_retention_days: int
|
||||
data_cleanup_interval_hours: int
|
||||
tcp_idle_timeout: int
|
||||
fence_check_enabled: bool
|
||||
fence_lbs_tolerance_meters: int
|
||||
fence_wifi_tolerance_meters: int
|
||||
fence_min_inside_seconds: int
|
||||
rate_limit_default: str
|
||||
rate_limit_write: str
|
||||
track_max_points: int
|
||||
geocoding_cache_size: int
|
||||
|
||||
|
||||
class SystemConfigUpdate(BaseModel):
|
||||
"""Fields that can be updated at runtime."""
|
||||
data_retention_days: int | None = Field(None, ge=1, le=3650)
|
||||
data_cleanup_interval_hours: int | None = Field(None, ge=1, le=720)
|
||||
tcp_idle_timeout: int | None = Field(None, ge=0, le=86400)
|
||||
fence_check_enabled: bool | None = None
|
||||
fence_lbs_tolerance_meters: int | None = Field(None, ge=0, le=10000)
|
||||
fence_wifi_tolerance_meters: int | None = Field(None, ge=0, le=10000)
|
||||
fence_min_inside_seconds: int | None = Field(None, ge=0, le=3600)
|
||||
track_max_points: int | None = Field(None, ge=100, le=100000)
|
||||
|
||||
|
||||
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")
|
||||
|
||||
@@ -3,15 +3,19 @@ Beacon Service - 蓝牙信标管理服务
|
||||
Provides CRUD operations for Bluetooth beacon configuration.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
import asyncio
|
||||
import logging
|
||||
import re
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy import delete as sa_delete, func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import BeaconConfig
|
||||
from app.models import BeaconConfig, CommandLog, Device, DeviceBeaconBinding
|
||||
from app.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
||||
from app.services import tcp_command_service
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def get_beacons(
|
||||
@@ -80,7 +84,8 @@ async def update_beacon(
|
||||
update_data = data.model_dump(exclude_unset=True)
|
||||
for key, value in update_data.items():
|
||||
setattr(beacon, key, value)
|
||||
beacon.updated_at = datetime.now(BEIJING_TZ)
|
||||
from app.config import now_cst
|
||||
beacon.updated_at = now_cst()
|
||||
|
||||
await db.flush()
|
||||
await db.refresh(beacon)
|
||||
@@ -94,3 +99,455 @@ async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
||||
await db.delete(beacon)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Beacon Binding
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_beacon_devices(db: AsyncSession, beacon_id: int) -> list[dict]:
|
||||
"""Get devices bound to a beacon."""
|
||||
result = await db.execute(
|
||||
select(
|
||||
DeviceBeaconBinding.id.label("binding_id"),
|
||||
DeviceBeaconBinding.device_id,
|
||||
Device.name.label("device_name"),
|
||||
Device.imei,
|
||||
)
|
||||
.join(Device, Device.id == DeviceBeaconBinding.device_id)
|
||||
.where(DeviceBeaconBinding.beacon_id == beacon_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
return [row._asdict() for row in result.all()]
|
||||
|
||||
|
||||
async def bind_devices_to_beacon(
|
||||
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a beacon. Idempotent."""
|
||||
beacon = await get_beacon(db, beacon_id)
|
||||
if beacon is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Beacon not found"}
|
||||
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.device_id).where(
|
||||
DeviceBeaconBinding.beacon_id == beacon_id,
|
||||
DeviceBeaconBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids or did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceBeaconBinding(device_id=did, beacon_id=beacon_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_beacon(
|
||||
db: AsyncSession, beacon_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a beacon."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceBeaconBinding).where(
|
||||
DeviceBeaconBinding.beacon_id == beacon_id,
|
||||
DeviceBeaconBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
|
||||
|
||||
async def sync_device_beacons(db: AsyncSession, device_id: int) -> dict:
|
||||
"""Query all beacons bound to a device and send BTMACSET commands via TCP.
|
||||
|
||||
BTMACSET supports up to 10 MACs per slot, 5 slots total (default + 1-4).
|
||||
Returns {"sent": bool, "mac_count": int, "commands": [...], "error": str|None}.
|
||||
"""
|
||||
# Get device IMEI
|
||||
result = await db.execute(select(Device).where(Device.id == device_id))
|
||||
device = result.scalar_one_or_none()
|
||||
if device is None:
|
||||
return {"sent": False, "mac_count": 0, "commands": [], "error": "设备不存在"}
|
||||
|
||||
# Get all beacons bound to this device
|
||||
result = await db.execute(
|
||||
select(BeaconConfig.beacon_mac)
|
||||
.join(DeviceBeaconBinding, DeviceBeaconBinding.beacon_id == BeaconConfig.id)
|
||||
.where(DeviceBeaconBinding.device_id == device_id)
|
||||
.order_by(BeaconConfig.id)
|
||||
)
|
||||
macs = [row[0] for row in result.all()]
|
||||
|
||||
if not tcp_command_service.is_device_online(device.imei):
|
||||
return {"sent": False, "mac_count": len(macs), "commands": [], "error": "设备离线,无法发送指令"}
|
||||
|
||||
# Build BTMACSET commands: up to 10 MACs per slot
|
||||
# Slot names: BTMACSET (default), BTMACSET1, BTMACSET2, BTMACSET3, BTMACSET4
|
||||
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
||||
commands_sent = []
|
||||
|
||||
if not macs:
|
||||
# Clear default slot
|
||||
cmd = "BTMACSET,#"
|
||||
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
||||
commands_sent.append(cmd)
|
||||
else:
|
||||
for i in range(0, min(len(macs), 50), 10):
|
||||
slot_idx = i // 10
|
||||
chunk = macs[i:i + 10]
|
||||
cmd = f"{slot_names[slot_idx]},{','.join(chunk)}#"
|
||||
await tcp_command_service.send_command(device.imei, "online_cmd", cmd)
|
||||
commands_sent.append(cmd)
|
||||
|
||||
return {"sent": True, "mac_count": len(macs), "commands": commands_sent, "error": None}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Reverse sync: query devices → update DB bindings
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
_MAC_PATTERN = re.compile(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})")
|
||||
|
||||
|
||||
def _parse_btmacset_response(text: str) -> list[str]:
|
||||
"""Extract MAC addresses from BTMACSET query response.
|
||||
|
||||
Example responses:
|
||||
'setting OK.bt mac address:1,C3:00:00:34:43:5E;'
|
||||
'bt mac address:1,C3:00:00:34:43:5E,AA:BB:CC:DD:EE:FF;'
|
||||
"""
|
||||
return [m.upper() for m in _MAC_PATTERN.findall(text)]
|
||||
|
||||
|
||||
async def reverse_sync_from_devices(db: AsyncSession) -> dict:
|
||||
"""Send BTMACSET# query to all online devices, parse responses, update bindings.
|
||||
|
||||
Uses separate DB sessions for command creation and polling to avoid
|
||||
transaction isolation issues with the TCP handler's independent session.
|
||||
"""
|
||||
from app.database import async_session as make_session
|
||||
from app.services import command_service
|
||||
from app.config import now_cst
|
||||
|
||||
# Get all online devices
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "没有在线设备"}
|
||||
|
||||
# Build beacon MAC → id lookup
|
||||
result = await db.execute(select(BeaconConfig.id, BeaconConfig.beacon_mac))
|
||||
mac_to_beacon_id = {row[1].upper(): row[0] for row in result.all()}
|
||||
|
||||
# --- Phase 1: Create CommandLogs and send commands (committed session) ---
|
||||
sent_devices: list[tuple[int, str, str | None, int]] = [] # (dev_id, imei, name, cmd_log_id)
|
||||
|
||||
async with make_session() as cmd_session:
|
||||
async with cmd_session.begin():
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
continue
|
||||
cmd_log = await command_service.create_command(
|
||||
cmd_session, device_id=dev.id,
|
||||
command_type="online_cmd", command_content="BTMACSET#",
|
||||
)
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", "BTMACSET#")
|
||||
if ok:
|
||||
cmd_log.status = "sent"
|
||||
cmd_log.sent_at = now_cst()
|
||||
sent_devices.append((dev.id, dev.imei, dev.name, cmd_log.id))
|
||||
else:
|
||||
cmd_log.status = "failed"
|
||||
except Exception:
|
||||
cmd_log.status = "failed"
|
||||
# Transaction committed here — TCP handler can now see these CommandLogs
|
||||
|
||||
if not sent_devices:
|
||||
return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "无法发送指令到任何设备"}
|
||||
|
||||
# --- Phase 2: Poll for responses (fresh session each iteration) ---
|
||||
responded: dict[int, str] = {}
|
||||
for attempt in range(10):
|
||||
await asyncio.sleep(1)
|
||||
pending_ids = [cid for _, _, _, cid in sent_devices if _ not in responded]
|
||||
# Rebuild pending from device IDs not yet responded
|
||||
pending_cmd_ids = [cid for did, _, _, cid in sent_devices if did not in responded]
|
||||
if not pending_cmd_ids:
|
||||
break
|
||||
async with make_session() as poll_session:
|
||||
result = await poll_session.execute(
|
||||
select(CommandLog.device_id, CommandLog.response_content).where(
|
||||
CommandLog.id.in_(pending_cmd_ids),
|
||||
CommandLog.status == "success",
|
||||
)
|
||||
)
|
||||
for row in result.all():
|
||||
responded[row[0]] = row[1] or ""
|
||||
|
||||
# --- Phase 3: Parse responses and update bindings ---
|
||||
details = []
|
||||
updated_count = 0
|
||||
for dev_id, imei, name, cmd_id in sent_devices:
|
||||
resp_text = responded.get(dev_id)
|
||||
if resp_text is None:
|
||||
details.append({"device_id": dev_id, "imei": imei, "name": name, "status": "无响应"})
|
||||
continue
|
||||
|
||||
found_macs = _parse_btmacset_response(resp_text)
|
||||
matched_beacon_ids = set()
|
||||
for mac in found_macs:
|
||||
bid = mac_to_beacon_id.get(mac)
|
||||
if bid:
|
||||
matched_beacon_ids.add(bid)
|
||||
|
||||
# Get current bindings for this device
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.beacon_id).where(
|
||||
DeviceBeaconBinding.device_id == dev_id
|
||||
)
|
||||
)
|
||||
current_bindings = set(row[0] for row in result.all())
|
||||
|
||||
to_add = matched_beacon_ids - current_bindings
|
||||
for bid in to_add:
|
||||
db.add(DeviceBeaconBinding(device_id=dev_id, beacon_id=bid))
|
||||
|
||||
to_remove = current_bindings - matched_beacon_ids
|
||||
if to_remove:
|
||||
await db.execute(
|
||||
sa_delete(DeviceBeaconBinding).where(
|
||||
DeviceBeaconBinding.device_id == dev_id,
|
||||
DeviceBeaconBinding.beacon_id.in_(to_remove),
|
||||
)
|
||||
)
|
||||
|
||||
changes = len(to_add) + len(to_remove)
|
||||
updated_count += 1 if changes else 0
|
||||
details.append({
|
||||
"device_id": dev_id, "imei": imei, "name": name,
|
||||
"status": "已同步",
|
||||
"device_macs": found_macs,
|
||||
"matched_beacons": len(matched_beacon_ids),
|
||||
"added": len(to_add), "removed": len(to_remove),
|
||||
"response": resp_text,
|
||||
})
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"queried": len(sent_devices),
|
||||
"responded": len(responded),
|
||||
"updated": updated_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Setup Bluetooth clock-in mode for devices
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
# Full config sequence per P241 docs:
|
||||
# CLOCKWAY,3# → manual + Bluetooth clock
|
||||
# MODE,2# → Bluetooth positioning mode
|
||||
# BTMACSET,...# → write bound beacon MACs
|
||||
# BTMP3SW,1# → enable voice broadcast
|
||||
|
||||
_BT_SETUP_STEPS = [
|
||||
("CLOCKWAY,3#", "设置打卡方式: 手动+蓝牙"),
|
||||
# MODE,2# inserted dynamically
|
||||
# BTMACSET,...# inserted dynamically
|
||||
("BTMP3SW,1#", "开启语音播报"),
|
||||
]
|
||||
|
||||
|
||||
async def setup_bluetooth_mode(
|
||||
db: AsyncSession,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Configure devices for Bluetooth beacon clock-in mode.
|
||||
|
||||
Sends the full command sequence to each device:
|
||||
1. CLOCKWAY,3# (manual + BT clock)
|
||||
2. MODE,2# (BT positioning)
|
||||
3. BTMACSET,... (bound beacon MACs)
|
||||
4. BTMP3SW,1# (voice broadcast on)
|
||||
|
||||
If device_ids is None, targets all online devices.
|
||||
"""
|
||||
if device_ids:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.id.in_(device_ids))
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
||||
|
||||
# Pre-load all beacon bindings: device_id → [mac1, mac2, ...]
|
||||
all_device_ids = [d.id for d in devices]
|
||||
result = await db.execute(
|
||||
select(DeviceBeaconBinding.device_id, BeaconConfig.beacon_mac)
|
||||
.join(BeaconConfig, BeaconConfig.id == DeviceBeaconBinding.beacon_id)
|
||||
.where(DeviceBeaconBinding.device_id.in_(all_device_ids))
|
||||
.order_by(DeviceBeaconBinding.device_id, BeaconConfig.id)
|
||||
)
|
||||
device_macs: dict[int, list[str]] = {}
|
||||
for row in result.all():
|
||||
device_macs.setdefault(row[0], []).append(row[1])
|
||||
|
||||
details = []
|
||||
sent_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "离线", "commands": [],
|
||||
})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
macs = device_macs.get(dev.id, [])
|
||||
# Build command sequence
|
||||
commands = [
|
||||
"CLOCKWAY,3#",
|
||||
"MODE,2#",
|
||||
]
|
||||
# BTMACSET: split into slots of 10
|
||||
slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"]
|
||||
if macs:
|
||||
for i in range(0, min(len(macs), 50), 10):
|
||||
slot_idx = i // 10
|
||||
chunk = macs[i:i + 10]
|
||||
commands.append(f"{slot_names[slot_idx]},{','.join(chunk)}#")
|
||||
commands.append("BTMP3SW,1#")
|
||||
|
||||
# Send commands sequentially with small delay
|
||||
sent_cmds = []
|
||||
has_error = False
|
||||
for cmd in commands:
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
||||
sent_cmds.append({"cmd": cmd, "ok": ok})
|
||||
if not ok:
|
||||
has_error = True
|
||||
# Small delay between commands to avoid overwhelming device
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
fail_count += 1
|
||||
else:
|
||||
sent_count += 1
|
||||
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "部分失败" if has_error else "已配置",
|
||||
"beacon_count": len(macs),
|
||||
"commands": sent_cmds,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": len(devices),
|
||||
"sent": sent_count,
|
||||
"failed": fail_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
|
||||
async def restore_normal_mode(
|
||||
db: AsyncSession,
|
||||
device_ids: list[int] | None = None,
|
||||
) -> dict:
|
||||
"""Restore devices from Bluetooth clock-in mode to normal (smart) mode.
|
||||
|
||||
Sends:
|
||||
1. CLOCKWAY,1# (manual clock only)
|
||||
2. MODE,3# (smart positioning)
|
||||
3. BTMP3SW,0# (voice broadcast off)
|
||||
"""
|
||||
if device_ids:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.id.in_(device_ids))
|
||||
)
|
||||
else:
|
||||
result = await db.execute(
|
||||
select(Device).where(Device.status == "online")
|
||||
)
|
||||
devices = list(result.scalars().all())
|
||||
|
||||
if not devices:
|
||||
return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"}
|
||||
|
||||
commands = ["CLOCKWAY,1#", "MODE,3#", "BTMP3SW,0#"]
|
||||
details = []
|
||||
sent_count = 0
|
||||
fail_count = 0
|
||||
|
||||
for dev in devices:
|
||||
if not tcp_command_service.is_device_online(dev.imei):
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "离线", "commands": [],
|
||||
})
|
||||
fail_count += 1
|
||||
continue
|
||||
|
||||
sent_cmds = []
|
||||
has_error = False
|
||||
for cmd in commands:
|
||||
try:
|
||||
ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd)
|
||||
sent_cmds.append({"cmd": cmd, "ok": ok})
|
||||
if not ok:
|
||||
has_error = True
|
||||
await asyncio.sleep(0.3)
|
||||
except Exception as e:
|
||||
sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)})
|
||||
has_error = True
|
||||
|
||||
if has_error:
|
||||
fail_count += 1
|
||||
else:
|
||||
sent_count += 1
|
||||
|
||||
details.append({
|
||||
"device_id": dev.id, "imei": dev.imei, "name": dev.name,
|
||||
"status": "部分失败" if has_error else "已恢复",
|
||||
"commands": sent_cmds,
|
||||
})
|
||||
|
||||
return {
|
||||
"total": len(devices),
|
||||
"sent": sent_count,
|
||||
"failed": fail_count,
|
||||
"details": details,
|
||||
"error": None,
|
||||
}
|
||||
|
||||
@@ -4,8 +4,7 @@ Provides CRUD operations and statistics for badge devices.
|
||||
"""
|
||||
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import func, select, or_
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
@@ -160,7 +159,7 @@ async def update_device(
|
||||
for field, value in update_fields.items():
|
||||
setattr(device, field, value)
|
||||
|
||||
device.updated_at = datetime.now(BEIJING_TZ)
|
||||
device.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(device)
|
||||
return device
|
||||
@@ -247,7 +246,7 @@ async def batch_update_devices(
|
||||
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(BEIJING_TZ)
|
||||
now = now_cst()
|
||||
|
||||
results = []
|
||||
for device_id in device_ids:
|
||||
@@ -290,12 +289,10 @@ async def batch_delete_devices(
|
||||
|
||||
async def get_device_stats(db: AsyncSession) -> dict:
|
||||
"""
|
||||
获取设备统计信息 / Get device statistics.
|
||||
获取设备统计信息(增强版)/ Get enhanced device statistics.
|
||||
|
||||
Returns
|
||||
-------
|
||||
dict
|
||||
{"total": int, "online": int, "offline": int}
|
||||
Returns dict with total/online/offline counts, online_rate,
|
||||
by_type breakdown, battery/signal distribution.
|
||||
"""
|
||||
total_result = await db.execute(select(func.count(Device.id)))
|
||||
total = total_result.scalar() or 0
|
||||
@@ -304,11 +301,57 @@ async def get_device_stats(db: AsyncSession) -> dict:
|
||||
select(func.count(Device.id)).where(Device.status == "online")
|
||||
)
|
||||
online = online_result.scalar() or 0
|
||||
|
||||
offline = total - online
|
||||
|
||||
# By device_type
|
||||
type_result = await db.execute(
|
||||
select(Device.device_type, func.count(Device.id))
|
||||
.group_by(Device.device_type)
|
||||
.order_by(func.count(Device.id).desc())
|
||||
)
|
||||
by_type = {row[0] or "unknown": row[1] for row in type_result.all()}
|
||||
|
||||
# Battery distribution: 0-20, 20-50, 50-100, unknown
|
||||
from sqlalchemy import case
|
||||
battery_result = await db.execute(
|
||||
select(
|
||||
func.sum(case((Device.battery_level < 20, 1), else_=0)).label("low"),
|
||||
func.sum(case(((Device.battery_level >= 20) & (Device.battery_level < 50), 1), else_=0)).label("medium"),
|
||||
func.sum(case((Device.battery_level >= 50, 1), else_=0)).label("high"),
|
||||
func.sum(case((Device.battery_level.is_(None), 1), else_=0)).label("unknown"),
|
||||
)
|
||||
)
|
||||
brow = battery_result.one()
|
||||
battery_distribution = {
|
||||
"low_0_20": int(brow.low or 0),
|
||||
"medium_20_50": int(brow.medium or 0),
|
||||
"high_50_100": int(brow.high or 0),
|
||||
"unknown": int(brow.unknown or 0),
|
||||
}
|
||||
|
||||
# Signal distribution: 0=none, 1-10=weak, 11-20=medium, 21-31=strong
|
||||
signal_result = await db.execute(
|
||||
select(
|
||||
func.sum(case(((Device.gsm_signal.is_not(None)) & (Device.gsm_signal <= 10), 1), else_=0)).label("weak"),
|
||||
func.sum(case(((Device.gsm_signal > 10) & (Device.gsm_signal <= 20), 1), else_=0)).label("medium"),
|
||||
func.sum(case((Device.gsm_signal > 20, 1), else_=0)).label("strong"),
|
||||
func.sum(case((Device.gsm_signal.is_(None), 1), else_=0)).label("unknown"),
|
||||
)
|
||||
)
|
||||
srow = signal_result.one()
|
||||
signal_distribution = {
|
||||
"weak_0_10": int(srow.weak or 0),
|
||||
"medium_11_20": int(srow.medium or 0),
|
||||
"strong_21_31": int(srow.strong or 0),
|
||||
"unknown": int(srow.unknown or 0),
|
||||
}
|
||||
|
||||
return {
|
||||
"total": total,
|
||||
"online": online,
|
||||
"offline": offline,
|
||||
"online_rate": round(online / total * 100, 1) if total else 0,
|
||||
"by_type": by_type,
|
||||
"battery_distribution": battery_distribution,
|
||||
"signal_distribution": signal_distribution,
|
||||
}
|
||||
|
||||
59
app/services/export_utils.py
Normal file
59
app/services/export_utils.py
Normal file
@@ -0,0 +1,59 @@
|
||||
"""
|
||||
CSV Export Utilities - CSV 数据导出工具
|
||||
Shared helpers for streaming CSV responses.
|
||||
"""
|
||||
|
||||
import csv
|
||||
import io
|
||||
from collections.abc import AsyncIterator, Sequence
|
||||
from datetime import datetime
|
||||
from typing import Any
|
||||
|
||||
|
||||
def _format_value(value: Any) -> str:
|
||||
"""Format a single value for CSV output."""
|
||||
if value is None:
|
||||
return ""
|
||||
if isinstance(value, datetime):
|
||||
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||
if isinstance(value, bool):
|
||||
return "是" if value else "否"
|
||||
if isinstance(value, float):
|
||||
return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}"
|
||||
if isinstance(value, (dict, list)):
|
||||
import json
|
||||
return json.dumps(value, ensure_ascii=False)
|
||||
return str(value)
|
||||
|
||||
|
||||
def build_csv_content(
|
||||
headers: list[str],
|
||||
rows: Sequence[Any],
|
||||
field_extractors: list[Any],
|
||||
) -> str:
|
||||
"""Build complete CSV string with BOM for Excel compatibility.
|
||||
|
||||
Args:
|
||||
headers: Column header names (Chinese).
|
||||
rows: ORM model instances or row tuples.
|
||||
field_extractors: List of callables or attribute name strings.
|
||||
"""
|
||||
buf = io.StringIO()
|
||||
buf.write("\ufeff") # UTF-8 BOM for Excel
|
||||
writer = csv.writer(buf)
|
||||
writer.writerow(headers)
|
||||
for row in rows:
|
||||
values = []
|
||||
for ext in field_extractors:
|
||||
if callable(ext):
|
||||
values.append(_format_value(ext(row)))
|
||||
else:
|
||||
values.append(_format_value(getattr(row, ext, "")))
|
||||
writer.writerow(values)
|
||||
return buf.getvalue()
|
||||
|
||||
|
||||
def csv_filename(prefix: str) -> str:
|
||||
"""Generate a timestamped CSV filename."""
|
||||
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||
return f"{prefix}_{ts}.csv"
|
||||
403
app/services/fence_checker.py
Normal file
403
app/services/fence_checker.py
Normal file
@@ -0,0 +1,403 @@
|
||||
"""Fence checker service - geofence judgment engine with auto-attendance.
|
||||
|
||||
Checks whether a device's reported coordinates fall inside its bound fences.
|
||||
Creates automatic attendance records (clock_in/clock_out) on state transitions.
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Optional
|
||||
|
||||
from sqlalchemy import func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.config import now_cst, settings
|
||||
from app.models import (
|
||||
AttendanceRecord,
|
||||
Device,
|
||||
DeviceFenceBinding,
|
||||
DeviceFenceState,
|
||||
FenceConfig,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
_EARTH_RADIUS_M = 6_371_000.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Geometry helpers (WGS-84)
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float:
|
||||
"""Return distance in meters between two WGS-84 points."""
|
||||
rlat1, rlat2 = math.radians(lat1), math.radians(lat2)
|
||||
dlat = math.radians(lat2 - lat1)
|
||||
dlon = math.radians(lon2 - lon1)
|
||||
a = (
|
||||
math.sin(dlat / 2) ** 2
|
||||
+ math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2
|
||||
)
|
||||
return _EARTH_RADIUS_M * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a))
|
||||
|
||||
|
||||
def is_inside_circle(
|
||||
lat: float, lon: float,
|
||||
center_lat: float, center_lng: float,
|
||||
radius_m: float,
|
||||
) -> bool:
|
||||
"""Check if point is inside a circle (haversine)."""
|
||||
return haversine_distance(lat, lon, center_lat, center_lng) <= radius_m
|
||||
|
||||
|
||||
def is_inside_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> bool:
|
||||
"""Ray-casting algorithm. vertices = [[lng, lat], ...] in WGS-84."""
|
||||
n = len(vertices)
|
||||
if n < 3:
|
||||
return False
|
||||
inside = False
|
||||
j = n - 1
|
||||
for i in range(n):
|
||||
xi, yi = vertices[i][0], vertices[i][1] # lng, lat
|
||||
xj, yj = vertices[j][0], vertices[j][1]
|
||||
if ((yi > lat) != (yj > lat)) and (
|
||||
lon < (xj - xi) * (lat - yi) / (yj - yi) + xi
|
||||
):
|
||||
inside = not inside
|
||||
j = i
|
||||
return inside
|
||||
|
||||
|
||||
def is_inside_fence(
|
||||
lat: float, lon: float,
|
||||
fence: FenceConfig,
|
||||
tolerance_m: float = 0,
|
||||
) -> bool:
|
||||
"""Check if a point is inside a fence, with optional tolerance buffer."""
|
||||
if fence.fence_type == "circle":
|
||||
if fence.center_lat is None or fence.center_lng is None or fence.radius is None:
|
||||
return False
|
||||
return is_inside_circle(
|
||||
lat, lon,
|
||||
fence.center_lat, fence.center_lng,
|
||||
fence.radius + tolerance_m,
|
||||
)
|
||||
|
||||
# polygon / rectangle: parse points JSON
|
||||
if not fence.points:
|
||||
return False
|
||||
try:
|
||||
vertices = json.loads(fence.points)
|
||||
except (json.JSONDecodeError, TypeError):
|
||||
logger.warning("Fence %d has invalid points JSON", fence.id)
|
||||
return False
|
||||
|
||||
if not isinstance(vertices, list) or len(vertices) < 3:
|
||||
return False
|
||||
|
||||
# For polygon with tolerance, check point-in-polygon first
|
||||
if is_inside_polygon(lat, lon, vertices):
|
||||
return True
|
||||
|
||||
# If not inside but tolerance > 0, check distance to nearest edge
|
||||
if tolerance_m > 0:
|
||||
return _min_distance_to_polygon(lat, lon, vertices) <= tolerance_m
|
||||
|
||||
return False
|
||||
|
||||
|
||||
def _min_distance_to_polygon(
|
||||
lat: float, lon: float,
|
||||
vertices: list[list[float]],
|
||||
) -> float:
|
||||
"""Approximate minimum distance from point to polygon edges (meters)."""
|
||||
min_dist = float("inf")
|
||||
n = len(vertices)
|
||||
for i in range(n):
|
||||
j = (i + 1) % n
|
||||
# Each vertex is [lng, lat]
|
||||
dist = _point_to_segment_distance(
|
||||
lat, lon,
|
||||
vertices[i][1], vertices[i][0],
|
||||
vertices[j][1], vertices[j][0],
|
||||
)
|
||||
if dist < min_dist:
|
||||
min_dist = dist
|
||||
return min_dist
|
||||
|
||||
|
||||
def _point_to_segment_distance(
|
||||
plat: float, plon: float,
|
||||
alat: float, alon: float,
|
||||
blat: float, blon: float,
|
||||
) -> float:
|
||||
"""Approximate distance from point P to line segment AB (meters)."""
|
||||
# Project P onto AB using flat-earth approximation (good for short segments)
|
||||
dx = blon - alon
|
||||
dy = blat - alat
|
||||
if dx == 0 and dy == 0:
|
||||
return haversine_distance(plat, plon, alat, alon)
|
||||
|
||||
t = max(0, min(1, ((plon - alon) * dx + (plat - alat) * dy) / (dx * dx + dy * dy)))
|
||||
proj_lat = alat + t * dy
|
||||
proj_lon = alon + t * dx
|
||||
return haversine_distance(plat, plon, proj_lat, proj_lon)
|
||||
|
||||
|
||||
def _get_tolerance_for_location_type(location_type: str) -> float:
|
||||
"""Return tolerance in meters based on location type accuracy."""
|
||||
if location_type in ("lbs", "lbs_4g"):
|
||||
return float(settings.FENCE_LBS_TOLERANCE_METERS)
|
||||
if location_type in ("wifi", "wifi_4g"):
|
||||
return float(settings.FENCE_WIFI_TOLERANCE_METERS)
|
||||
# GPS: no extra tolerance
|
||||
return 0.0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Daily dedup helper
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def _has_attendance_today(
|
||||
session: AsyncSession,
|
||||
device_id: int,
|
||||
attendance_type: str,
|
||||
) -> bool:
|
||||
"""Check if device already has an attendance record of given type today (CST)."""
|
||||
cst_now = datetime.now(timezone(timedelta(hours=8)))
|
||||
day_start = cst_now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None)
|
||||
day_end = day_start + timedelta(days=1)
|
||||
result = await session.execute(
|
||||
select(func.count()).select_from(AttendanceRecord).where(
|
||||
AttendanceRecord.device_id == device_id,
|
||||
AttendanceRecord.attendance_type == attendance_type,
|
||||
AttendanceRecord.recorded_at >= day_start,
|
||||
AttendanceRecord.recorded_at < day_end,
|
||||
)
|
||||
)
|
||||
return (result.scalar() or 0) > 0
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Main fence check entry point
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def check_device_fences(
|
||||
session: AsyncSession,
|
||||
device_id: int,
|
||||
imei: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
location_type: str,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
*,
|
||||
mcc: Optional[int] = None,
|
||||
mnc: Optional[int] = None,
|
||||
lac: Optional[int] = None,
|
||||
cell_id: Optional[int] = None,
|
||||
) -> list[dict]:
|
||||
"""Check all bound active fences for a device. Returns attendance events.
|
||||
|
||||
Called after each location report is stored. Creates automatic
|
||||
AttendanceRecords for fence entry/exit transitions.
|
||||
"""
|
||||
# 1. Query active fences bound to this device
|
||||
result = await session.execute(
|
||||
select(FenceConfig)
|
||||
.join(DeviceFenceBinding, DeviceFenceBinding.fence_id == FenceConfig.id)
|
||||
.where(
|
||||
DeviceFenceBinding.device_id == device_id,
|
||||
FenceConfig.is_active == 1,
|
||||
)
|
||||
)
|
||||
fences = list(result.scalars().all())
|
||||
if not fences:
|
||||
return []
|
||||
|
||||
# Query device for latest battery/signal info (from heartbeats)
|
||||
device = await session.get(Device, device_id)
|
||||
device_info = {
|
||||
"battery_level": device.battery_level if device else None,
|
||||
"gsm_signal": device.gsm_signal if device else None,
|
||||
"mcc": mcc,
|
||||
"mnc": mnc,
|
||||
"lac": lac,
|
||||
"cell_id": cell_id,
|
||||
}
|
||||
|
||||
# 2. Batch-load all fence states in one query (avoid N+1)
|
||||
fence_ids = [f.id for f in fences]
|
||||
states_result = await session.execute(
|
||||
select(DeviceFenceState).where(
|
||||
DeviceFenceState.device_id == device_id,
|
||||
DeviceFenceState.fence_id.in_(fence_ids),
|
||||
)
|
||||
)
|
||||
states_map: dict[int, DeviceFenceState] = {s.fence_id: s for s in states_result.scalars().all()}
|
||||
|
||||
# Pre-check today's attendance dedup once (not per-fence)
|
||||
_today_clock_in = await _has_attendance_today(session, device_id, "clock_in")
|
||||
|
||||
tolerance = _get_tolerance_for_location_type(location_type)
|
||||
events: list[dict] = []
|
||||
now = now_cst()
|
||||
min_interval = settings.FENCE_MIN_INSIDE_SECONDS
|
||||
|
||||
for fence in fences:
|
||||
currently_inside = is_inside_fence(latitude, longitude, fence, tolerance)
|
||||
|
||||
state = states_map.get(fence.id)
|
||||
|
||||
was_inside = bool(state and state.is_inside)
|
||||
|
||||
# 3. Detect transition
|
||||
if currently_inside and not was_inside:
|
||||
# ENTRY: outside -> inside = clock_in
|
||||
if state and state.last_transition_at:
|
||||
elapsed = (now - state.last_transition_at).total_seconds()
|
||||
if elapsed < min_interval:
|
||||
logger.debug(
|
||||
"Fence %d debounce: %ds < %ds, skip clock_in for device %d",
|
||||
fence.id, int(elapsed), min_interval, device_id,
|
||||
)
|
||||
_update_state(state, currently_inside, now, latitude, longitude)
|
||||
continue
|
||||
|
||||
# Daily dedup: only one clock_in per device per day (pre-fetched)
|
||||
if _today_clock_in:
|
||||
logger.info(
|
||||
"Fence skip clock_in: device=%d fence=%d(%s) already clocked in today",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
else:
|
||||
attendance = _create_attendance(
|
||||
device_id, imei, "clock_in", latitude, longitude,
|
||||
address, recorded_at, fence, device_info,
|
||||
)
|
||||
session.add(attendance)
|
||||
|
||||
event = _build_event(
|
||||
device_id, imei, fence, "clock_in",
|
||||
latitude, longitude, address, recorded_at,
|
||||
)
|
||||
events.append(event)
|
||||
logger.info(
|
||||
"Fence auto clock_in: device=%d fence=%d(%s)",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
elif not currently_inside and was_inside:
|
||||
# EXIT: inside -> outside — skip clock_out (GPS drift causes false exits)
|
||||
logger.debug(
|
||||
"Fence exit ignored: device=%d fence=%d(%s), no clock_out created",
|
||||
device_id, fence.id, fence.name,
|
||||
)
|
||||
|
||||
# 4. Update state
|
||||
if state is None:
|
||||
state = DeviceFenceState(
|
||||
device_id=device_id,
|
||||
fence_id=fence.id,
|
||||
is_inside=currently_inside,
|
||||
last_transition_at=now if (currently_inside != was_inside) else None,
|
||||
last_check_at=now,
|
||||
last_latitude=latitude,
|
||||
last_longitude=longitude,
|
||||
)
|
||||
session.add(state)
|
||||
else:
|
||||
if currently_inside != was_inside:
|
||||
state.last_transition_at = now
|
||||
state.is_inside = currently_inside
|
||||
state.last_check_at = now
|
||||
state.last_latitude = latitude
|
||||
state.last_longitude = longitude
|
||||
|
||||
await session.flush()
|
||||
return events
|
||||
|
||||
|
||||
def _update_state(
|
||||
state: DeviceFenceState,
|
||||
is_inside: bool,
|
||||
now: datetime,
|
||||
lat: float,
|
||||
lon: float,
|
||||
) -> None:
|
||||
"""Update state fields without creating a transition."""
|
||||
state.last_check_at = now
|
||||
state.last_latitude = lat
|
||||
state.last_longitude = lon
|
||||
# Don't update is_inside or last_transition_at during debounce
|
||||
|
||||
|
||||
def _create_attendance(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
fence: FenceConfig,
|
||||
device_info: Optional[dict] = None,
|
||||
) -> AttendanceRecord:
|
||||
"""Create an auto-generated fence attendance record."""
|
||||
info = device_info or {}
|
||||
return AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=imei,
|
||||
attendance_type=attendance_type,
|
||||
attendance_source="fence",
|
||||
protocol_number=0, # synthetic, not from device protocol
|
||||
gps_positioned=True,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
address=address,
|
||||
recorded_at=recorded_at,
|
||||
battery_level=info.get("battery_level"),
|
||||
gsm_signal=info.get("gsm_signal"),
|
||||
mcc=info.get("mcc"),
|
||||
mnc=info.get("mnc"),
|
||||
lac=info.get("lac"),
|
||||
cell_id=info.get("cell_id"),
|
||||
lbs_data={
|
||||
"source": "fence_auto",
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _build_event(
|
||||
device_id: int,
|
||||
imei: str,
|
||||
fence: FenceConfig,
|
||||
attendance_type: str,
|
||||
latitude: float,
|
||||
longitude: float,
|
||||
address: Optional[str],
|
||||
recorded_at: datetime,
|
||||
) -> dict:
|
||||
"""Build a WebSocket broadcast event dict."""
|
||||
return {
|
||||
"device_id": device_id,
|
||||
"imei": imei,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"attendance_type": attendance_type,
|
||||
"latitude": latitude,
|
||||
"longitude": longitude,
|
||||
"address": address,
|
||||
"recorded_at": recorded_at.isoformat() if recorded_at else None,
|
||||
"source": "fence_auto",
|
||||
}
|
||||
208
app/services/fence_service.py
Normal file
208
app/services/fence_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Fence Service - CRUD operations for geofence configuration and device bindings."""
|
||||
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import delete as sa_delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device, DeviceFenceBinding, DeviceFenceState, FenceConfig
|
||||
from app.schemas import FenceConfigCreate, FenceConfigUpdate
|
||||
|
||||
|
||||
async def get_fences(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[FenceConfig], int]:
|
||||
query = select(FenceConfig)
|
||||
count_query = select(func.count(FenceConfig.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(FenceConfig.is_active == is_active)
|
||||
count_query = count_query.where(FenceConfig.is_active == is_active)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = FenceConfig.name.ilike(like) | FenceConfig.description.ilike(like)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
query.order_by(FenceConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_all_active_fences(db: AsyncSession) -> list[FenceConfig]:
|
||||
result = await db.execute(
|
||||
select(FenceConfig).where(FenceConfig.is_active == 1).order_by(FenceConfig.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_fence(db: AsyncSession, fence_id: int) -> FenceConfig | None:
|
||||
result = await db.execute(select(FenceConfig).where(FenceConfig.id == fence_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_fence(db: AsyncSession, data: FenceConfigCreate) -> FenceConfig:
|
||||
fence = FenceConfig(**data.model_dump())
|
||||
db.add(fence)
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def update_fence(db: AsyncSession, fence_id: int, data: FenceConfigUpdate) -> FenceConfig | None:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return None
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(fence, key, value)
|
||||
fence.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def delete_fence(db: AsyncSession, fence_id: int) -> bool:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return False
|
||||
# CASCADE FK handles bindings/states, but explicit delete for safety
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(DeviceFenceState.fence_id == fence_id)
|
||||
)
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(DeviceFenceBinding.fence_id == fence_id)
|
||||
)
|
||||
await db.delete(fence)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_fence_devices(
|
||||
db: AsyncSession, fence_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get devices bound to a fence, with their current fence state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, Device, DeviceFenceState)
|
||||
.join(Device, Device.id == DeviceFenceBinding.device_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.fence_id == fence_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
items = []
|
||||
for binding, device, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"imei": device.imei,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def get_device_fences(
|
||||
db: AsyncSession, device_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get fences bound to a device, with current state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, FenceConfig, DeviceFenceState)
|
||||
.join(FenceConfig, FenceConfig.id == DeviceFenceBinding.fence_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.device_id == device_id)
|
||||
.order_by(FenceConfig.name)
|
||||
)
|
||||
items = []
|
||||
for binding, fence, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"fence_type": fence.fence_type,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def bind_devices_to_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a fence. Idempotent (skips existing bindings)."""
|
||||
# Verify fence exists
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Fence not found"}
|
||||
|
||||
# Verify devices exist
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
# Check existing bindings
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding.device_id).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids:
|
||||
continue
|
||||
if did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceFenceBinding(device_id=did, fence_id=fence_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a fence. Also cleans up state records."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
# Clean up state records
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(
|
||||
DeviceFenceState.fence_id == fence_id,
|
||||
DeviceFenceState.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
@@ -15,6 +15,7 @@ async def get_locations(
|
||||
db: AsyncSession,
|
||||
device_id: int | None = None,
|
||||
location_type: str | None = None,
|
||||
exclude_type: str | None = None,
|
||||
start_time: datetime | None = None,
|
||||
end_time: datetime | None = None,
|
||||
page: int = 1,
|
||||
@@ -56,6 +57,14 @@ async def get_locations(
|
||||
query = query.where(LocationRecord.location_type == location_type)
|
||||
count_query = count_query.where(LocationRecord.location_type == location_type)
|
||||
|
||||
if exclude_type:
|
||||
# Map prefix to actual values for index-friendly IN query
|
||||
_EXCLUDE_MAP = {"lbs": ["lbs", "lbs_4g"], "wifi": ["wifi", "wifi_4g"], "gps": ["gps", "gps_4g"]}
|
||||
exclude_values = _EXCLUDE_MAP.get(exclude_type, [exclude_type])
|
||||
clause = LocationRecord.location_type.notin_(exclude_values)
|
||||
query = query.where(clause)
|
||||
count_query = count_query.where(clause)
|
||||
|
||||
if start_time:
|
||||
query = query.where(LocationRecord.recorded_at >= start_time)
|
||||
count_query = count_query.where(LocationRecord.recorded_at >= start_time)
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -13,9 +13,7 @@ from __future__ import annotations
|
||||
import asyncio
|
||||
import logging
|
||||
import struct
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from datetime import datetime, timedelta, timezone
|
||||
from typing import Any, Dict, Optional, Tuple
|
||||
|
||||
from sqlalchemy import select, update
|
||||
@@ -237,12 +235,13 @@ class PacketBuilder:
|
||||
class ConnectionInfo:
|
||||
"""Metadata about a single device TCP connection."""
|
||||
|
||||
__slots__ = ("imei", "addr", "connected_at", "last_activity", "serial_counter")
|
||||
__slots__ = ("imei", "device_id", "addr", "connected_at", "last_activity", "serial_counter")
|
||||
|
||||
def __init__(self, addr: Tuple[str, int]) -> None:
|
||||
self.imei: Optional[str] = None
|
||||
self.device_id: Optional[int] = None
|
||||
self.addr = addr
|
||||
self.connected_at = datetime.now(BEIJING_TZ)
|
||||
self.connected_at = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
self.last_activity = self.connected_at
|
||||
self.serial_counter: int = 1
|
||||
|
||||
@@ -256,12 +255,16 @@ class ConnectionInfo:
|
||||
# Helper: look up device_id from IMEI
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
async def _get_device_id(session, imei: str) -> Optional[int]:
|
||||
"""Query the Device table and return the integer id for the given IMEI."""
|
||||
async def _get_device_id(session, imei: str, conn_info: Optional["ConnectionInfo"] = None) -> Optional[int]:
|
||||
"""Return the device id for the given IMEI, using ConnectionInfo cache if available."""
|
||||
if conn_info is not None and conn_info.device_id is not None:
|
||||
return conn_info.device_id
|
||||
result = await session.execute(
|
||||
select(Device.id).where(Device.imei == imei)
|
||||
)
|
||||
row = result.scalar_one_or_none()
|
||||
if row is not None and conn_info is not None:
|
||||
conn_info.device_id = row
|
||||
return row
|
||||
|
||||
|
||||
@@ -275,6 +278,7 @@ class TCPManager:
|
||||
def __init__(self) -> None:
|
||||
# {imei: (reader, writer, connection_info)}
|
||||
self.connections: Dict[str, Tuple[asyncio.StreamReader, asyncio.StreamWriter, ConnectionInfo]] = {}
|
||||
self._conn_lock = asyncio.Lock()
|
||||
self._server: Optional[asyncio.AbstractServer] = None
|
||||
|
||||
# Protocol number -> handler coroutine mapping
|
||||
@@ -318,11 +322,22 @@ class TCPManager:
|
||||
conn_info = ConnectionInfo(addr)
|
||||
logger.info("New TCP connection from %s:%d", addr[0], addr[1])
|
||||
|
||||
recv_buffer = b""
|
||||
recv_buffer = bytearray()
|
||||
|
||||
try:
|
||||
idle_timeout = settings.TCP_IDLE_TIMEOUT or None
|
||||
while True:
|
||||
try:
|
||||
if idle_timeout:
|
||||
data = await asyncio.wait_for(reader.read(4096), timeout=idle_timeout)
|
||||
else:
|
||||
data = await reader.read(4096)
|
||||
except asyncio.TimeoutError:
|
||||
logger.info(
|
||||
"Idle timeout (%ds) for %s:%d (IMEI=%s), closing",
|
||||
idle_timeout, addr[0], addr[1], conn_info.imei,
|
||||
)
|
||||
break
|
||||
if not data:
|
||||
logger.info(
|
||||
"Connection closed by remote %s:%d (IMEI=%s)",
|
||||
@@ -333,7 +348,7 @@ class TCPManager:
|
||||
break
|
||||
|
||||
recv_buffer += data
|
||||
conn_info.last_activity = datetime.now(BEIJING_TZ)
|
||||
conn_info.last_activity = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
logger.info("Received %d bytes from %s:%d (IMEI=%s): %s",
|
||||
len(data), addr[0], addr[1], conn_info.imei, data[:50].hex())
|
||||
|
||||
@@ -373,7 +388,7 @@ class TCPManager:
|
||||
"Receive buffer overflow for IMEI=%s, discarding",
|
||||
conn_info.imei,
|
||||
)
|
||||
recv_buffer = b""
|
||||
recv_buffer = bytearray()
|
||||
|
||||
except asyncio.CancelledError:
|
||||
logger.info(
|
||||
@@ -404,6 +419,7 @@ class TCPManager:
|
||||
) -> None:
|
||||
"""Remove connection from tracking and update the device status."""
|
||||
imei = conn_info.imei
|
||||
async with self._conn_lock:
|
||||
if imei and imei in self.connections:
|
||||
# Only remove if this is still the active connection (not replaced by reconnect)
|
||||
_, stored_writer, _ = self.connections[imei]
|
||||
@@ -557,12 +573,15 @@ class TCPManager:
|
||||
|
||||
@staticmethod
|
||||
def _parse_datetime(content: bytes, offset: int = 0) -> Optional[datetime]:
|
||||
"""Parse a 6-byte datetime field at *offset* and return a Beijing-time datetime."""
|
||||
"""Parse a 6-byte datetime field at *offset* (UTC) and return CST (UTC+8) naive datetime."""
|
||||
if len(content) < offset + 6:
|
||||
return None
|
||||
yy, mo, dd, hh, mi, ss = struct.unpack_from("BBBBBB", content, offset)
|
||||
try:
|
||||
return datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=BEIJING_TZ)
|
||||
utc_dt = datetime(2000 + yy, mo, dd, hh, mi, ss, tzinfo=timezone.utc)
|
||||
# Convert to CST (UTC+8) and strip tzinfo for SQLite
|
||||
cst_dt = utc_dt + timedelta(hours=8)
|
||||
return cst_dt.replace(tzinfo=None)
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
@@ -598,6 +617,7 @@ class TCPManager:
|
||||
conn_info.imei = imei
|
||||
|
||||
# Close existing connection if device reconnects
|
||||
async with self._conn_lock:
|
||||
old_conn = self.connections.get(imei)
|
||||
if old_conn is not None:
|
||||
_, old_writer, old_info = old_conn
|
||||
@@ -606,7 +626,6 @@ class TCPManager:
|
||||
old_writer.close()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
self.connections[imei] = (reader, writer, conn_info)
|
||||
logger.info(
|
||||
"Device login: IMEI=%s from %s:%d", imei, conn_info.addr[0], conn_info.addr[1]
|
||||
@@ -636,7 +655,7 @@ class TCPManager:
|
||||
lang_str = "zh" if lang_code == 1 else "en" if lang_code == 2 else str(lang_code)
|
||||
|
||||
# Persist device record
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
@@ -733,12 +752,12 @@ class TCPManager:
|
||||
if ext_info:
|
||||
extension_data = ext_info
|
||||
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("Heartbeat for unknown IMEI=%s", imei)
|
||||
return
|
||||
@@ -758,6 +777,7 @@ class TCPManager:
|
||||
# Store heartbeat record
|
||||
record = HeartbeatRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
protocol_number=proto,
|
||||
terminal_info=terminal_info,
|
||||
battery_level=battery_level if battery_level is not None else 0,
|
||||
@@ -856,7 +876,7 @@ class TCPManager:
|
||||
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# Parse recorded_at from the 6-byte datetime at offset 0
|
||||
recorded_at = self._parse_datetime(content, 0) or now
|
||||
@@ -1006,6 +1026,13 @@ class TCPManager:
|
||||
cell_id = int.from_bytes(content[pos : pos + 3], "big")
|
||||
pos += 3
|
||||
|
||||
# --- Skip LBS/WiFi records with empty cell data (device hasn't acquired cells yet) ---
|
||||
if location_type in ("lbs", "lbs_4g", "wifi", "wifi_4g") and latitude is None:
|
||||
mcc_val = mcc & 0x7FFF if mcc else 0
|
||||
if mcc_val == 0 and (lac is None or lac == 0) and (cell_id is None or cell_id == 0):
|
||||
logger.debug("Skipping empty LBS/WiFi packet for IMEI=%s (no cell data)", imei)
|
||||
return
|
||||
|
||||
# --- Geocoding for LBS/WiFi locations (no GPS coordinates) ---
|
||||
neighbor_cells_data: Optional[list] = None
|
||||
wifi_data_list: Optional[list] = None
|
||||
@@ -1025,6 +1052,7 @@ class TCPManager:
|
||||
wifi_list=wifi_data_list,
|
||||
neighbor_cells=neighbor_cells_data,
|
||||
imei=imei,
|
||||
location_type=location_type,
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude = lat
|
||||
@@ -1036,24 +1064,32 @@ class TCPManager:
|
||||
except Exception:
|
||||
logger.exception("Geocoding failed for %s IMEI=%s", location_type, imei)
|
||||
|
||||
# --- Reverse geocoding: coordinates -> address ---
|
||||
address: Optional[str] = None
|
||||
# --- Reverse geocoding (run concurrently with DB store below) ---
|
||||
address_task = None
|
||||
if latitude is not None and longitude is not None:
|
||||
try:
|
||||
address = await reverse_geocode(latitude, longitude)
|
||||
except Exception:
|
||||
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
|
||||
address_task = asyncio.ensure_future(reverse_geocode(latitude, longitude))
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("Location for unknown IMEI=%s", imei)
|
||||
if address_task:
|
||||
address_task.cancel()
|
||||
return
|
||||
|
||||
# Await reverse geocode result if running
|
||||
address: Optional[str] = None
|
||||
if address_task:
|
||||
try:
|
||||
address = await address_task
|
||||
except Exception:
|
||||
logger.exception("Reverse geocoding failed for IMEI=%s", imei)
|
||||
|
||||
record = LocationRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
location_type=location_type,
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
@@ -1075,12 +1111,30 @@ class TCPManager:
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
session.add(record)
|
||||
# Broadcast to WebSocket subscribers
|
||||
|
||||
# --- Fence auto-attendance check (same session) ---
|
||||
fence_events = []
|
||||
if settings.FENCE_CHECK_ENABLED and latitude is not None and longitude is not None:
|
||||
try:
|
||||
from app.services.fence_checker import check_device_fences
|
||||
fence_events = await check_device_fences(
|
||||
session, device_id, imei,
|
||||
latitude, longitude, location_type,
|
||||
address, recorded_at,
|
||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Fence check failed for IMEI=%s", imei)
|
||||
|
||||
# Broadcast to WebSocket subscribers (after commit)
|
||||
ws_manager.broadcast_nonblocking("location", {
|
||||
"imei": imei, "device_id": device_id, "location_type": location_type,
|
||||
"latitude": latitude, "longitude": longitude, "speed": speed,
|
||||
"address": address, "recorded_at": str(recorded_at),
|
||||
})
|
||||
for evt in fence_events:
|
||||
ws_manager.broadcast_nonblocking("fence_attendance", evt)
|
||||
ws_manager.broadcast_nonblocking("attendance", evt)
|
||||
except Exception:
|
||||
logger.exception(
|
||||
"DB error storing %s location for IMEI=%s", location_type, imei
|
||||
@@ -1249,7 +1303,7 @@ class TCPManager:
|
||||
if len(content) >= 8:
|
||||
language = struct.unpack("!H", content[6:8])[0]
|
||||
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
if language == 0x0001:
|
||||
# Chinese: use GMT+8 timestamp
|
||||
ts = int(now.timestamp()) + 8 * 3600
|
||||
@@ -1271,7 +1325,7 @@ class TCPManager:
|
||||
conn_info: ConnectionInfo,
|
||||
) -> None:
|
||||
"""Handle time sync 2 request (0x8A). Respond with YY MM DD HH MM SS."""
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
payload = bytes(
|
||||
[
|
||||
now.year % 100,
|
||||
@@ -1363,7 +1417,7 @@ class TCPManager:
|
||||
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
recorded_at = self._parse_datetime(content, 0) or now
|
||||
|
||||
@@ -1504,10 +1558,12 @@ class TCPManager:
|
||||
if latitude is None and mcc is not None and lac is not None and cell_id is not None:
|
||||
try:
|
||||
wifi_list_for_geocode = wifi_data_list if proto == PROTO_ALARM_WIFI else None
|
||||
alarm_is_4g = proto in (PROTO_ALARM_SINGLE_FENCE, PROTO_ALARM_MULTI_FENCE, PROTO_ALARM_LBS_4G)
|
||||
lat, lon = await geocode_location(
|
||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||
wifi_list=wifi_list_for_geocode,
|
||||
imei=imei,
|
||||
location_type="lbs_4g" if alarm_is_4g else "lbs",
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude = lat
|
||||
@@ -1527,13 +1583,14 @@ class TCPManager:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("Alarm for unknown IMEI=%s", imei)
|
||||
return
|
||||
|
||||
record = AlarmRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
alarm_type=alarm_type_name,
|
||||
alarm_source=alarm_source,
|
||||
protocol_number=proto,
|
||||
@@ -1662,7 +1719,7 @@ class TCPManager:
|
||||
imei = conn_info.imei
|
||||
content = pkt["content"]
|
||||
proto = pkt["protocol"]
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# -- Parse fields --
|
||||
pos = 0
|
||||
@@ -1692,16 +1749,12 @@ class TCPManager:
|
||||
gps_positioned = gps["gps_positioned"]
|
||||
pos += 12
|
||||
|
||||
# Terminal info (1 byte) - contains clock_in/clock_out status bits
|
||||
# Terminal info (1 byte) - parse status bits (clock_out disabled due to GPS drift)
|
||||
attendance_type = "clock_in"
|
||||
terminal_info = 0
|
||||
if len(content) > pos:
|
||||
terminal_info = content[pos]
|
||||
status_code = (terminal_info >> 2) & 0x0F
|
||||
if status_code == 0b0010:
|
||||
attendance_type = "clock_out"
|
||||
elif status_code == 0b0001:
|
||||
attendance_type = "clock_in"
|
||||
# status_code = (terminal_info >> 2) & 0x0F — always use clock_in
|
||||
pos += 1
|
||||
|
||||
# voltage_level (1 byte)
|
||||
@@ -1799,9 +1852,11 @@ class TCPManager:
|
||||
if not gps_positioned or latitude is None:
|
||||
if mcc is not None and lac is not None and cell_id is not None:
|
||||
try:
|
||||
att_loc_type = "wifi_4g" if is_4g and wifi_data_list else ("lbs_4g" if is_4g else "lbs")
|
||||
lat, lon = await geocode_location(
|
||||
mcc=mcc, mnc=mnc, lac=lac, cell_id=cell_id,
|
||||
wifi_list=wifi_data_list,
|
||||
location_type=att_loc_type,
|
||||
)
|
||||
if lat is not None and lon is not None:
|
||||
latitude, longitude = lat, lon
|
||||
@@ -1829,14 +1884,28 @@ class TCPManager:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("Attendance for unknown IMEI=%s", imei)
|
||||
return attendance_type, reserved_bytes, datetime_bytes
|
||||
|
||||
# Determine attendance source from protocol
|
||||
_att_source = "bluetooth" if proto == 0xB2 else "device"
|
||||
|
||||
# Daily dedup: one clock_in / clock_out per device per day
|
||||
from app.services.fence_checker import _has_attendance_today
|
||||
if await _has_attendance_today(session, device_id, attendance_type):
|
||||
logger.info(
|
||||
"Attendance dedup: IMEI=%s already has %s today, skip",
|
||||
imei, attendance_type,
|
||||
)
|
||||
return attendance_type, reserved_bytes, datetime_bytes
|
||||
|
||||
record = AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
attendance_type=attendance_type,
|
||||
attendance_source=_att_source,
|
||||
protocol_number=proto,
|
||||
gps_positioned=gps_positioned,
|
||||
latitude=latitude,
|
||||
@@ -1888,7 +1957,7 @@ class TCPManager:
|
||||
"""
|
||||
content = pkt["content"]
|
||||
imei = conn_info.imei
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
# -- Parse 0xB2 fields --
|
||||
pos = 0
|
||||
@@ -1938,13 +2007,9 @@ class TCPManager:
|
||||
beacon_battery = raw_batt * 0.01
|
||||
pos += 2
|
||||
|
||||
# Terminal info (1 byte) - clock_in/clock_out
|
||||
# Terminal info (1 byte) - parse but always use clock_in (clock_out disabled)
|
||||
attendance_type = "clock_in"
|
||||
if len(content) > pos:
|
||||
terminal_info = content[pos]
|
||||
status_code = (terminal_info >> 2) & 0x0F
|
||||
if status_code == 0b0010:
|
||||
attendance_type = "clock_out"
|
||||
pos += 1
|
||||
|
||||
# Terminal reserved (2 bytes) - echo back
|
||||
@@ -1955,7 +2020,7 @@ class TCPManager:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is not None:
|
||||
# Look up beacon location from beacon_configs
|
||||
beacon_lat = None
|
||||
@@ -1980,6 +2045,7 @@ class TCPManager:
|
||||
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="punch",
|
||||
protocol_number=pkt["protocol"],
|
||||
beacon_mac=beacon_mac,
|
||||
@@ -2009,12 +2075,53 @@ class TCPManager:
|
||||
beacon_major, beacon_minor, rssi,
|
||||
beacon_battery or 0,
|
||||
)
|
||||
|
||||
# Create AttendanceRecord for bluetooth punch
|
||||
from app.services.fence_checker import _has_attendance_today
|
||||
if not await _has_attendance_today(session, device_id, attendance_type):
|
||||
device = await session.get(Device, device_id)
|
||||
att_record = AttendanceRecord(
|
||||
device_id=device_id,
|
||||
imei=imei,
|
||||
attendance_type=attendance_type,
|
||||
attendance_source="bluetooth",
|
||||
protocol_number=pkt["protocol"],
|
||||
gps_positioned=False,
|
||||
latitude=beacon_lat,
|
||||
longitude=beacon_lon,
|
||||
address=beacon_addr,
|
||||
battery_level=device.battery_level if device else None,
|
||||
gsm_signal=device.gsm_signal if device else None,
|
||||
lbs_data={
|
||||
"source": "bluetooth",
|
||||
"beacon_mac": beacon_mac,
|
||||
"beacon_name": beacon_cfg.name if beacon_cfg else None,
|
||||
},
|
||||
recorded_at=recorded_at,
|
||||
)
|
||||
session.add(att_record)
|
||||
logger.info(
|
||||
"BT attendance created: IMEI=%s type=%s beacon=%s",
|
||||
imei, attendance_type, beacon_mac,
|
||||
)
|
||||
else:
|
||||
logger.info(
|
||||
"BT attendance dedup: IMEI=%s already has %s today",
|
||||
imei, attendance_type,
|
||||
)
|
||||
|
||||
# Broadcast bluetooth punch
|
||||
ws_manager.broadcast_nonblocking("bluetooth", {
|
||||
"imei": imei, "record_type": "punch",
|
||||
"beacon_mac": beacon_mac, "attendance_type": attendance_type,
|
||||
"recorded_at": str(recorded_at),
|
||||
})
|
||||
ws_manager.broadcast_nonblocking("attendance", {
|
||||
"imei": imei, "attendance_type": attendance_type,
|
||||
"latitude": beacon_lat, "longitude": beacon_lon,
|
||||
"address": beacon_addr, "recorded_at": str(recorded_at),
|
||||
"source": "bluetooth",
|
||||
})
|
||||
except Exception:
|
||||
logger.exception("DB error storing BT punch for IMEI=%s", imei)
|
||||
|
||||
@@ -2038,7 +2145,7 @@ class TCPManager:
|
||||
"""
|
||||
content = pkt["content"]
|
||||
imei = conn_info.imei
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
pos = 0
|
||||
recorded_at = self._parse_datetime(content, pos) or now
|
||||
@@ -2118,7 +2225,7 @@ class TCPManager:
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("BT location for unknown IMEI=%s", imei)
|
||||
return
|
||||
@@ -2156,6 +2263,7 @@ class TCPManager:
|
||||
cfg = beacon_locations.get(b["mac"])
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="location",
|
||||
protocol_number=pkt["protocol"],
|
||||
beacon_mac=b["mac"],
|
||||
@@ -2181,6 +2289,7 @@ class TCPManager:
|
||||
# No beacons parsed, store raw
|
||||
record = BluetoothRecord(
|
||||
device_id=device_id,
|
||||
imei=conn_info.imei,
|
||||
record_type="location",
|
||||
protocol_number=pkt["protocol"],
|
||||
bluetooth_data={"raw": content.hex(), "beacon_count": beacon_count},
|
||||
@@ -2295,12 +2404,12 @@ class TCPManager:
|
||||
except Exception:
|
||||
response_text = content[5:].hex()
|
||||
|
||||
now = datetime.now(BEIJING_TZ)
|
||||
now = datetime.now(timezone(timedelta(hours=8))).replace(tzinfo=None)
|
||||
|
||||
try:
|
||||
async with async_session() as session:
|
||||
async with session.begin():
|
||||
device_id = await _get_device_id(session, imei)
|
||||
device_id = await _get_device_id(session, imei, conn_info)
|
||||
if device_id is None:
|
||||
logger.warning("Command reply for unknown IMEI=%s", imei)
|
||||
return
|
||||
@@ -2349,12 +2458,16 @@ class TCPManager:
|
||||
bool
|
||||
``True`` if the command was successfully written to the socket.
|
||||
"""
|
||||
async with self._conn_lock:
|
||||
conn = self.connections.get(imei)
|
||||
if conn is None:
|
||||
logger.warning("Cannot send command to IMEI=%s: not connected", imei)
|
||||
return False
|
||||
|
||||
_reader, writer, conn_info = conn
|
||||
if writer.is_closing():
|
||||
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
||||
del self.connections[imei]
|
||||
return False
|
||||
serial = conn_info.next_serial()
|
||||
|
||||
# Build 0x80 online-command packet
|
||||
@@ -2379,7 +2492,8 @@ class TCPManager:
|
||||
command_content,
|
||||
)
|
||||
except Exception:
|
||||
logger.exception("Failed to send command to IMEI=%s", imei)
|
||||
logger.exception("Failed to send command to IMEI=%s, removing stale connection", imei)
|
||||
self.connections.pop(imei, None)
|
||||
return False
|
||||
|
||||
return True
|
||||
@@ -2399,12 +2513,16 @@ class TCPManager:
|
||||
bool
|
||||
``True`` if the message was successfully written to the socket.
|
||||
"""
|
||||
async with self._conn_lock:
|
||||
conn = self.connections.get(imei)
|
||||
if conn is None:
|
||||
logger.warning("Cannot send message to IMEI=%s: not connected", imei)
|
||||
return False
|
||||
|
||||
_reader, writer, conn_info = conn
|
||||
if writer.is_closing():
|
||||
logger.warning("IMEI=%s writer is closing, removing stale connection", imei)
|
||||
del self.connections[imei]
|
||||
return False
|
||||
serial = conn_info.next_serial()
|
||||
|
||||
msg_bytes = message.encode("utf-16-be")
|
||||
@@ -2427,7 +2545,8 @@ class TCPManager:
|
||||
logger.info("Message sent to IMEI=%s (%d bytes)", imei, len(msg_bytes))
|
||||
return True
|
||||
except Exception:
|
||||
logger.exception("Failed to send message to IMEI=%s", imei)
|
||||
logger.exception("Failed to send message to IMEI=%s, removing stale connection", imei)
|
||||
self.connections.pop(imei, None)
|
||||
return False
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
|
||||
@@ -6,9 +6,7 @@ Manages client connections, topic subscriptions, and broadcasting.
|
||||
import asyncio
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime
|
||||
|
||||
from app.config import BEIJING_TZ
|
||||
from app.config import now_cst
|
||||
|
||||
from fastapi import WebSocket
|
||||
|
||||
@@ -18,7 +16,7 @@ logger = logging.getLogger(__name__)
|
||||
MAX_CONNECTIONS = 100
|
||||
|
||||
# Valid topics
|
||||
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth"}
|
||||
VALID_TOPICS = {"location", "alarm", "device_status", "attendance", "bluetooth", "fence_attendance"}
|
||||
|
||||
|
||||
class WebSocketManager:
|
||||
@@ -59,21 +57,26 @@ class WebSocketManager:
|
||||
return
|
||||
|
||||
message = json.dumps(
|
||||
{"topic": topic, "data": data, "timestamp": datetime.now(BEIJING_TZ).isoformat()},
|
||||
{"topic": topic, "data": data, "timestamp": now_cst().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)
|
||||
# Send to all subscribers concurrently with timeout
|
||||
subscribers = [(ws, topics) for ws, topics in list(self.active_connections.items()) if topic in topics]
|
||||
if not subscribers:
|
||||
return
|
||||
|
||||
for ws in disconnected:
|
||||
async def _safe_send(ws):
|
||||
try:
|
||||
await asyncio.wait_for(ws.send_text(message), timeout=3.0)
|
||||
return None
|
||||
except Exception:
|
||||
return ws
|
||||
|
||||
results = await asyncio.gather(*[_safe_send(ws) for ws, _ in subscribers])
|
||||
for ws in results:
|
||||
if ws is not None:
|
||||
self.active_connections.pop(ws, None)
|
||||
|
||||
def broadcast_nonblocking(self, topic: str, data: dict):
|
||||
|
||||
Reference in New Issue
Block a user