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>
This commit is contained in:
@@ -18,6 +18,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
│
|
│
|
||||||
├── app/
|
├── app/
|
||||||
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器, /api/system/overview, /api/system/cleanup
|
│ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器, /api/system/overview, /api/system/cleanup
|
||||||
|
│ ├── middleware.py # AuditMiddleware — 自动记录 POST/PUT/DELETE 操作到 audit_logs 表
|
||||||
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
|
│ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流)
|
||||||
│ ├── database.py # SQLAlchemy async 数据库连接
|
│ ├── database.py # SQLAlchemy async 数据库连接
|
||||||
│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin)
|
│ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin)
|
||||||
@@ -38,10 +39,11 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
│ │ ├── device_service.py # 设备 CRUD
|
│ │ ├── device_service.py # 设备 CRUD
|
||||||
│ │ ├── command_service.py # 指令日志 CRUD
|
│ │ ├── command_service.py # 指令日志 CRUD
|
||||||
│ │ ├── location_service.py # 位置记录查询
|
│ │ ├── location_service.py # 位置记录查询
|
||||||
│ │ ├── beacon_service.py # 蓝牙信标 CRUD
|
│ │ ├── beacon_service.py # 蓝牙信标 CRUD + 设备绑定 + 蓝牙模式配置/恢复 + 反向同步
|
||||||
│ │ ├── fence_service.py # 电子围栏 CRUD + 设备绑定管理
|
│ │ ├── fence_service.py # 电子围栏 CRUD + 设备绑定管理
|
||||||
│ │ ├── fence_checker.py # 围栏自动考勤引擎 (几何判定+状态机+自动打卡)
|
│ │ ├── fence_checker.py # 围栏自动考勤引擎 (几何判定+状态机+自动打卡)
|
||||||
│ │ └── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
|
│ │ ├── tcp_command_service.py # TCP指令抽象层 (解耦routers↔tcp_server)
|
||||||
|
│ │ └── export_utils.py # CSV导出工具 (build_csv_content, csv_filename)
|
||||||
│ │
|
│ │
|
||||||
│ ├── routers/
|
│ ├── routers/
|
||||||
│ │ ├── devices.py # /api/devices (含 /stats增强, /batch, /batch-delete, /all-latest-locations)
|
│ │ ├── devices.py # /api/devices (含 /stats增强, /batch, /batch-delete, /all-latest-locations)
|
||||||
@@ -51,11 +53,14 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T
|
|||||||
│ │ ├── attendance.py # /api/attendance (含 /stats增强, /report, /device/{id}, attendance_source过滤)
|
│ │ ├── attendance.py # /api/attendance (含 /stats增强, /report, /device/{id}, attendance_source过滤)
|
||||||
│ │ ├── bluetooth.py # /api/bluetooth (含 /stats, beacon_mac过滤, /batch-delete)
|
│ │ ├── bluetooth.py # /api/bluetooth (含 /stats, beacon_mac过滤, /batch-delete)
|
||||||
│ │ ├── heartbeats.py # /api/heartbeats (含 /stats, /batch-delete, 心跳记录查询)
|
│ │ ├── heartbeats.py # /api/heartbeats (含 /stats, /batch-delete, 心跳记录查询)
|
||||||
│ │ ├── beacons.py # /api/beacons (信标管理 CRUD)
|
│ │ ├── beacons.py # /api/beacons (信标管理 CRUD + 设备绑定 + 蓝牙模式/恢复/反向同步)
|
||||||
│ │ ├── fences.py # /api/fences (含 /stats, /{id}/events, /all-active, 设备绑定CRUD)
|
│ │ ├── fences.py # /api/fences (含 /stats, /{id}/events, /all-active, 设备绑定CRUD)
|
||||||
│ │ ├── geocoding.py # /api/geocode (POI搜索代理 /search, 逆地理编码 /reverse)
|
│ │ ├── geocoding.py # /api/geocode (POI搜索代理 /search, 逆地理编码 /reverse)
|
||||||
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
|
│ │ ├── api_keys.py # /api/keys (API密钥管理 CRUD, admin only)
|
||||||
│ │ └── ws.py # /ws (WebSocket实时推送, topic订阅)
|
│ │ ├── ws.py # /ws (WebSocket实时推送, topic订阅)
|
||||||
|
│ │ ├── device_groups.py # /api/groups (设备分组管理 CRUD + 成员管理)
|
||||||
|
│ │ ├── alert_rules.py # /api/alert-rules (告警规则 CRUD)
|
||||||
|
│ │ └── system.py # /api/system (审计日志/运行时配置/数据库备份/固件信息)
|
||||||
│ │
|
│ │
|
||||||
│ └── static/
|
│ └── static/
|
||||||
│ └── admin.html # 管理后台 SPA (暗色主题, 10个页面)
|
│ └── admin.html # 管理后台 SPA (暗色主题, 10个页面)
|
||||||
@@ -367,6 +372,7 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|||||||
|
|
||||||
### 蓝牙信标管理
|
### 蓝牙信标管理
|
||||||
- **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址
|
- **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址
|
||||||
|
- **DeviceBeaconBinding 表**: 设备-信标多对多绑定 (device_id + beacon_id 唯一约束)
|
||||||
- **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表
|
- **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表
|
||||||
- **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude
|
- **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude
|
||||||
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
- **多信标定位**: 0xB3 多信标场景,取 RSSI 信号最强的已注册信标位置
|
||||||
@@ -374,6 +380,13 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md`
|
|||||||
- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641
|
- **已验证信标**: MAC=`C3:00:00:34:43:5E`, UUID=FDA50693-A4E2-4FB1-AFCF-C6EB07647825, Major=10001, Minor=19641
|
||||||
- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空
|
- **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空
|
||||||
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
- **制造商**: 几米物联 (Jimi IoT / jimiiot.com.cn),P240/P241 智能电子工牌系列
|
||||||
|
- **蓝牙模式 API**:
|
||||||
|
- `POST /api/beacons/setup-bluetooth-mode` — 批量配置蓝牙打卡模式 (CLOCKWAY,3# + MODE,2# + BTMACSET + BTMP3SW,1#)
|
||||||
|
- `POST /api/beacons/restore-normal-mode` — 批量恢复智能模式 (CLOCKWAY,1# + MODE,3# + BTMP3SW,0#)
|
||||||
|
- `POST /api/beacons/reverse-sync` — 查询设备 BTMACSET 配置,反向更新数据库绑定
|
||||||
|
- `POST /api/beacons/sync-device/{id}` — 同步绑定信标 MAC 到指定设备
|
||||||
|
- `GET/POST/DELETE /api/beacons/{id}/devices` — 信标设备绑定 CRUD
|
||||||
|
- **反向同步事务隔离**: 使用独立 session 创建 CommandLog 并 commit,轮询用独立 session 避免 SQLAlchemy greenlet 错误
|
||||||
|
|
||||||
### P241 蓝牙模式配置指南 (设备端)
|
### P241 蓝牙模式配置指南 (设备端)
|
||||||
设备蓝牙模式通过在线指令 (0x80) 配置,需依次完成以下步骤:
|
设备蓝牙模式通过在线指令 (0x80) 配置,需依次完成以下步骤:
|
||||||
@@ -824,40 +837,59 @@ remotePort = 5001
|
|||||||
194. **总览低精度过滤** - 独立的低精度按钮,切换时自动重新加载轨迹(跳过LBS点重连折线)
|
194. **总览低精度过滤** - 独立的低精度按钮,切换时自动重新加载轨迹(跳过LBS点重连折线)
|
||||||
195. **离线设备支持** - batch-latest 查数据库历史位置(不限在线),取消勾选的设备点击名称仍可定位+查轨迹
|
195. **离线设备支持** - batch-latest 查数据库历史位置(不限在线),取消勾选的设备点击名称仍可定位+查轨迹
|
||||||
|
|
||||||
|
### 数据导出 API + 前端导出按钮 (2026-04-01)
|
||||||
|
196. **export_utils.py** - 新建共享 CSV 导出工具模块 (build_csv_content + csv_filename),UTF-8 BOM 编码确保 Excel 兼容
|
||||||
|
197. **设备导出** - `GET /api/devices/export` 导出设备列表 CSV,支持 status/search 筛选
|
||||||
|
198. **位置导出** - `GET /api/locations/export` 导出位置记录 CSV,支持 device_id/location_type/时间筛选,最多 50000 条
|
||||||
|
199. **告警导出** - `GET /api/alarms/export` 导出告警记录 CSV,支持 alarm_type/acknowledged/时间筛选
|
||||||
|
200. **考勤导出** - `GET /api/attendance/export` 导出考勤记录 CSV,支持 attendance_type/attendance_source/时间筛选
|
||||||
|
201. **蓝牙导出** - `GET /api/bluetooth/export` 导出蓝牙记录 CSV,支持 record_type/beacon_mac/时间筛选
|
||||||
|
202. **考勤报表导出** - `GET /api/attendance/report/export` 导出考勤日报表 CSV (设备×日期汇总)
|
||||||
|
203. **前端导出按钮** - 设备/位置/告警/考勤/蓝牙 5 个页面工具栏新增导出按钮,携带当前筛选条件,fetch+blob 下载
|
||||||
|
|
||||||
|
### 批量操作 + 筛选增强 (2026-04-01)
|
||||||
|
204. **告警条件删除** - `POST /api/alarms/batch-delete` 增强为双模式: 按 alarm_ids 列表删除 (最多500) 或按条件删除 (device_id/alarm_type/acknowledged/时间范围),使用 sql_delete 高效执行
|
||||||
|
205. **位置记录清理** - 新增 `POST /api/locations/cleanup`,按天数删除旧记录 (1-365天),支持 device_id/location_type 筛选,返回删除条数
|
||||||
|
206. **设备分组管理** - 新增 DeviceGroup + DeviceGroupMember 模型,Device 增加 group_id 字段;完整 CRUD API (`/api/groups`),支持组内设备管理 (添加/移除/查看),设备计数通过 outerjoin 聚合
|
||||||
|
207. **位置热力图** - 新增 `GET /api/locations/heatmap`,坐标网格聚合 (precision 参数控制精度),返回 [{lat, lng, weight}];前端使用高德 HeatMap 插件渲染,含 WGS84→GCJ02 偏移
|
||||||
|
208. **告警规则配置** - 新增 AlertRule 模型 (name/rule_type/conditions JSON/is_active/device_ids/group_id),5种规则类型 (low_battery/no_heartbeat/fence_stay/speed_limit/offline_duration);完整 CRUD API (`/api/alert-rules`)
|
||||||
|
209. **前端告警条件删除** - 告警页面工具栏新增"条件删除"按钮,弹窗支持按设备/类型/状态/时间范围批量删除
|
||||||
|
210. **前端位置清理** - 位置页面工具栏新增"清理旧数据"按钮,弹窗输入天数+可选设备ID/定位类型筛选
|
||||||
|
211. **前端热力图** - 位置页面工具栏新增"热力图"按钮,弹窗选择设备和时间范围,高德 HeatMap 插件全屏渲染
|
||||||
|
212. **前端分组管理** - 设备页面工具栏新增"分组管理"按钮,弹窗展示分组列表 (含设备数),支持创建/删除分组、管理组内设备
|
||||||
|
213. **前端告警规则** - 告警页面工具栏新增"告警规则"按钮,弹窗展示规则列表,支持创建/启用禁用/删除规则
|
||||||
|
|
||||||
|
### 系统管理增强 (2026-04-01)
|
||||||
|
214. **操作审计日志** - 新增 AuditLog 模型 + AuditMiddleware 中间件,自动记录所有 /api/ POST/PUT/DELETE 操作 (方法/路径/状态码/操作人/IP/请求体/耗时),敏感字段自动脱敏
|
||||||
|
215. **审计日志 API** - `GET /api/system/audit-logs` 分页查询 (支持方法/路径/操作人/时间筛选),`DELETE /api/system/audit-logs?days=N` 清理旧日志
|
||||||
|
216. **系统配置 API** - `GET /api/system/config` 查看运行时配置,`PUT /api/system/config` 修改 (数据保留天数/清理间隔/TCP超时/围栏参数等),仅影响当前进程,重启恢复 .env 值
|
||||||
|
217. **数据库备份 API** - `POST /api/system/backup` SQLite online backup + 文件下载,`GET /api/system/backups` 列出备份,`DELETE /api/system/backups/{name}` 删除备份
|
||||||
|
218. **设备固件信息 API** - `GET /api/system/firmware` 获取所有设备型号/ICCID/IMSI/登录时间等信息,支持按状态筛选
|
||||||
|
219. **前端系统管理** - 仪表盘工具栏新增: 系统配置弹窗 (运行时参数编辑)、审计日志弹窗 (分页+筛选+清理)、备份管理弹窗 (创建+下载+删除)、固件信息弹窗 (设备列表+批量VERSION#查询)
|
||||||
|
|
||||||
|
### 信标设备绑定 + 蓝牙模式管理 (2026-04-01)
|
||||||
|
220. **DeviceBeaconBinding 模型** - 设备-信标多对多绑定表 (device_id + beacon_id 唯一约束),支持 CRUD
|
||||||
|
221. **信标绑定 API** - `GET/POST/DELETE /api/beacons/{id}/devices` 管理信标绑定的设备列表
|
||||||
|
222. **设备信标同步** - `POST /api/beacons/sync-device/{id}` 将绑定的信标 MAC 通过 BTMACSET 指令写入设备 (5 slot × 10 MAC)
|
||||||
|
223. **蓝牙打卡模式** - `POST /api/beacons/setup-bluetooth-mode` 批量配置设备蓝牙打卡 (CLOCKWAY,3# → MODE,2# → BTMACSET → BTMP3SW,1#)
|
||||||
|
224. **恢复正常模式** - `POST /api/beacons/restore-normal-mode` 批量恢复设备到智能模式 (CLOCKWAY,1# → MODE,3# → BTMP3SW,0#)
|
||||||
|
225. **反向同步** - `POST /api/beacons/reverse-sync` 查询所有在线设备的 BTMACSET 配置并更新数据库绑定关系,独立 session 解决事务隔离问题
|
||||||
|
226. **信标绑定矩阵 UI** - 信标管理页面 Tab 切换 (信标列表 + 设备绑定),设备绑定 Tab 含绑定矩阵 checkbox 表格、保存更改按钮
|
||||||
|
227. **设备列表蓝牙操作** - 工具栏新增"蓝牙打卡模式"(紫色)和"恢复正常模式"(绿色)按钮;每行设备新增"蓝牙"和"正常"快捷按钮
|
||||||
|
228. **快捷操作弹窗修复** - `_devQuickCmd` 重写: 移除按钮 disabled/spinner,改为立即弹窗显示进度,轮询移入 fire-and-forget IIFE 不阻塞
|
||||||
|
229. **保存按钮防抖** - 围栏绑定和信标绑定的"保存更改"按钮点击后立即 disabled + 转圈,防止重复提交;信标保存还显示"同步指令到设备..."阶段提示
|
||||||
|
|
||||||
## 待完成功能
|
## 待完成功能
|
||||||
|
|
||||||
### 第一优先级 — API 增强 (用于系统集成)
|
### 第一优先级 — 前端与体验
|
||||||
|
|
||||||
#### 第二批: 数据导出 API
|
|
||||||
5. **设备数据导出** - `GET /api/devices/export?format=csv` — 导出设备列表 CSV/Excel,支持状态/类型筛选
|
|
||||||
6. **位置数据导出** - `GET /api/locations/export?format=csv` — 导出位置记录 CSV,支持设备/时间/类型筛选
|
|
||||||
7. **告警数据导出** - `GET /api/alarms/export?format=csv` — 导出告警记录 CSV,支持类型/状态/时间筛选
|
|
||||||
8. **考勤数据导出** - `GET /api/attendance/export?format=csv` — 导出考勤记录 CSV,支持设备/来源/时间筛选
|
|
||||||
9. **蓝牙数据导出** - `GET /api/bluetooth/export?format=csv` — 导出蓝牙记录 CSV
|
|
||||||
10. **考勤报表导出** - `GET /api/attendance/report/export?format=csv` — 导出考勤日报表 (每设备每天汇总)
|
|
||||||
|
|
||||||
#### 第三批: 批量操作 + 筛选增强
|
|
||||||
11. **告警批量删除增强** - 支持按时间范围/类型/设备批量删除 (不限于选中 ID)
|
|
||||||
12. **位置记录清理增强** - `POST /api/locations/cleanup` — 删除N天前旧记录,支持按定位类型筛选
|
|
||||||
13. **设备分组管理** - 新增 DeviceGroup 模型,支持设备分组、按组查询、按组批量操作
|
|
||||||
14. **位置数据聚合** - `GET /api/locations/heatmap?device_id=&start_time=&end_time=` — 返回热力图数据 (经纬度+权重)
|
|
||||||
15. **告警规则配置** - 新增告警规则 API,支持自定义告警阈值 (低电量阈值、围栏停留时间等)
|
|
||||||
|
|
||||||
#### 第四批: 系统管理增强
|
|
||||||
16. **操作审计日志** - 新增 AuditLog 模型,记录所有 POST/PUT/DELETE 操作 (操作人/IP/端点/参数/时间)
|
|
||||||
17. **系统配置 API** - `GET/PUT /api/system/config` — 运行时配置查看和修改 (数据保留天数、清理间隔等)
|
|
||||||
18. **数据库备份 API** - `POST /api/system/backup` — 触发 SQLite 数据库备份,返回下载链接
|
|
||||||
19. **设备固件管理** - 新增固件版本记录 API,支持批量查询设备固件版本
|
|
||||||
|
|
||||||
### 第二优先级 — 前端与体验
|
|
||||||
|
|
||||||
20. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知弹窗
|
20. **前端 WebSocket 集成** - admin.html Dashboard 改用 WebSocket 替代 30s 轮询,报警页实时通知弹窗
|
||||||
21. **考勤报表页面** - 前端新增考勤报表 Tab,调用 `GET /api/attendance/report` 展示每设备每天签到汇总
|
21. **考勤报表页面** - 前端新增考勤报表 Tab,调用 `GET /api/attendance/report` 展示每设备每天签到汇总
|
||||||
22. **心跳统计页面** - 数据日志页面心跳 Tab 增加统计面板,调用 `GET /api/heartbeats/stats` 展示设备活跃/异常/电量分布
|
22. **心跳统计页面** - 数据日志页面心跳 Tab 增加统计面板,调用 `GET /api/heartbeats/stats` 展示设备活跃/异常/电量分布
|
||||||
23. **位置统计页面** - 位置追踪页面增加统计面板,调用 `GET /api/locations/stats` 展示定位类型分布/小时趋势
|
23. **位置统计页面** - 位置追踪页面增加统计面板,调用 `GET /api/locations/stats` 展示定位类型分布/小时趋势
|
||||||
24. **导出按钮集成** - 各数据页面工具栏添加"导出 CSV"按钮 (需先完成第二批导出 API)
|
24. ~~**导出按钮集成**~~ - ✅ 已完成 (随第二批导出 API 一起实现)
|
||||||
|
|
||||||
### 第三优先级 — 性能与架构
|
### 第二优先级 — 性能与架构
|
||||||
|
|
||||||
25. **性能优化第三批** - 迁移 PostgreSQL、多worker部署 (几千台设备时)
|
25. **性能优化第三批** - 迁移 PostgreSQL、多worker部署 (几千台设备时)
|
||||||
26. **API 版本化** - 添加 `/api/v1/` 前缀,为将来 v2 预留兼容空间
|
26. **API 版本化** - 添加 `/api/v1/` 前缀,为将来 v2 预留兼容空间
|
||||||
@@ -866,6 +898,40 @@ remotePort = 5001
|
|||||||
27. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 (已存原始 hex,按需解析)
|
27. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 (已存原始 hex,按需解析)
|
||||||
28. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py (代码重构)
|
28. **协议层深度统一** - tcp_server.py 辅助方法 (_parse_gps, _parse_datetime 等) 逐步迁移到 protocol/parser.py (代码重构)
|
||||||
|
|
||||||
|
### 已完成的 API 增强 (第四批 — 系统管理增强,2026-04-01)
|
||||||
|
- ✅ AuditLog 模型 + AuditMiddleware — 自动记录 /api/ POST/PUT/DELETE 操作 (方法/路径/状态码/操作人/IP/请求体/耗时)
|
||||||
|
- ✅ `GET /api/system/audit-logs` — 审计日志分页查询 (方法/路径/操作人/时间筛选)
|
||||||
|
- ✅ `DELETE /api/system/audit-logs?days=N` — 清理N天前审计日志
|
||||||
|
- ✅ `GET /api/system/config` — 查看运行时配置参数
|
||||||
|
- ✅ `PUT /api/system/config` — 更新运行时配置 (进程级,重启恢复)
|
||||||
|
- ✅ `POST /api/system/backup` — SQLite online backup + 文件下载
|
||||||
|
- ✅ `GET /api/system/backups` — 列出已有备份文件
|
||||||
|
- ✅ `DELETE /api/system/backups/{filename}` — 删除指定备份
|
||||||
|
- ✅ `GET /api/system/firmware` — 设备固件信息列表 (型号/ICCID/IMSI等)
|
||||||
|
- ✅ 前端: 仪表盘系统配置弹窗、审计日志弹窗、备份管理弹窗、固件信息弹窗
|
||||||
|
- ✅ 新增文件: app/middleware.py (审计中间件), app/routers/system.py (系统管理路由)
|
||||||
|
|
||||||
|
### 已完成的 API 增强 (第三批 — 批量操作+筛选增强,2026-04-01)
|
||||||
|
- ✅ `POST /api/alarms/batch-delete` 增强 — 双模式删除: 按ID列表 或 按条件 (设备/类型/状态/时间范围)
|
||||||
|
- ✅ `POST /api/locations/cleanup` — 位置记录清理,按天数+设备+定位类型筛选删除旧记录
|
||||||
|
- ✅ `GET /api/locations/heatmap` — 位置热力图数据,坐标网格聚合,precision 参数控制精度
|
||||||
|
- ✅ `/api/groups` CRUD — 设备分组管理 (创建/更新/删除/列表+设备计数)
|
||||||
|
- ✅ `/api/groups/{id}/devices` — 分组设备管理 (查看/添加/移除,幂等添加)
|
||||||
|
- ✅ `/api/alert-rules` CRUD — 告警规则配置 (5种规则类型,conditions JSON,启用/禁用)
|
||||||
|
- ✅ 新增 ORM 模型: DeviceGroup, DeviceGroupMember, AlertRule; Device 增加 group_id
|
||||||
|
- ✅ 前端: 告警条件删除弹窗、位置清理弹窗、热力图 (高德HeatMap插件)、分组管理弹窗、告警规则弹窗
|
||||||
|
|
||||||
|
### 已完成的 API 增强 (第二批 — 数据导出,2026-04-01)
|
||||||
|
- ✅ `GET /api/devices/export` — 导出设备列表 CSV,支持状态/搜索筛选
|
||||||
|
- ✅ `GET /api/locations/export` — 导出位置记录 CSV,支持设备/类型/时间筛选 (最多50000条)
|
||||||
|
- ✅ `GET /api/alarms/export` — 导出告警记录 CSV,支持类型/状态/时间筛选 (最多50000条)
|
||||||
|
- ✅ `GET /api/attendance/export` — 导出考勤记录 CSV,支持设备/类型/来源/时间筛选 (最多50000条)
|
||||||
|
- ✅ `GET /api/bluetooth/export` — 导出蓝牙记录 CSV,支持设备/类型/信标/时间筛选 (最多50000条)
|
||||||
|
- ✅ `GET /api/attendance/report/export` — 导出考勤日报表 CSV (每设备每天汇总)
|
||||||
|
- ✅ 前端 admin.html: 设备/位置/告警/考勤/蓝牙 页面工具栏新增"导出"按钮,支持当前筛选条件导出
|
||||||
|
- ✅ CSV 使用 UTF-8 BOM 编码,Excel 可直接打开中文不乱码
|
||||||
|
- ✅ 共享 export_utils.py 工具模块 (build_csv_content + csv_filename)
|
||||||
|
|
||||||
### 已完成的 API 增强 (第一批 — 统计/聚合,2026-03-31)
|
### 已完成的 API 增强 (第一批 — 统计/聚合,2026-03-31)
|
||||||
- ✅ `GET /api/system/overview` — 系统总览 (设备在线率/今日统计/表记录数/DB大小)
|
- ✅ `GET /api/system/overview` — 系统总览 (设备在线率/今日统计/表记录数/DB大小)
|
||||||
- ✅ `GET /api/devices/stats` 增强 — 新增 by_type, battery_distribution, signal_distribution, online_rate
|
- ✅ `GET /api/devices/stats` 增强 — 新增 by_type, battery_distribution, signal_distribution, online_rate
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ async def init_db() -> None:
|
|||||||
from app.models import ( # noqa: F401
|
from app.models import ( # noqa: F401
|
||||||
AlarmRecord,
|
AlarmRecord,
|
||||||
AttendanceRecord,
|
AttendanceRecord,
|
||||||
|
AuditLog,
|
||||||
BluetoothRecord,
|
BluetoothRecord,
|
||||||
CommandLog,
|
CommandLog,
|
||||||
Device,
|
Device,
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import hashlib
|
|||||||
import secrets
|
import secrets
|
||||||
import time
|
import time
|
||||||
|
|
||||||
from fastapi import Depends, HTTPException, Security
|
from fastapi import Depends, HTTPException, Request, Security
|
||||||
from fastapi.security import APIKeyHeader
|
from fastapi.security import APIKeyHeader
|
||||||
from sqlalchemy import select
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -32,6 +32,7 @@ def _hash_key(key: str) -> str:
|
|||||||
|
|
||||||
|
|
||||||
async def verify_api_key(
|
async def verify_api_key(
|
||||||
|
request: Request,
|
||||||
api_key: str | None = Security(_api_key_header),
|
api_key: str | None = Security(_api_key_header),
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
) -> dict | None:
|
) -> dict | None:
|
||||||
@@ -48,7 +49,9 @@ async def verify_api_key(
|
|||||||
|
|
||||||
# Check master key
|
# Check master key
|
||||||
if secrets.compare_digest(api_key, settings.API_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
|
# Check in-memory cache first
|
||||||
key_hash = _hash_key(api_key)
|
key_hash = _hash_key(api_key)
|
||||||
@@ -77,6 +80,7 @@ async def verify_api_key(
|
|||||||
|
|
||||||
key_info = {"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)
|
_AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL)
|
||||||
|
request.state.key_info = key_info
|
||||||
return key_info
|
return key_info
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
|
|||||||
from app.database import init_db, async_session, engine
|
from app.database import init_db, async_session, engine
|
||||||
from app.tcp_server import tcp_manager
|
from app.tcp_server import tcp_manager
|
||||||
from app.config import settings
|
from app.config import settings
|
||||||
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, 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
|
from app.dependencies import verify_api_key, require_write, require_admin
|
||||||
|
|
||||||
import asyncio
|
import asyncio
|
||||||
@@ -159,6 +159,10 @@ app.add_middleware(
|
|||||||
allow_headers=["*"],
|
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
|
# Global exception handler — prevent stack trace leaks
|
||||||
@app.exception_handler(Exception)
|
@app.exception_handler(Exception)
|
||||||
@@ -190,6 +194,9 @@ app.include_router(heartbeats.router, dependencies=[*_api_deps])
|
|||||||
app.include_router(api_keys.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(ws.router) # WebSocket handles auth internally
|
||||||
app.include_router(geocoding.router, dependencies=[*_api_deps])
|
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"
|
_STATIC_DIR = Path(__file__).parent / "static"
|
||||||
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static")
|
||||||
|
|||||||
101
app/middleware.py
Normal file
101
app/middleware.py
Normal file
@@ -0,0 +1,101 @@
|
|||||||
|
"""
|
||||||
|
Audit logging middleware.
|
||||||
|
Records POST/PUT/DELETE requests to the audit_logs table.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import time
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
|
||||||
|
from starlette.requests import Request
|
||||||
|
from starlette.responses import Response
|
||||||
|
|
||||||
|
from app.database import async_session
|
||||||
|
from app.models import AuditLog
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
# Methods to audit
|
||||||
|
_AUDIT_METHODS = {"POST", "PUT", "DELETE"}
|
||||||
|
|
||||||
|
# Paths to skip (noisy or non-business endpoints)
|
||||||
|
_SKIP_PREFIXES = ("/ws", "/health", "/docs", "/redoc", "/openapi.json")
|
||||||
|
|
||||||
|
# Max request body size to store (bytes)
|
||||||
|
_MAX_BODY_SIZE = 4096
|
||||||
|
|
||||||
|
|
||||||
|
def _get_client_ip(request: Request) -> str:
|
||||||
|
"""Extract real client IP from proxy headers."""
|
||||||
|
forwarded = request.headers.get("X-Forwarded-For")
|
||||||
|
if forwarded:
|
||||||
|
return forwarded.split(",")[0].strip()
|
||||||
|
cf_ip = request.headers.get("CF-Connecting-IP")
|
||||||
|
if cf_ip:
|
||||||
|
return cf_ip
|
||||||
|
return request.client.host if request.client else "unknown"
|
||||||
|
|
||||||
|
|
||||||
|
def _get_operator(request: Request) -> str | None:
|
||||||
|
"""Extract operator name from request state (set by verify_api_key)."""
|
||||||
|
key_info = getattr(request.state, "key_info", None)
|
||||||
|
if key_info and isinstance(key_info, dict):
|
||||||
|
return key_info.get("name")
|
||||||
|
return None
|
||||||
|
|
||||||
|
|
||||||
|
class AuditMiddleware(BaseHTTPMiddleware):
|
||||||
|
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
|
||||||
|
if request.method not in _AUDIT_METHODS:
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
path = request.url.path
|
||||||
|
if any(path.startswith(p) for p in _SKIP_PREFIXES):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Only audit /api/ routes
|
||||||
|
if not path.startswith("/api/"):
|
||||||
|
return await call_next(request)
|
||||||
|
|
||||||
|
# Read request body for audit (cache it for downstream)
|
||||||
|
body_bytes = await request.body()
|
||||||
|
request_body = None
|
||||||
|
if body_bytes and len(body_bytes) <= _MAX_BODY_SIZE:
|
||||||
|
try:
|
||||||
|
request_body = json.loads(body_bytes)
|
||||||
|
# Redact sensitive fields
|
||||||
|
if isinstance(request_body, dict):
|
||||||
|
for key in ("password", "api_key", "key", "secret", "token"):
|
||||||
|
if key in request_body:
|
||||||
|
request_body[key] = "***REDACTED***"
|
||||||
|
except (json.JSONDecodeError, UnicodeDecodeError):
|
||||||
|
request_body = None
|
||||||
|
|
||||||
|
start = time.monotonic()
|
||||||
|
response = await call_next(request)
|
||||||
|
duration_ms = int((time.monotonic() - start) * 1000)
|
||||||
|
|
||||||
|
# Extract operator from dependency injection result
|
||||||
|
operator = _get_operator(request)
|
||||||
|
|
||||||
|
# Build response summary
|
||||||
|
response_summary = f"HTTP {response.status_code}"
|
||||||
|
|
||||||
|
try:
|
||||||
|
async with async_session() as session:
|
||||||
|
async with session.begin():
|
||||||
|
session.add(AuditLog(
|
||||||
|
method=request.method,
|
||||||
|
path=path,
|
||||||
|
status_code=response.status_code,
|
||||||
|
operator=operator,
|
||||||
|
client_ip=_get_client_ip(request),
|
||||||
|
request_body=request_body,
|
||||||
|
response_summary=response_summary,
|
||||||
|
duration_ms=duration_ms,
|
||||||
|
))
|
||||||
|
except Exception:
|
||||||
|
logger.debug("Failed to write audit log for %s %s", request.method, path)
|
||||||
|
|
||||||
|
return response
|
||||||
110
app/models.py
110
app/models.py
@@ -36,6 +36,7 @@ class Device(Base):
|
|||||||
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
imsi: Mapped[str | None] = mapped_column(String(20), nullable=True)
|
||||||
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False)
|
||||||
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False)
|
||||||
|
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
|
||||||
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
|
||||||
updated_at: Mapped[datetime | None] = mapped_column(
|
updated_at: Mapped[datetime | None] = mapped_column(
|
||||||
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
|
||||||
@@ -297,6 +298,27 @@ class BeaconConfig(Base):
|
|||||||
return f"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
|
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):
|
class FenceConfig(Base):
|
||||||
"""Geofence configuration for area monitoring."""
|
"""Geofence configuration for area monitoring."""
|
||||||
|
|
||||||
@@ -403,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):
|
class ApiKey(Base):
|
||||||
"""API keys for external system authentication."""
|
"""API keys for external system authentication."""
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
from pydantic import BaseModel, Field
|
from pydantic import BaseModel, Field
|
||||||
from sqlalchemy import func, select, case, extract
|
from sqlalchemy import func, select, case, extract
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
@@ -16,6 +17,7 @@ from app.dependencies import require_write
|
|||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import AlarmRecord
|
from app.models import AlarmRecord
|
||||||
|
from app.services.export_utils import build_csv_content, csv_filename
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
AlarmAcknowledge,
|
AlarmAcknowledge,
|
||||||
AlarmRecordResponse,
|
AlarmRecordResponse,
|
||||||
@@ -94,6 +96,49 @@ 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(
|
@router.get(
|
||||||
"/stats",
|
"/stats",
|
||||||
response_model=APIResponse[dict],
|
response_model=APIResponse[dict],
|
||||||
@@ -210,6 +255,15 @@ async def batch_acknowledge_alarms(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
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(
|
@router.post(
|
||||||
"/batch-delete",
|
"/batch-delete",
|
||||||
response_model=APIResponse[dict],
|
response_model=APIResponse[dict],
|
||||||
@@ -217,23 +271,56 @@ async def batch_acknowledge_alarms(
|
|||||||
dependencies=[Depends(require_write)],
|
dependencies=[Depends(require_write)],
|
||||||
)
|
)
|
||||||
async def batch_delete_alarms(
|
async def batch_delete_alarms(
|
||||||
body: dict,
|
body: BatchDeleteAlarmRequest,
|
||||||
db: AsyncSession = Depends(get_db),
|
db: AsyncSession = Depends(get_db),
|
||||||
):
|
):
|
||||||
"""批量删除告警记录,最多500条。 / Batch delete alarm records (max 500)."""
|
"""
|
||||||
alarm_ids = body.get("alarm_ids", [])
|
批量删除告警记录。两种模式:
|
||||||
if not alarm_ids:
|
1. 按ID删除: 传 alarm_ids (最多500条)
|
||||||
raise HTTPException(status_code=400, detail="alarm_ids is required")
|
2. 按条件删除: 传 device_id/alarm_type/acknowledged/start_time/end_time 组合
|
||||||
if len(alarm_ids) > 500:
|
"""
|
||||||
raise HTTPException(status_code=400, detail="Max 500 records per request")
|
from sqlalchemy import delete as sql_delete
|
||||||
result = await db.execute(
|
|
||||||
select(AlarmRecord).where(AlarmRecord.id.in_(alarm_ids))
|
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},
|
||||||
)
|
)
|
||||||
records = list(result.scalars().all())
|
|
||||||
for r in records:
|
|
||||||
await db.delete(r)
|
|
||||||
await db.flush()
|
|
||||||
return APIResponse(data={"deleted": len(records)})
|
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
|
|||||||
109
app/routers/alert_rules.py
Normal file
109
app/routers/alert_rules.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
"""
|
||||||
|
Alert Rules Router - 告警规则配置接口
|
||||||
|
API endpoints for alert rule CRUD operations.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy import select
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import require_write
|
||||||
|
from app.models import AlertRule
|
||||||
|
from app.schemas import (
|
||||||
|
APIResponse,
|
||||||
|
AlertRuleCreate,
|
||||||
|
AlertRuleResponse,
|
||||||
|
AlertRuleUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules / 告警规则"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=APIResponse[list[AlertRuleResponse]],
|
||||||
|
summary="获取告警规则列表 / List alert rules",
|
||||||
|
)
|
||||||
|
async def list_rules(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取所有告警规则。"""
|
||||||
|
result = await db.execute(select(AlertRule).order_by(AlertRule.id))
|
||||||
|
rules = list(result.scalars().all())
|
||||||
|
return APIResponse(data=[AlertRuleResponse.model_validate(r) for r in rules])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=APIResponse[AlertRuleResponse],
|
||||||
|
status_code=201,
|
||||||
|
summary="创建告警规则 / Create alert rule",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def create_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""创建新告警规则。"""
|
||||||
|
rule = AlertRule(
|
||||||
|
name=body.name,
|
||||||
|
rule_type=body.rule_type,
|
||||||
|
conditions=body.conditions,
|
||||||
|
is_active=body.is_active,
|
||||||
|
device_ids=body.device_ids,
|
||||||
|
group_id=body.group_id,
|
||||||
|
description=body.description,
|
||||||
|
)
|
||||||
|
db.add(rule)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{rule_id}",
|
||||||
|
response_model=APIResponse[AlertRuleResponse],
|
||||||
|
summary="获取告警规则详情 / Get alert rule",
|
||||||
|
)
|
||||||
|
async def get_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取告警规则详情。"""
|
||||||
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||||
|
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{rule_id}",
|
||||||
|
response_model=APIResponse[AlertRuleResponse],
|
||||||
|
summary="更新告警规则 / Update alert rule",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def update_rule(
|
||||||
|
rule_id: int, body: AlertRuleUpdate, db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新告警规则。"""
|
||||||
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for k, v in update_data.items():
|
||||||
|
setattr(rule, k, v)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(rule)
|
||||||
|
return APIResponse(data=AlertRuleResponse.model_validate(rule))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{rule_id}",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="删除告警规则 / Delete alert rule",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""删除告警规则。"""
|
||||||
|
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
|
||||||
|
rule = result.scalar_one_or_none()
|
||||||
|
if not rule:
|
||||||
|
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
|
||||||
|
await db.delete(rule)
|
||||||
|
await db.flush()
|
||||||
|
return APIResponse(message="规则已删除")
|
||||||
@@ -7,11 +7,13 @@ import math
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import func, select, case
|
from sqlalchemy import func, select, case
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import AttendanceRecord
|
from app.models import AttendanceRecord
|
||||||
|
from app.services.export_utils import build_csv_content, csv_filename
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
AttendanceRecordResponse,
|
AttendanceRecordResponse,
|
||||||
@@ -83,6 +85,46 @@ 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(
|
@router.get(
|
||||||
"/stats",
|
"/stats",
|
||||||
response_model=APIResponse[dict],
|
response_model=APIResponse[dict],
|
||||||
@@ -252,6 +294,71 @@ async def attendance_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')}"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.get(
|
@router.get(
|
||||||
"/device/{device_id}",
|
"/device/{device_id}",
|
||||||
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
response_model=APIResponse[PaginatedList[AttendanceRecordResponse]],
|
||||||
|
|||||||
@@ -16,6 +16,8 @@ from app.schemas import (
|
|||||||
BeaconConfigCreate,
|
BeaconConfigCreate,
|
||||||
BeaconConfigResponse,
|
BeaconConfigResponse,
|
||||||
BeaconConfigUpdate,
|
BeaconConfigUpdate,
|
||||||
|
BeaconDeviceDetail,
|
||||||
|
DeviceBeaconBindRequest,
|
||||||
PaginatedList,
|
PaginatedList,
|
||||||
)
|
)
|
||||||
from app.services import beacon_service
|
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(
|
@router.get(
|
||||||
"/{beacon_id}",
|
"/{beacon_id}",
|
||||||
response_model=APIResponse[BeaconConfigResponse],
|
response_model=APIResponse[BeaconConfigResponse],
|
||||||
@@ -102,3 +174,52 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
|||||||
if not success:
|
if not success:
|
||||||
raise HTTPException(status_code=404, detail="Beacon not found")
|
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||||
return APIResponse(message="Beacon deleted")
|
return APIResponse(message="Beacon deleted")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device-Beacon Binding endpoints
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{beacon_id}/devices",
|
||||||
|
response_model=APIResponse[list[BeaconDeviceDetail]],
|
||||||
|
summary="获取信标绑定的设备 / Get beacon devices",
|
||||||
|
)
|
||||||
|
async def get_beacon_devices(beacon_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
beacon = await beacon_service.get_beacon(db, beacon_id)
|
||||||
|
if beacon is None:
|
||||||
|
raise HTTPException(status_code=404, detail="Beacon not found")
|
||||||
|
items = await beacon_service.get_beacon_devices(db, beacon_id)
|
||||||
|
return APIResponse(data=[BeaconDeviceDetail(**item) for item in items])
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{beacon_id}/devices",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="绑定设备到信标 / Bind devices to beacon",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def bind_devices(
|
||||||
|
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
result = await beacon_service.bind_devices_to_beacon(db, beacon_id, body.device_ids)
|
||||||
|
if result.get("error"):
|
||||||
|
raise HTTPException(status_code=404, detail=result["error"])
|
||||||
|
return APIResponse(
|
||||||
|
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
|
||||||
|
data=result,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{beacon_id}/devices",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="解绑设备 / Unbind devices from beacon",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def unbind_devices(
|
||||||
|
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
count = await beacon_service.unbind_devices_from_beacon(db, beacon_id, body.device_ids)
|
||||||
|
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||||
|
|||||||
@@ -8,11 +8,13 @@ from datetime import datetime
|
|||||||
from typing import Literal
|
from typing import Literal
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import func, select
|
from sqlalchemy import func, select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import BluetoothRecord
|
from app.models import BluetoothRecord
|
||||||
|
from app.services.export_utils import build_csv_content, csv_filename
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
BluetoothRecordResponse,
|
BluetoothRecordResponse,
|
||||||
@@ -86,6 +88,46 @@ 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(
|
@router.get(
|
||||||
"/stats",
|
"/stats",
|
||||||
response_model=APIResponse[dict],
|
response_model=APIResponse[dict],
|
||||||
|
|||||||
210
app/routers/device_groups.py
Normal file
210
app/routers/device_groups.py
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
"""
|
||||||
|
Device Groups Router - 设备分组管理接口
|
||||||
|
API endpoints for device group CRUD and membership management.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
from sqlalchemy import func, select, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import require_write
|
||||||
|
from app.models import Device, DeviceGroup, DeviceGroupMember
|
||||||
|
from app.schemas import (
|
||||||
|
APIResponse,
|
||||||
|
DeviceGroupCreate,
|
||||||
|
DeviceGroupResponse,
|
||||||
|
DeviceGroupUpdate,
|
||||||
|
DeviceGroupWithCount,
|
||||||
|
DeviceResponse,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/groups", tags=["Device Groups / 设备分组"])
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"",
|
||||||
|
response_model=APIResponse[list[DeviceGroupWithCount]],
|
||||||
|
summary="获取分组列表 / List device groups",
|
||||||
|
)
|
||||||
|
async def list_groups(db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取所有设备分组及各组设备数。"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(
|
||||||
|
DeviceGroup,
|
||||||
|
func.count(DeviceGroupMember.id).label("cnt"),
|
||||||
|
)
|
||||||
|
.outerjoin(DeviceGroupMember, DeviceGroup.id == DeviceGroupMember.group_id)
|
||||||
|
.group_by(DeviceGroup.id)
|
||||||
|
.order_by(DeviceGroup.name)
|
||||||
|
)
|
||||||
|
groups = []
|
||||||
|
for row in result.all():
|
||||||
|
g = DeviceGroupResponse.model_validate(row[0])
|
||||||
|
groups.append(DeviceGroupWithCount(**g.model_dump(), device_count=row[1]))
|
||||||
|
return APIResponse(data=groups)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"",
|
||||||
|
response_model=APIResponse[DeviceGroupResponse],
|
||||||
|
status_code=201,
|
||||||
|
summary="创建分组 / Create group",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def create_group(body: DeviceGroupCreate, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""创建新设备分组。"""
|
||||||
|
existing = await db.execute(
|
||||||
|
select(DeviceGroup).where(DeviceGroup.name == body.name)
|
||||||
|
)
|
||||||
|
if existing.scalar_one_or_none():
|
||||||
|
raise HTTPException(status_code=400, detail=f"分组名 '{body.name}' 已存在")
|
||||||
|
group = DeviceGroup(name=body.name, description=body.description, color=body.color)
|
||||||
|
db.add(group)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(group)
|
||||||
|
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/{group_id}",
|
||||||
|
response_model=APIResponse[DeviceGroupResponse],
|
||||||
|
summary="更新分组 / Update group",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def update_group(
|
||||||
|
group_id: int, body: DeviceGroupUpdate, db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""更新分组信息。"""
|
||||||
|
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for k, v in update_data.items():
|
||||||
|
setattr(group, k, v)
|
||||||
|
await db.flush()
|
||||||
|
await db.refresh(group)
|
||||||
|
return APIResponse(data=DeviceGroupResponse.model_validate(group))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{group_id}",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="删除分组 / Delete group",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""删除分组(级联删除成员关系,不删除设备)。"""
|
||||||
|
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
|
||||||
|
group = result.scalar_one_or_none()
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||||
|
# Clear group_id on devices
|
||||||
|
devices_result = await db.execute(select(Device).where(Device.group_id == group_id))
|
||||||
|
for d in devices_result.scalars().all():
|
||||||
|
d.group_id = None
|
||||||
|
await db.delete(group)
|
||||||
|
await db.flush()
|
||||||
|
return APIResponse(message="分组已删除")
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/{group_id}/devices",
|
||||||
|
response_model=APIResponse[list[DeviceResponse]],
|
||||||
|
summary="获取分组设备 / Get group devices",
|
||||||
|
)
|
||||||
|
async def get_group_devices(group_id: int, db: AsyncSession = Depends(get_db)):
|
||||||
|
"""获取分组内的设备列表。"""
|
||||||
|
result = await db.execute(
|
||||||
|
select(Device)
|
||||||
|
.join(DeviceGroupMember, Device.id == DeviceGroupMember.device_id)
|
||||||
|
.where(DeviceGroupMember.group_id == group_id)
|
||||||
|
.order_by(Device.name)
|
||||||
|
)
|
||||||
|
devices = list(result.scalars().all())
|
||||||
|
return APIResponse(data=[DeviceResponse.model_validate(d) for d in devices])
|
||||||
|
|
||||||
|
|
||||||
|
class GroupMemberRequest(BaseModel):
|
||||||
|
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="设备ID列表")
|
||||||
|
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/{group_id}/devices",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="添加设备到分组 / Add devices to group",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def add_devices_to_group(
|
||||||
|
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""添加设备到分组(幂等,已存在的跳过)。"""
|
||||||
|
# Verify group exists
|
||||||
|
group = (await db.execute(
|
||||||
|
select(DeviceGroup).where(DeviceGroup.id == group_id)
|
||||||
|
)).scalar_one_or_none()
|
||||||
|
if not group:
|
||||||
|
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
|
||||||
|
|
||||||
|
# Get existing members
|
||||||
|
existing = await db.execute(
|
||||||
|
select(DeviceGroupMember.device_id).where(
|
||||||
|
DeviceGroupMember.group_id == group_id,
|
||||||
|
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
existing_ids = {row[0] for row in existing.all()}
|
||||||
|
|
||||||
|
added = 0
|
||||||
|
for did in body.device_ids:
|
||||||
|
if did not in existing_ids:
|
||||||
|
db.add(DeviceGroupMember(device_id=did, group_id=group_id))
|
||||||
|
added += 1
|
||||||
|
# Also update device.group_id
|
||||||
|
if body.device_ids:
|
||||||
|
devices = await db.execute(
|
||||||
|
select(Device).where(Device.id.in_(body.device_ids))
|
||||||
|
)
|
||||||
|
for d in devices.scalars().all():
|
||||||
|
d.group_id = group_id
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return APIResponse(
|
||||||
|
message=f"已添加 {added} 台设备到分组",
|
||||||
|
data={"added": added, "already_in_group": len(existing_ids)},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/{group_id}/devices",
|
||||||
|
response_model=APIResponse[dict],
|
||||||
|
summary="从分组移除设备 / Remove devices from group",
|
||||||
|
dependencies=[Depends(require_write)],
|
||||||
|
)
|
||||||
|
async def remove_devices_from_group(
|
||||||
|
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
|
||||||
|
):
|
||||||
|
"""从分组移除设备。"""
|
||||||
|
result = await db.execute(
|
||||||
|
delete(DeviceGroupMember).where(
|
||||||
|
DeviceGroupMember.group_id == group_id,
|
||||||
|
DeviceGroupMember.device_id.in_(body.device_ids),
|
||||||
|
)
|
||||||
|
)
|
||||||
|
# Clear group_id on removed devices
|
||||||
|
devices = await db.execute(
|
||||||
|
select(Device).where(
|
||||||
|
Device.id.in_(body.device_ids),
|
||||||
|
Device.group_id == group_id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
for d in devices.scalars().all():
|
||||||
|
d.group_id = None
|
||||||
|
|
||||||
|
await db.flush()
|
||||||
|
return APIResponse(
|
||||||
|
message=f"已移除 {result.rowcount} 台设备",
|
||||||
|
data={"removed": result.rowcount},
|
||||||
|
)
|
||||||
@@ -6,9 +6,13 @@ API endpoints for device CRUD operations and statistics.
|
|||||||
import math
|
import math
|
||||||
|
|
||||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||||
|
from fastapi.responses import Response
|
||||||
|
from sqlalchemy import select
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
|
from app.models import Device
|
||||||
|
from app.services.export_utils import build_csv_content, csv_filename
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
BatchDeviceCreateRequest,
|
BatchDeviceCreateRequest,
|
||||||
@@ -74,6 +78,40 @@ async def device_stats(db: AsyncSession = Depends(get_db)):
|
|||||||
return APIResponse(data=stats)
|
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(
|
@router.get(
|
||||||
"/imei/{imei}",
|
"/imei/{imei}",
|
||||||
response_model=APIResponse[DeviceResponse],
|
response_model=APIResponse[DeviceResponse],
|
||||||
|
|||||||
@@ -7,12 +7,14 @@ import math
|
|||||||
from datetime import datetime, timedelta
|
from datetime import datetime, timedelta
|
||||||
|
|
||||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import Response
|
||||||
from sqlalchemy import func, select, delete, case, extract
|
from sqlalchemy import func, select, delete, case, extract
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.dependencies import require_write
|
from app.dependencies import require_write
|
||||||
from app.database import get_db
|
from app.database import get_db
|
||||||
from app.models import LocationRecord
|
from app.models import LocationRecord
|
||||||
|
from app.services.export_utils import build_csv_content, csv_filename
|
||||||
from app.schemas import (
|
from app.schemas import (
|
||||||
APIResponse,
|
APIResponse,
|
||||||
LocationRecordResponse,
|
LocationRecordResponse,
|
||||||
@@ -129,6 +131,93 @@ async def location_stats(
|
|||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
@router.get(
|
||||||
"/track-summary/{device_id}",
|
"/track-summary/{device_id}",
|
||||||
response_model=APIResponse[dict],
|
response_model=APIResponse[dict],
|
||||||
@@ -352,6 +441,43 @@ async def delete_no_coord_locations(
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@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(
|
@router.get(
|
||||||
"/{location_id}",
|
"/{location_id}",
|
||||||
response_model=APIResponse[LocationRecordResponse],
|
response_model=APIResponse[LocationRecordResponse],
|
||||||
|
|||||||
299
app/routers/system.py
Normal file
299
app/routers/system.py
Normal file
@@ -0,0 +1,299 @@
|
|||||||
|
"""
|
||||||
|
System Management Router - 系统管理接口
|
||||||
|
Audit logs, runtime config, database backup, firmware info.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import shutil
|
||||||
|
import time
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, Query, Request
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
from sqlalchemy import select, func, delete
|
||||||
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
|
from app.config import settings, now_cst
|
||||||
|
from app.database import get_db
|
||||||
|
from app.dependencies import require_admin
|
||||||
|
from app.models import AuditLog, Device
|
||||||
|
from app.schemas import (
|
||||||
|
APIResponse,
|
||||||
|
AuditLogResponse,
|
||||||
|
PaginatedList,
|
||||||
|
SystemConfigResponse,
|
||||||
|
SystemConfigUpdate,
|
||||||
|
)
|
||||||
|
|
||||||
|
router = APIRouter(prefix="/api/system", tags=["System / 系统管理"])
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Audit Logs
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/audit-logs",
|
||||||
|
response_model=APIResponse[PaginatedList[AuditLogResponse]],
|
||||||
|
summary="获取审计日志 / List audit logs",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def list_audit_logs(
|
||||||
|
page: int = Query(1, ge=1),
|
||||||
|
page_size: int = Query(20, ge=1, le=100),
|
||||||
|
method: str | None = Query(None, description="Filter by HTTP method (POST/PUT/DELETE)"),
|
||||||
|
path_contains: str | None = Query(None, description="Filter by path keyword"),
|
||||||
|
operator: str | None = Query(None, description="Filter by operator name"),
|
||||||
|
start_time: datetime | None = None,
|
||||||
|
end_time: datetime | None = None,
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""查询操作审计日志 (admin only)。"""
|
||||||
|
query = select(AuditLog)
|
||||||
|
count_query = select(func.count(AuditLog.id))
|
||||||
|
|
||||||
|
if method:
|
||||||
|
query = query.where(AuditLog.method == method.upper())
|
||||||
|
count_query = count_query.where(AuditLog.method == method.upper())
|
||||||
|
if path_contains:
|
||||||
|
query = query.where(AuditLog.path.contains(path_contains))
|
||||||
|
count_query = count_query.where(AuditLog.path.contains(path_contains))
|
||||||
|
if operator:
|
||||||
|
query = query.where(AuditLog.operator == operator)
|
||||||
|
count_query = count_query.where(AuditLog.operator == operator)
|
||||||
|
if start_time:
|
||||||
|
query = query.where(AuditLog.created_at >= start_time)
|
||||||
|
count_query = count_query.where(AuditLog.created_at >= start_time)
|
||||||
|
if end_time:
|
||||||
|
query = query.where(AuditLog.created_at <= end_time)
|
||||||
|
count_query = count_query.where(AuditLog.created_at <= end_time)
|
||||||
|
|
||||||
|
total = (await db.execute(count_query)).scalar() or 0
|
||||||
|
total_pages = (total + page_size - 1) // page_size
|
||||||
|
|
||||||
|
rows = await db.execute(
|
||||||
|
query.order_by(AuditLog.id.desc())
|
||||||
|
.offset((page - 1) * page_size)
|
||||||
|
.limit(page_size)
|
||||||
|
)
|
||||||
|
items = [AuditLogResponse.model_validate(r) for r in rows.scalars().all()]
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
data=PaginatedList(
|
||||||
|
items=items, total=total, page=page,
|
||||||
|
page_size=page_size, total_pages=total_pages,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/audit-logs",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="清理审计日志 / Clean audit logs",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def clean_audit_logs(
|
||||||
|
days: int = Query(..., ge=1, le=3650, description="Delete logs older than N days"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""删除N天前的审计日志 (admin only)。"""
|
||||||
|
cutoff = now_cst() - timedelta(days=days)
|
||||||
|
result = await db.execute(
|
||||||
|
delete(AuditLog).where(AuditLog.created_at < cutoff)
|
||||||
|
)
|
||||||
|
return APIResponse(
|
||||||
|
message=f"已删除 {result.rowcount} 条 {days} 天前的审计日志",
|
||||||
|
data={"deleted": result.rowcount},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# System Config
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/config",
|
||||||
|
response_model=APIResponse[SystemConfigResponse],
|
||||||
|
summary="获取运行时配置 / Get runtime config",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def get_config():
|
||||||
|
"""获取当前运行时配置参数 (admin only)。"""
|
||||||
|
return APIResponse(data=SystemConfigResponse(
|
||||||
|
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||||
|
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||||
|
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||||
|
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||||
|
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||||
|
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||||
|
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||||
|
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||||
|
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||||
|
track_max_points=settings.TRACK_MAX_POINTS,
|
||||||
|
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||||
|
))
|
||||||
|
|
||||||
|
|
||||||
|
@router.put(
|
||||||
|
"/config",
|
||||||
|
response_model=APIResponse[SystemConfigResponse],
|
||||||
|
summary="更新运行时配置 / Update runtime config",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def update_config(body: SystemConfigUpdate):
|
||||||
|
"""更新运行时配置参数 (admin only)。仅影响当前进程,重启后恢复 .env 值。"""
|
||||||
|
update_data = body.model_dump(exclude_unset=True)
|
||||||
|
for key, value in update_data.items():
|
||||||
|
attr_name = key.upper()
|
||||||
|
if hasattr(settings, attr_name):
|
||||||
|
object.__setattr__(settings, attr_name, value)
|
||||||
|
|
||||||
|
return APIResponse(
|
||||||
|
message=f"已更新 {len(update_data)} 项配置 (进程级,重启后恢复)",
|
||||||
|
data=SystemConfigResponse(
|
||||||
|
data_retention_days=settings.DATA_RETENTION_DAYS,
|
||||||
|
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
|
||||||
|
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
|
||||||
|
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
|
||||||
|
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
|
||||||
|
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
|
||||||
|
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
|
||||||
|
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
|
||||||
|
rate_limit_write=settings.RATE_LIMIT_WRITE,
|
||||||
|
track_max_points=settings.TRACK_MAX_POINTS,
|
||||||
|
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Database Backup
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.post(
|
||||||
|
"/backup",
|
||||||
|
summary="创建数据库备份 / Create database backup",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def create_backup():
|
||||||
|
"""
|
||||||
|
触发 SQLite 数据库备份,返回备份文件下载 (admin only)。
|
||||||
|
使用 SQLite 原生 backup API 确保一致性。
|
||||||
|
"""
|
||||||
|
import aiosqlite
|
||||||
|
|
||||||
|
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||||
|
if not Path(db_path).exists():
|
||||||
|
return APIResponse(code=500, message="Database file not found")
|
||||||
|
|
||||||
|
backup_dir = Path(db_path).parent / "backups"
|
||||||
|
backup_dir.mkdir(exist_ok=True)
|
||||||
|
|
||||||
|
timestamp = now_cst().strftime("%Y%m%d_%H%M%S")
|
||||||
|
backup_name = f"badge_admin_backup_{timestamp}.db"
|
||||||
|
backup_path = backup_dir / backup_name
|
||||||
|
|
||||||
|
# Use SQLite online backup API for consistency
|
||||||
|
async with aiosqlite.connect(db_path) as source:
|
||||||
|
async with aiosqlite.connect(str(backup_path)) as dest:
|
||||||
|
await source.backup(dest)
|
||||||
|
|
||||||
|
size_mb = round(backup_path.stat().st_size / 1024 / 1024, 2)
|
||||||
|
|
||||||
|
return FileResponse(
|
||||||
|
path=str(backup_path),
|
||||||
|
filename=backup_name,
|
||||||
|
media_type="application/octet-stream",
|
||||||
|
headers={
|
||||||
|
"X-Backup-Size-MB": str(size_mb),
|
||||||
|
"X-Backup-Timestamp": timestamp,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/backups",
|
||||||
|
response_model=APIResponse[list[dict]],
|
||||||
|
summary="列出数据库备份 / List backups",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def list_backups():
|
||||||
|
"""列出所有已有的数据库备份文件 (admin only)。"""
|
||||||
|
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||||
|
backup_dir = Path(db_path).parent / "backups"
|
||||||
|
|
||||||
|
if not backup_dir.exists():
|
||||||
|
return APIResponse(data=[])
|
||||||
|
|
||||||
|
backups = []
|
||||||
|
for f in sorted(backup_dir.glob("*.db"), reverse=True):
|
||||||
|
backups.append({
|
||||||
|
"filename": f.name,
|
||||||
|
"size_mb": round(f.stat().st_size / 1024 / 1024, 2),
|
||||||
|
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
|
||||||
|
})
|
||||||
|
return APIResponse(data=backups)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete(
|
||||||
|
"/backups/{filename}",
|
||||||
|
response_model=APIResponse,
|
||||||
|
summary="删除数据库备份 / Delete backup",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def delete_backup(filename: str):
|
||||||
|
"""删除指定的数据库备份文件 (admin only)。"""
|
||||||
|
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
|
||||||
|
backup_path = Path(db_path).parent / "backups" / filename
|
||||||
|
|
||||||
|
if not backup_path.exists() or not backup_path.suffix == ".db":
|
||||||
|
return APIResponse(code=404, message="Backup not found")
|
||||||
|
|
||||||
|
# Prevent path traversal
|
||||||
|
if ".." in filename or "/" in filename:
|
||||||
|
return APIResponse(code=400, message="Invalid filename")
|
||||||
|
|
||||||
|
backup_path.unlink()
|
||||||
|
return APIResponse(message=f"已删除备份 {filename}")
|
||||||
|
|
||||||
|
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
# Device Firmware Info
|
||||||
|
# ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get(
|
||||||
|
"/firmware",
|
||||||
|
response_model=APIResponse[list[dict]],
|
||||||
|
summary="设备固件信息 / Device firmware info",
|
||||||
|
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
|
||||||
|
)
|
||||||
|
async def list_firmware_info(
|
||||||
|
status: str | None = Query(None, description="Filter by device status"),
|
||||||
|
db: AsyncSession = Depends(get_db),
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
获取所有设备的固件相关信息 (型号、ICCID、IMSI等)。
|
||||||
|
实际固件版本需通过 VERSION# 指令查询 (admin only)。
|
||||||
|
"""
|
||||||
|
query = select(Device).order_by(Device.id)
|
||||||
|
if status:
|
||||||
|
query = query.where(Device.status == status)
|
||||||
|
|
||||||
|
result = await db.execute(query)
|
||||||
|
devices = result.scalars().all()
|
||||||
|
|
||||||
|
firmware_list = []
|
||||||
|
for d in devices:
|
||||||
|
firmware_list.append({
|
||||||
|
"device_id": d.id,
|
||||||
|
"imei": d.imei,
|
||||||
|
"name": d.name,
|
||||||
|
"device_type": d.device_type,
|
||||||
|
"status": d.status,
|
||||||
|
"iccid": d.iccid,
|
||||||
|
"imsi": d.imsi,
|
||||||
|
"last_login": d.last_login.isoformat() if d.last_login else None,
|
||||||
|
"last_heartbeat": d.last_heartbeat.isoformat() if d.last_heartbeat else None,
|
||||||
|
})
|
||||||
|
|
||||||
|
return APIResponse(data=firmware_list)
|
||||||
145
app/schemas.py
145
app/schemas.py
@@ -59,6 +59,7 @@ class DeviceUpdate(BaseModel):
|
|||||||
imsi: str | None = Field(None, max_length=20)
|
imsi: str | None = Field(None, max_length=20)
|
||||||
timezone: str | None = Field(None, max_length=30)
|
timezone: str | None = Field(None, max_length=30)
|
||||||
language: str | None = Field(None, max_length=10)
|
language: str | None = Field(None, max_length=10)
|
||||||
|
group_id: int | None = None
|
||||||
|
|
||||||
|
|
||||||
class DeviceResponse(DeviceBase):
|
class DeviceResponse(DeviceBase):
|
||||||
@@ -72,6 +73,7 @@ class DeviceResponse(DeviceBase):
|
|||||||
last_login: datetime | None = None
|
last_login: datetime | None = None
|
||||||
iccid: str | None = None
|
iccid: str | None = None
|
||||||
imsi: str | None = None
|
imsi: str | None = None
|
||||||
|
group_id: int | None = None
|
||||||
created_at: datetime
|
created_at: datetime
|
||||||
updated_at: datetime | None = None
|
updated_at: datetime | None = None
|
||||||
|
|
||||||
@@ -374,6 +376,23 @@ class BeaconConfigResponse(BaseModel):
|
|||||||
updated_at: datetime | None = None
|
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
|
# Fence Config schemas
|
||||||
# ---------------------------------------------------------------------------
|
# ---------------------------------------------------------------------------
|
||||||
@@ -588,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):
|
class ApiKeyCreate(BaseModel):
|
||||||
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
|
name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称")
|
||||||
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
|
permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level")
|
||||||
|
|||||||
@@ -3,13 +3,19 @@ Beacon Service - 蓝牙信标管理服务
|
|||||||
Provides CRUD operations for Bluetooth beacon configuration.
|
Provides CRUD operations for Bluetooth beacon configuration.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
import asyncio
|
||||||
|
import logging
|
||||||
|
import re
|
||||||
from datetime import datetime, timezone
|
from datetime import datetime, timezone
|
||||||
|
|
||||||
from sqlalchemy import func, select, or_
|
from sqlalchemy import delete as sa_delete, func, select, or_
|
||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
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.schemas import BeaconConfigCreate, BeaconConfigUpdate
|
||||||
|
from app.services import tcp_command_service
|
||||||
|
|
||||||
|
logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
|
||||||
async def get_beacons(
|
async def get_beacons(
|
||||||
@@ -93,3 +99,455 @@ async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool:
|
|||||||
await db.delete(beacon)
|
await db.delete(beacon)
|
||||||
await db.flush()
|
await db.flush()
|
||||||
return True
|
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,
|
||||||
|
}
|
||||||
|
|||||||
59
app/services/export_utils.py
Normal file
59
app/services/export_utils.py
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
"""
|
||||||
|
CSV Export Utilities - CSV 数据导出工具
|
||||||
|
Shared helpers for streaming CSV responses.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
from collections.abc import AsyncIterator, Sequence
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
|
||||||
|
def _format_value(value: Any) -> str:
|
||||||
|
"""Format a single value for CSV output."""
|
||||||
|
if value is None:
|
||||||
|
return ""
|
||||||
|
if isinstance(value, datetime):
|
||||||
|
return value.strftime("%Y-%m-%d %H:%M:%S")
|
||||||
|
if isinstance(value, bool):
|
||||||
|
return "是" if value else "否"
|
||||||
|
if isinstance(value, float):
|
||||||
|
return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}"
|
||||||
|
if isinstance(value, (dict, list)):
|
||||||
|
import json
|
||||||
|
return json.dumps(value, ensure_ascii=False)
|
||||||
|
return str(value)
|
||||||
|
|
||||||
|
|
||||||
|
def build_csv_content(
|
||||||
|
headers: list[str],
|
||||||
|
rows: Sequence[Any],
|
||||||
|
field_extractors: list[Any],
|
||||||
|
) -> str:
|
||||||
|
"""Build complete CSV string with BOM for Excel compatibility.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
headers: Column header names (Chinese).
|
||||||
|
rows: ORM model instances or row tuples.
|
||||||
|
field_extractors: List of callables or attribute name strings.
|
||||||
|
"""
|
||||||
|
buf = io.StringIO()
|
||||||
|
buf.write("\ufeff") # UTF-8 BOM for Excel
|
||||||
|
writer = csv.writer(buf)
|
||||||
|
writer.writerow(headers)
|
||||||
|
for row in rows:
|
||||||
|
values = []
|
||||||
|
for ext in field_extractors:
|
||||||
|
if callable(ext):
|
||||||
|
values.append(_format_value(ext(row)))
|
||||||
|
else:
|
||||||
|
values.append(_format_value(getattr(row, ext, "")))
|
||||||
|
writer.writerow(values)
|
||||||
|
return buf.getvalue()
|
||||||
|
|
||||||
|
|
||||||
|
def csv_filename(prefix: str) -> str:
|
||||||
|
"""Generate a timestamped CSV filename."""
|
||||||
|
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
|
||||||
|
return f"{prefix}_{ts}.csv"
|
||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user