Compare commits

..

13 Commits

Author SHA1 Message Date
3c370721bb refactor: 删除位置追踪热力图功能
移除热力图按钮和 showLocationHeatmap/HeatMap 相关 JS 逻辑

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-04-01 09:34:42 +00:00
9cd9dd9d76 feat: 信标设备绑定 + 蓝牙模式管理 + 系统管理增强 + 数据导出
- 新增 DeviceBeaconBinding 模型,信标-设备多对多绑定 CRUD
- 蓝牙打卡模式批量配置/恢复正常模式 API
- 反向同步: 查询设备 BTMACSET 配置更新数据库绑定 (独立 session 解决事务隔离)
- 设备列表快捷操作弹窗修复 (fire-and-forget IIFE 替代阻塞轮询)
- 保存按钮防抖: 围栏/信标绑定保存点击后 disabled + 转圈防重复提交
- 审计日志中间件 + 系统配置/备份/固件 API
- 设备分组管理 + 告警规则配置
- 5个数据导出 API (CSV UTF-8 BOM)
- 位置热力图 + 告警条件删除 + 位置清理

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-04-01 07:06:37 +00:00
9daa81621c docs: 更新CLAUDE.md反映最新API和前端变化
- routers注释更新: 补充/stats、/batch-acknowledge、/report、/broadcast等新端点
- 管理后台UI描述更新: 各页面统计面板、批量确认、轨迹摘要等新功能
- main.py描述更新: 补充system/overview和system/cleanup端点

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 10:14:54 +00:00
8157f9cb52 feat: 13个统计/聚合API + 前端同步 + 待完成功能文档
API新增:
- GET /api/system/overview 系统总览(在线率/今日统计/表大小)
- GET /api/locations/stats 位置统计(类型分布/小时趋势)
- GET /api/locations/track-summary/{id} 轨迹摘要(距离/时长/速度)
- POST /api/alarms/batch-acknowledge 批量确认告警
- GET /api/attendance/report 考勤日报表(每设备每天汇总)
- GET /api/bluetooth/stats 蓝牙统计(类型/TOP信标/RSSI分布)
- GET /api/heartbeats/stats 心跳统计(活跃设备/电量/间隔分析)
- GET /api/fences/stats 围栏统计(绑定/进出状态/今日事件)
- GET /api/fences/{id}/events 围栏进出事件历史
- GET /api/commands/stats 指令统计(成功率/类型/趋势)

API增强:
- devices/stats: 新增by_type/battery_distribution/signal_distribution
- alarms/stats: 新增today/by_source/daily_trend/top_devices
- attendance/stats: 新增today/by_source/daily_trend/by_device

前端同步:
- 仪表盘: 今日告警/考勤/定位卡片 + 在线率
- 告警页: 批量确认按钮 + 今日计数
- 考勤页: 今日计数
- 轨迹: 加载后显示距离/时长/速度摘要
- 蓝牙/围栏/指令页: 统计面板

文档: CLAUDE.md待完成功能按优先级重新规划

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 10:11:33 +00:00
b25eafc483 feat: 性能优化 + 设备总览轨迹展示 + 广播指令API
性能: SQLite WAL模式、aiohttp Session复用、TCP连接锁+空闲超时、
device_id缓存、WebSocket并发广播、API Key认证缓存、围栏N+1查询
批量化、逆地理编码并行化、新增5个DB索引、日志降级DEBUG

功能: 广播指令API(broadcast)、exclude_type低精度后端过滤、
前端设备总览Tab+多设备轨迹叠加+高亮联动+搜索+专属颜色

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 09:41:09 +00:00
b970b78136 feat: 管理后台UI全面优化 + TCP指令发送修复
- 指令页面:三个设备选择器合并为顶部统一选择器,在线/离线分组显示
- 仪表盘:8卡片压缩为6紧凑卡片,新增刷新按钮和更新时间戳
- 仪表盘:最近告警添加"查看全部"跳转链接
- 考勤页面:定位方式显示GPS/WiFi/LBS文字标签替代纯图标
- 告警页面:alarm_source中文化(单围栏/多围栏/基站/WiFi)
- 告警页面:确认操作改为DOM单行更新,无需刷新整个表格
- 数据日志:默认选中"位置"类型
- 全部表格:全选复选框tooltip改为"全选当前页"
- TCP修复:移除发送前硬性在线检查,改为尝试发送+失败提示
- TCP修复:检测僵死连接(writer.is_closing)并自动清理

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 05:01:04 +00:00
61c300bad8 feat: 告警/考勤/蓝牙/数据日志页面添加批量删除功能
- 新增 POST /api/alarms/batch-delete、/api/bluetooth/batch-delete、
  /api/heartbeats/batch-delete 批量删除端点 (最多500条)
- 四个页面表格添加全选复选框和"删除选中"按钮
- 提取通用 toggleAllCheckboxes/updateSelCount/_batchDelete 函数
- 数据日志页面根据当前查询类型自动路由到对应的批量删除API

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 02:03:21 +00:00
a97dcd07a5 feat: 取消签退(clock_out)机制,仅保留签到记录
GPS定位偏差导致设备频繁误判为离开围栏触发签退,
移除所有签退逻辑:围栏出栏不再创建clock_out,
设备考勤(0xB0/0xB1)和蓝牙打卡(0xB2)统一只记录签到。

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-31 01:54:48 +00:00
3437cd24ea feat: 围栏Tab布局重构、低精度过滤、蓝牙考勤去重、考勤删除API
- 围栏管理页面Tab移至顶部,设备绑定Tab隐藏地图全屏展示绑定矩阵
- 位置追踪新增"低精度"按钮,隐藏LBS/WiFi点(地图+折线+表格联动)
- 移除LBS/WiFi精度半径圆圈,仅通过标记颜色区分定位类型
- 蓝牙打卡(0xB2)自动创建考勤记录,含去重和WebSocket广播
- 新增考勤批量删除和单条删除API
- fence_checker补充json导入

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-30 09:41:55 +00:00
891344bfa0 feat: 位置追踪优化、考勤去重、围栏考勤补充设备信息
- 地图轨迹点按定位类型区分颜色 (GPS蓝/WiFi青/LBS橙/蓝牙紫)
- LBS/WiFi定位点显示精度圈 (虚线圆, LBS~1km/WiFi~80m)
- 地图图例显示各定位类型颜色和精度范围
- 精度圈添加 bubble:true 防止遮挡轨迹点点击
- 点击列表记录直接在地图显示Marker+弹窗 (无需先加载轨迹)
- 修复3D地图setZoomAndCenter坐标偏移, 改用setCenter+setZoom
- 最新位置轮询超时从15s延长至30s (适配LBS慢响应)
- 考勤每日去重: 同设备同类型每天只记录一条 (fence/device/bluetooth通用)
- 围栏自动考勤补充设备电量/信号/基站信息 (从Device表和位置包获取)
- 考勤来源字段 attendance_source 区分 device/bluetooth/fence

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-30 04:26:29 +00:00
1d06cc5415 feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤
- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT)
- 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段
- 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索
- 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState)
- 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖
- TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播
- 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板
- 新增fence_attendance WebSocket topic

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

Co-Authored-By: HAPI <noreply@hapi.run>
2026-03-27 13:04:11 +00:00
cde5146bfe Merge branch 'master' of https://code.desunweb3.com/xufuhan/desungongpai
# Conflicts:
#	.gitignore
#	app/config.py
#	app/dependencies.py
#	app/main.py
#	app/models.py
#	app/protocol/builder.py
#	app/protocol/parser.py
#	app/routers/commands.py
#	app/services/beacon_service.py
#	app/services/device_service.py
#	app/tcp_server.py
#	app/websocket_manager.py
2026-03-27 10:22:16 +00:00
d54e53e0b7 feat: KKS P240/P241 蓝牙工牌管理系统初始提交
FastAPI + SQLAlchemy + asyncio TCP 服务器,支持设备管理、实时定位、
告警、考勤打卡、蓝牙记录、指令下发、TTS语音播报等功能。
2026-03-27 10:19:34 +00:00
36 changed files with 11192 additions and 1161 deletions

1023
.claude/CLAUDE.md Normal file

File diff suppressed because it is too large Load Diff

View File

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

@@ -5,3 +5,4 @@ __pycache__/
nohup.out
.env
.claude/
.idea/

559
CLAUDE.md
View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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
View 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="规则已删除")

View File

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

View File

@@ -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} 个设备")

View File

@@ -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}",

View File

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

View 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},
)

View File

@@ -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
View 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} 个设备")

View File

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

View File

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

View File

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

View File

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

View File

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

View 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"

View 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",
}

View 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

View File

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

View File

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

View File

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

1686
通讯协议.md Normal file

File diff suppressed because it is too large Load Diff