From 9cd9dd9d764260e88be51ae151f92582127e00a9 Mon Sep 17 00:00:00 2001 From: default Date: Wed, 1 Apr 2026 07:06:37 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E4=BF=A1=E6=A0=87=E8=AE=BE=E5=A4=87?= =?UTF-8?q?=E7=BB=91=E5=AE=9A=20+=20=E8=93=9D=E7=89=99=E6=A8=A1=E5=BC=8F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=20+=20=E7=B3=BB=E7=BB=9F=E7=AE=A1=E7=90=86?= =?UTF-8?q?=E5=A2=9E=E5=BC=BA=20+=20=E6=95=B0=E6=8D=AE=E5=AF=BC=E5=87=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 新增 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 --- .claude/CLAUDE.md | 126 ++- app/database.py | 1 + app/dependencies.py | 8 +- app/main.py | 9 +- app/middleware.py | 101 +++ app/models.py | 110 +++ app/routers/alarms.py | 115 ++- app/routers/alert_rules.py | 109 +++ app/routers/attendance.py | 107 +++ app/routers/beacons.py | 121 +++ app/routers/bluetooth.py | 42 + app/routers/device_groups.py | 210 +++++ app/routers/devices.py | 38 + app/routers/locations.py | 126 +++ app/routers/system.py | 299 ++++++++ app/schemas.py | 145 ++++ app/services/beacon_service.py | 462 ++++++++++- app/services/export_utils.py | 59 ++ app/static/admin.html | 1315 ++++++++++++++++++++++++++++++-- 19 files changed, 3403 insertions(+), 100 deletions(-) create mode 100644 app/middleware.py create mode 100644 app/routers/alert_rules.py create mode 100644 app/routers/device_groups.py create mode 100644 app/routers/system.py create mode 100644 app/services/export_utils.py diff --git a/.claude/CLAUDE.md b/.claude/CLAUDE.md index af60057..930a500 100644 --- a/.claude/CLAUDE.md +++ b/.claude/CLAUDE.md @@ -18,6 +18,7 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ ├── app/ │ ├── main.py # FastAPI 应用入口, 挂载静态文件, 启动TCP服务器, /api/system/overview, /api/system/cleanup +│ ├── middleware.py # AuditMiddleware — 自动记录 POST/PUT/DELETE 操作到 audit_logs 表 │ ├── config.py # 配置 (pydantic-settings, .env支持, 端口/API密钥/缓存/限流) │ ├── database.py # SQLAlchemy async 数据库连接 │ ├── dependencies.py # FastAPI 依赖 (多API Key认证 + 权限控制: read/write/admin) @@ -38,10 +39,11 @@ KKS P240/P241 蓝牙工牌管理后台,基于 FastAPI + SQLAlchemy + asyncio T │ │ ├── device_service.py # 设备 CRUD │ │ ├── command_service.py # 指令日志 CRUD │ │ ├── location_service.py # 位置记录查询 -│ │ ├── beacon_service.py # 蓝牙信标 CRUD +│ │ ├── beacon_service.py # 蓝牙信标 CRUD + 设备绑定 + 蓝牙模式配置/恢复 + 反向同步 │ │ ├── fence_service.py # 电子围栏 CRUD + 设备绑定管理 │ │ ├── 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/ │ │ ├── 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过滤) │ │ ├── bluetooth.py # /api/bluetooth (含 /stats, beacon_mac过滤, /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) │ │ ├── geocoding.py # /api/geocode (POI搜索代理 /search, 逆地理编码 /reverse) │ │ ├── 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/ │ └── admin.html # 管理后台 SPA (暗色主题, 10个页面) @@ -367,6 +372,7 @@ KKS 二进制协议,详见 `doc/KKS_Protocol_P240_P241.md` ### 蓝牙信标管理 - **BeaconConfig 表**: 注册蓝牙信标,配置 MAC/UUID/Major/Minor/楼层/区域/经纬度/地址 +- **DeviceBeaconBinding 表**: 设备-信标多对多绑定 (device_id + beacon_id 唯一约束) - **自动关联**: 0xB2 打卡和 0xB3 定位时,根据 beacon_mac 查询 beacon_configs 表 - **位置写入**: 将已注册信标的经纬度写入 BluetoothRecord 的 latitude/longitude - **多信标定位**: 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 - **注意**: 信标需配置经纬度/地址,否则打卡记录中位置为空 - **制造商**: 几米物联 (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 蓝牙模式配置指南 (设备端) 设备蓝牙模式通过在线指令 (0x80) 配置,需依次完成以下步骤: @@ -824,40 +837,59 @@ remotePort = 5001 194. **总览低精度过滤** - 独立的低精度按钮,切换时自动重新加载轨迹(跳过LBS点重连折线) 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 轮询,报警页实时通知弹窗 21. **考勤报表页面** - 前端新增考勤报表 Tab,调用 `GET /api/attendance/report` 展示每设备每天签到汇总 22. **心跳统计页面** - 数据日志页面心跳 Tab 增加统计面板,调用 `GET /api/heartbeats/stats` 展示设备活跃/异常/电量分布 23. **位置统计页面** - 位置追踪页面增加统计面板,调用 `GET /api/locations/stats` 展示定位类型分布/小时趋势 -24. **导出按钮集成** - 各数据页面工具栏添加"导出 CSV"按钮 (需先完成第二批导出 API) +24. ~~**导出按钮集成**~~ - ✅ 已完成 (随第二批导出 API 一起实现) -### 第三优先级 — 性能与架构 +### 第二优先级 — 性能与架构 25. **性能优化第三批** - 迁移 PostgreSQL、多worker部署 (几千台设备时) 26. **API 版本化** - 添加 `/api/v1/` 前缀,为将来 v2 预留兼容空间 @@ -866,6 +898,40 @@ remotePort = 5001 27. **心跳扩展模块解析** - 计步器、外部电压等模块未解析 (已存原始 hex,按需解析) 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) - ✅ `GET /api/system/overview` — 系统总览 (设备在线率/今日统计/表记录数/DB大小) - ✅ `GET /api/devices/stats` 增强 — 新增 by_type, battery_distribution, signal_distribution, online_rate diff --git a/app/database.py b/app/database.py index 8923aaa..37c7271 100644 --- a/app/database.py +++ b/app/database.py @@ -51,6 +51,7 @@ async def init_db() -> None: from app.models import ( # noqa: F401 AlarmRecord, AttendanceRecord, + AuditLog, BluetoothRecord, CommandLog, Device, diff --git a/app/dependencies.py b/app/dependencies.py index 858a348..b74d1c8 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -8,7 +8,7 @@ import hashlib import secrets import time -from fastapi import Depends, HTTPException, Security +from fastapi import Depends, HTTPException, Request, Security from fastapi.security import APIKeyHeader from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession @@ -32,6 +32,7 @@ def _hash_key(key: str) -> str: async def verify_api_key( + request: Request, api_key: str | None = Security(_api_key_header), db: AsyncSession = Depends(get_db), ) -> dict | None: @@ -48,7 +49,9 @@ async def verify_api_key( # Check master key if secrets.compare_digest(api_key, settings.API_KEY): - return {"permissions": "admin", "key_id": None, "name": "master"} + info = {"permissions": "admin", "key_id": None, "name": "master"} + request.state.key_info = info + return info # Check in-memory cache first key_hash = _hash_key(api_key) @@ -77,6 +80,7 @@ async def verify_api_key( key_info = {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} _AUTH_CACHE[key_hash] = (key_info, now + _AUTH_CACHE_TTL) + request.state.key_info = key_info return key_info diff --git a/app/main.py b/app/main.py index 90fe65a..2fe25d2 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded from app.database import init_db, async_session, engine from app.tcp_server import tcp_manager from app.config import settings -from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, 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 import asyncio @@ -159,6 +159,10 @@ app.add_middleware( allow_headers=["*"], ) +# Audit logging middleware (records POST/PUT/DELETE to audit_logs table) +from app.middleware import AuditMiddleware +app.add_middleware(AuditMiddleware) + # Global exception handler — prevent stack trace leaks @app.exception_handler(Exception) @@ -190,6 +194,9 @@ app.include_router(heartbeats.router, dependencies=[*_api_deps]) app.include_router(api_keys.router, dependencies=[*_api_deps]) app.include_router(ws.router) # WebSocket handles auth internally app.include_router(geocoding.router, dependencies=[*_api_deps]) +app.include_router(device_groups.router, dependencies=[*_api_deps]) +app.include_router(alert_rules.router, dependencies=[*_api_deps]) +app.include_router(system.router, dependencies=[*_api_deps]) _STATIC_DIR = Path(__file__).parent / "static" app.mount("/static", StaticFiles(directory=str(_STATIC_DIR)), name="static") diff --git a/app/middleware.py b/app/middleware.py new file mode 100644 index 0000000..8cf60f3 --- /dev/null +++ b/app/middleware.py @@ -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 diff --git a/app/models.py b/app/models.py index e01f3e8..98b55ca 100644 --- a/app/models.py +++ b/app/models.py @@ -36,6 +36,7 @@ class Device(Base): imsi: Mapped[str | None] = mapped_column(String(20), nullable=True) timezone: Mapped[str] = mapped_column(String(30), default="+8", nullable=False) language: Mapped[str] = mapped_column(String(10), default="cn", nullable=False) + group_id: Mapped[int | None] = mapped_column(Integer, nullable=True) created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) updated_at: Mapped[datetime | None] = mapped_column( DateTime, default=_utcnow, onupdate=_utcnow, nullable=True @@ -297,6 +298,27 @@ class BeaconConfig(Base): return f"" +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"" + + class FenceConfig(Base): """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"" + + +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"" + + +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"" + + +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"" + + class ApiKey(Base): """API keys for external system authentication.""" diff --git a/app/routers/alarms.py b/app/routers/alarms.py index 573b699..0ac11e6 100644 --- a/app/routers/alarms.py +++ b/app/routers/alarms.py @@ -8,6 +8,7 @@ from datetime import datetime, timedelta, timezone from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response from pydantic import BaseModel, Field from sqlalchemy import func, select, case, extract from sqlalchemy.ext.asyncio import AsyncSession @@ -16,6 +17,7 @@ from app.dependencies import require_write from app.database import get_db from app.models import AlarmRecord +from app.services.export_utils import build_csv_content, csv_filename from app.schemas import ( AlarmAcknowledge, AlarmRecordResponse, @@ -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( "/stats", 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( "/batch-delete", response_model=APIResponse[dict], @@ -217,23 +271,56 @@ async def batch_acknowledge_alarms( dependencies=[Depends(require_write)], ) async def batch_delete_alarms( - body: dict, + body: BatchDeleteAlarmRequest, db: AsyncSession = Depends(get_db), ): - """批量删除告警记录,最多500条。 / Batch delete alarm records (max 500).""" - alarm_ids = body.get("alarm_ids", []) - if not alarm_ids: - raise HTTPException(status_code=400, detail="alarm_ids is required") - if len(alarm_ids) > 500: - raise HTTPException(status_code=400, detail="Max 500 records per request") - result = await db.execute( - select(AlarmRecord).where(AlarmRecord.id.in_(alarm_ids)) + """ + 批量删除告警记录。两种模式: + 1. 按ID删除: 传 alarm_ids (最多500条) + 2. 按条件删除: 传 device_id/alarm_type/acknowledged/start_time/end_time 组合 + """ + from sqlalchemy import delete as sql_delete + + if body.alarm_ids: + # Mode 1: by IDs + result = await db.execute( + sql_delete(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids)) + ) + await db.flush() + return APIResponse( + message=f"已删除 {result.rowcount} 条告警", + data={"deleted": result.rowcount, "requested": len(body.alarm_ids)}, + ) + + # Mode 2: by filters (at least one filter required) + conditions = [] + if body.device_id is not None: + conditions.append(AlarmRecord.device_id == body.device_id) + if body.alarm_type: + conditions.append(AlarmRecord.alarm_type == body.alarm_type) + if body.acknowledged is not None: + conditions.append(AlarmRecord.acknowledged == body.acknowledged) + if body.start_time: + conditions.append(AlarmRecord.recorded_at >= body.start_time) + if body.end_time: + conditions.append(AlarmRecord.recorded_at <= body.end_time) + + if not conditions: + raise HTTPException(status_code=400, detail="需提供 alarm_ids 或至少一个筛选条件") + + # Count first + count = (await db.execute( + select(func.count(AlarmRecord.id)).where(*conditions) + )).scalar() or 0 + + if count > 0: + await db.execute(sql_delete(AlarmRecord).where(*conditions)) + await db.flush() + + return APIResponse( + message=f"已删除 {count} 条告警", + data={"deleted": count}, ) - records = list(result.scalars().all()) - for r in records: - await db.delete(r) - await db.flush() - return APIResponse(data={"deleted": len(records)}) @router.get( diff --git a/app/routers/alert_rules.py b/app/routers/alert_rules.py new file mode 100644 index 0000000..e492cf0 --- /dev/null +++ b/app/routers/alert_rules.py @@ -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="规则已删除") diff --git a/app/routers/attendance.py b/app/routers/attendance.py index f2c73b3..86c02e1 100644 --- a/app/routers/attendance.py +++ b/app/routers/attendance.py @@ -7,11 +7,13 @@ import math from datetime import datetime, timedelta from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response from sqlalchemy import func, select, case from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import AttendanceRecord +from app.services.export_utils import build_csv_content, csv_filename from app.schemas import ( APIResponse, AttendanceRecordResponse, @@ -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( "/stats", 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( "/device/{device_id}", response_model=APIResponse[PaginatedList[AttendanceRecordResponse]], diff --git a/app/routers/beacons.py b/app/routers/beacons.py index f21754f..06a329a 100644 --- a/app/routers/beacons.py +++ b/app/routers/beacons.py @@ -16,6 +16,8 @@ from app.schemas import ( BeaconConfigCreate, BeaconConfigResponse, BeaconConfigUpdate, + BeaconDeviceDetail, + DeviceBeaconBindRequest, PaginatedList, ) from app.services import beacon_service @@ -49,6 +51,76 @@ async def list_beacons( ) +@router.post( + "/setup-bluetooth-mode", + response_model=APIResponse, + summary="批量配置蓝牙打卡模式 / Setup BT clock-in mode for devices", + dependencies=[Depends(require_write)], +) +async def setup_bluetooth_mode( + device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"), + db: AsyncSession = Depends(get_db), +): + result = await beacon_service.setup_bluetooth_mode(db, device_ids) + if result["error"]: + return APIResponse(code=1, message=result["error"], data=result) + return APIResponse( + message=f"共 {result['total']} 台: {result['sent']} 台已配置, {result['failed']} 台失败", + data=result, + ) + + +@router.post( + "/restore-normal-mode", + response_model=APIResponse, + summary="恢复正常模式 / Restore devices to normal (smart) mode", + dependencies=[Depends(require_write)], +) +async def restore_normal_mode( + device_ids: list[int] | None = Query(default=None, description="指定设备ID,不传则所有在线设备"), + db: AsyncSession = Depends(get_db), +): + result = await beacon_service.restore_normal_mode(db, device_ids) + if result["error"]: + return APIResponse(code=1, message=result["error"], data=result) + return APIResponse( + message=f"共 {result['total']} 台: {result['sent']} 台已恢复, {result['failed']} 台失败", + data=result, + ) + + +@router.post( + "/reverse-sync", + response_model=APIResponse, + summary="从设备反向同步信标配置 / Query devices and update DB bindings", + dependencies=[Depends(require_write)], +) +async def reverse_sync_beacons(db: AsyncSession = Depends(get_db)): + result = await beacon_service.reverse_sync_from_devices(db) + if result["error"]: + return APIResponse(code=1, message=result["error"], data=result) + return APIResponse( + message=f"查询 {result['queried']} 台设备,{result['responded']} 台响应,{result['updated']} 台有变更", + data=result, + ) + + +@router.post( + "/sync-device/{device_id}", + response_model=APIResponse, + summary="同步信标配置到设备 / Sync beacon MACs to device via BTMACSET", + dependencies=[Depends(require_write)], +) +async def sync_device_beacons(device_id: int, db: AsyncSession = Depends(get_db)): + result = await beacon_service.sync_device_beacons(db, device_id) + if result["error"]: + return APIResponse(code=1, message=result["error"], data=result) + return APIResponse( + message=f"已发送 {len(result['commands'])} 条指令,共 {result['mac_count']} 个信标MAC", + data=result, + ) + + @router.get( "/{beacon_id}", response_model=APIResponse[BeaconConfigResponse], @@ -102,3 +174,52 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)): if not success: raise HTTPException(status_code=404, detail="Beacon not found") return APIResponse(message="Beacon deleted") + + +# --------------------------------------------------------------------------- +# Device-Beacon Binding endpoints +# --------------------------------------------------------------------------- + + +@router.get( + "/{beacon_id}/devices", + response_model=APIResponse[list[BeaconDeviceDetail]], + summary="获取信标绑定的设备 / Get beacon devices", +) +async def get_beacon_devices(beacon_id: int, db: AsyncSession = Depends(get_db)): + beacon = await beacon_service.get_beacon(db, beacon_id) + if beacon is None: + raise HTTPException(status_code=404, detail="Beacon not found") + items = await beacon_service.get_beacon_devices(db, beacon_id) + return APIResponse(data=[BeaconDeviceDetail(**item) for item in items]) + + +@router.post( + "/{beacon_id}/devices", + response_model=APIResponse, + summary="绑定设备到信标 / Bind devices to beacon", + dependencies=[Depends(require_write)], +) +async def bind_devices( + beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db), +): + result = await beacon_service.bind_devices_to_beacon(db, beacon_id, body.device_ids) + if result.get("error"): + raise HTTPException(status_code=404, detail=result["error"]) + return APIResponse( + message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}", + data=result, + ) + + +@router.delete( + "/{beacon_id}/devices", + response_model=APIResponse, + summary="解绑设备 / Unbind devices from beacon", + dependencies=[Depends(require_write)], +) +async def unbind_devices( + beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db), +): + count = await beacon_service.unbind_devices_from_beacon(db, beacon_id, body.device_ids) + return APIResponse(message=f"已解绑 {count} 个设备") diff --git a/app/routers/bluetooth.py b/app/routers/bluetooth.py index e1f5d16..36728d2 100644 --- a/app/routers/bluetooth.py +++ b/app/routers/bluetooth.py @@ -8,11 +8,13 @@ from datetime import datetime from typing import Literal from fastapi import APIRouter, Depends, HTTPException, Query +from fastapi.responses import Response from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db from app.models import BluetoothRecord +from app.services.export_utils import build_csv_content, csv_filename from app.schemas import ( APIResponse, BluetoothRecordResponse, @@ -86,6 +88,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( "/stats", response_model=APIResponse[dict], diff --git a/app/routers/device_groups.py b/app/routers/device_groups.py new file mode 100644 index 0000000..5082e82 --- /dev/null +++ b/app/routers/device_groups.py @@ -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}, + ) diff --git a/app/routers/devices.py b/app/routers/devices.py index 84b8eba..72666cc 100644 --- a/app/routers/devices.py +++ b/app/routers/devices.py @@ -6,9 +6,13 @@ API endpoints for device CRUD operations and statistics. import math from fastapi import APIRouter, Depends, HTTPException, Query, Request +from fastapi.responses import Response +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.database import get_db +from app.models import Device +from app.services.export_utils import build_csv_content, csv_filename from app.schemas import ( APIResponse, BatchDeviceCreateRequest, @@ -74,6 +78,40 @@ async def device_stats(db: AsyncSession = Depends(get_db)): return APIResponse(data=stats) +@router.get( + "/export", + summary="导出设备列表 CSV / Export devices CSV", +) +async def export_devices( + status: str | None = Query(default=None, description="状态过滤 (online/offline)"), + search: str | None = Query(default=None, description="搜索IMEI或名称"), + db: AsyncSession = Depends(get_db), +): + """导出设备列表为 CSV 文件,支持状态/搜索筛选。""" + from sqlalchemy import or_ + + query = select(Device) + if status: + query = query.where(Device.status == status) + if search: + pattern = f"%{search}%" + query = query.where(or_(Device.imei.ilike(pattern), Device.name.ilike(pattern))) + query = query.order_by(Device.id) + + result = await db.execute(query) + devices = list(result.scalars().all()) + + headers = ["ID", "IMEI", "名称", "类型", "状态", "电量%", "信号", "ICCID", "IMSI", "最后心跳", "最后登录", "创建时间"] + fields = ["id", "imei", "name", "device_type", "status", "battery_level", "gsm_signal", "iccid", "imsi", "last_heartbeat", "last_login", "created_at"] + + content = build_csv_content(headers, devices, fields) + return Response( + content=content, + media_type="text/csv; charset=utf-8", + headers={"Content-Disposition": f"attachment; filename={csv_filename('devices')}"}, + ) + + @router.get( "/imei/{imei}", response_model=APIResponse[DeviceResponse], diff --git a/app/routers/locations.py b/app/routers/locations.py index 84bbcd1..4380a86 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -7,12 +7,14 @@ import math from datetime import datetime, timedelta from fastapi import APIRouter, Body, Depends, HTTPException, Query +from fastapi.responses import Response from sqlalchemy import func, select, delete, case, extract from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import require_write from app.database import get_db from app.models import LocationRecord +from app.services.export_utils import build_csv_content, csv_filename from app.schemas import ( APIResponse, LocationRecordResponse, @@ -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( "/track-summary/{device_id}", 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( "/{location_id}", response_model=APIResponse[LocationRecordResponse], diff --git a/app/routers/system.py b/app/routers/system.py new file mode 100644 index 0000000..82bf72d --- /dev/null +++ b/app/routers/system.py @@ -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) diff --git a/app/schemas.py b/app/schemas.py index 7062b49..246eaa1 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -59,6 +59,7 @@ class DeviceUpdate(BaseModel): imsi: str | None = Field(None, max_length=20) timezone: str | None = Field(None, max_length=30) language: str | None = Field(None, max_length=10) + group_id: int | None = None class DeviceResponse(DeviceBase): @@ -72,6 +73,7 @@ class DeviceResponse(DeviceBase): last_login: datetime | None = None iccid: str | None = None imsi: str | None = None + group_id: int | None = None created_at: datetime updated_at: datetime | None = None @@ -374,6 +376,23 @@ class BeaconConfigResponse(BaseModel): updated_at: datetime | None = None +# --------------------------------------------------------------------------- +# Device-Beacon Binding schemas +# --------------------------------------------------------------------------- + + +class DeviceBeaconBindRequest(BaseModel): + device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表") + + +class BeaconDeviceDetail(BaseModel): + """Binding detail with device info.""" + binding_id: int + device_id: int + device_name: str | None = None + imei: str | None = None + + # --------------------------------------------------------------------------- # Fence Config schemas # --------------------------------------------------------------------------- @@ -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): name: str = Field(..., min_length=1, max_length=100, description="Key name / 名称") permissions: Literal["read", "write", "admin"] = Field(default="read", description="Permission level") diff --git a/app/services/beacon_service.py b/app/services/beacon_service.py index 357d0b3..684de60 100644 --- a/app/services/beacon_service.py +++ b/app/services/beacon_service.py @@ -3,13 +3,19 @@ Beacon Service - 蓝牙信标管理服务 Provides CRUD operations for Bluetooth beacon configuration. """ +import asyncio +import logging +import re 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 app.models import BeaconConfig +from app.models import BeaconConfig, CommandLog, Device, DeviceBeaconBinding from app.schemas import BeaconConfigCreate, BeaconConfigUpdate +from app.services import tcp_command_service + +logger = logging.getLogger(__name__) async def get_beacons( @@ -93,3 +99,455 @@ async def delete_beacon(db: AsyncSession, beacon_id: int) -> bool: await db.delete(beacon) await db.flush() return True + + +# --------------------------------------------------------------------------- +# Device-Beacon Binding +# --------------------------------------------------------------------------- + + +async def get_beacon_devices(db: AsyncSession, beacon_id: int) -> list[dict]: + """Get devices bound to a beacon.""" + result = await db.execute( + select( + DeviceBeaconBinding.id.label("binding_id"), + DeviceBeaconBinding.device_id, + Device.name.label("device_name"), + Device.imei, + ) + .join(Device, Device.id == DeviceBeaconBinding.device_id) + .where(DeviceBeaconBinding.beacon_id == beacon_id) + .order_by(Device.name) + ) + return [row._asdict() for row in result.all()] + + +async def bind_devices_to_beacon( + db: AsyncSession, beacon_id: int, device_ids: list[int], +) -> dict: + """Bind multiple devices to a beacon. Idempotent.""" + beacon = await get_beacon(db, beacon_id) + if beacon is None: + return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Beacon not found"} + + result = await db.execute( + select(Device.id).where(Device.id.in_(device_ids)) + ) + existing_device_ids = set(row[0] for row in result.all()) + + result = await db.execute( + select(DeviceBeaconBinding.device_id).where( + DeviceBeaconBinding.beacon_id == beacon_id, + DeviceBeaconBinding.device_id.in_(device_ids), + ) + ) + already_bound_ids = set(row[0] for row in result.all()) + + created = 0 + for did in device_ids: + if did not in existing_device_ids or did in already_bound_ids: + continue + db.add(DeviceBeaconBinding(device_id=did, beacon_id=beacon_id)) + created += 1 + + await db.flush() + return { + "created": created, + "already_bound": len(already_bound_ids & existing_device_ids), + "not_found": len(set(device_ids) - existing_device_ids), + } + + +async def unbind_devices_from_beacon( + db: AsyncSession, beacon_id: int, device_ids: list[int], +) -> int: + """Unbind devices from a beacon.""" + result = await db.execute( + sa_delete(DeviceBeaconBinding).where( + DeviceBeaconBinding.beacon_id == beacon_id, + DeviceBeaconBinding.device_id.in_(device_ids), + ) + ) + await db.flush() + return result.rowcount + + +async def sync_device_beacons(db: AsyncSession, device_id: int) -> dict: + """Query all beacons bound to a device and send BTMACSET commands via TCP. + + BTMACSET supports up to 10 MACs per slot, 5 slots total (default + 1-4). + Returns {"sent": bool, "mac_count": int, "commands": [...], "error": str|None}. + """ + # Get device IMEI + result = await db.execute(select(Device).where(Device.id == device_id)) + device = result.scalar_one_or_none() + if device is None: + return {"sent": False, "mac_count": 0, "commands": [], "error": "设备不存在"} + + # Get all beacons bound to this device + result = await db.execute( + select(BeaconConfig.beacon_mac) + .join(DeviceBeaconBinding, DeviceBeaconBinding.beacon_id == BeaconConfig.id) + .where(DeviceBeaconBinding.device_id == device_id) + .order_by(BeaconConfig.id) + ) + macs = [row[0] for row in result.all()] + + if not tcp_command_service.is_device_online(device.imei): + return {"sent": False, "mac_count": len(macs), "commands": [], "error": "设备离线,无法发送指令"} + + # Build BTMACSET commands: up to 10 MACs per slot + # Slot names: BTMACSET (default), BTMACSET1, BTMACSET2, BTMACSET3, BTMACSET4 + slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"] + commands_sent = [] + + if not macs: + # Clear default slot + cmd = "BTMACSET,#" + await tcp_command_service.send_command(device.imei, "online_cmd", cmd) + commands_sent.append(cmd) + else: + for i in range(0, min(len(macs), 50), 10): + slot_idx = i // 10 + chunk = macs[i:i + 10] + cmd = f"{slot_names[slot_idx]},{','.join(chunk)}#" + await tcp_command_service.send_command(device.imei, "online_cmd", cmd) + commands_sent.append(cmd) + + return {"sent": True, "mac_count": len(macs), "commands": commands_sent, "error": None} + + +# --------------------------------------------------------------------------- +# Reverse sync: query devices → update DB bindings +# --------------------------------------------------------------------------- + +_MAC_PATTERN = re.compile(r"([0-9A-Fa-f]{2}(?::[0-9A-Fa-f]{2}){5})") + + +def _parse_btmacset_response(text: str) -> list[str]: + """Extract MAC addresses from BTMACSET query response. + + Example responses: + 'setting OK.bt mac address:1,C3:00:00:34:43:5E;' + 'bt mac address:1,C3:00:00:34:43:5E,AA:BB:CC:DD:EE:FF;' + """ + return [m.upper() for m in _MAC_PATTERN.findall(text)] + + +async def reverse_sync_from_devices(db: AsyncSession) -> dict: + """Send BTMACSET# query to all online devices, parse responses, update bindings. + + Uses separate DB sessions for command creation and polling to avoid + transaction isolation issues with the TCP handler's independent session. + """ + from app.database import async_session as make_session + from app.services import command_service + from app.config import now_cst + + # Get all online devices + result = await db.execute( + select(Device).where(Device.status == "online") + ) + devices = list(result.scalars().all()) + + if not devices: + return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "没有在线设备"} + + # Build beacon MAC → id lookup + result = await db.execute(select(BeaconConfig.id, BeaconConfig.beacon_mac)) + mac_to_beacon_id = {row[1].upper(): row[0] for row in result.all()} + + # --- Phase 1: Create CommandLogs and send commands (committed session) --- + sent_devices: list[tuple[int, str, str | None, int]] = [] # (dev_id, imei, name, cmd_log_id) + + async with make_session() as cmd_session: + async with cmd_session.begin(): + for dev in devices: + if not tcp_command_service.is_device_online(dev.imei): + continue + cmd_log = await command_service.create_command( + cmd_session, device_id=dev.id, + command_type="online_cmd", command_content="BTMACSET#", + ) + try: + ok = await tcp_command_service.send_command(dev.imei, "online_cmd", "BTMACSET#") + if ok: + cmd_log.status = "sent" + cmd_log.sent_at = now_cst() + sent_devices.append((dev.id, dev.imei, dev.name, cmd_log.id)) + else: + cmd_log.status = "failed" + except Exception: + cmd_log.status = "failed" + # Transaction committed here — TCP handler can now see these CommandLogs + + if not sent_devices: + return {"queried": 0, "responded": 0, "updated": 0, "details": [], "error": "无法发送指令到任何设备"} + + # --- Phase 2: Poll for responses (fresh session each iteration) --- + responded: dict[int, str] = {} + for attempt in range(10): + await asyncio.sleep(1) + pending_ids = [cid for _, _, _, cid in sent_devices if _ not in responded] + # Rebuild pending from device IDs not yet responded + pending_cmd_ids = [cid for did, _, _, cid in sent_devices if did not in responded] + if not pending_cmd_ids: + break + async with make_session() as poll_session: + result = await poll_session.execute( + select(CommandLog.device_id, CommandLog.response_content).where( + CommandLog.id.in_(pending_cmd_ids), + CommandLog.status == "success", + ) + ) + for row in result.all(): + responded[row[0]] = row[1] or "" + + # --- Phase 3: Parse responses and update bindings --- + details = [] + updated_count = 0 + for dev_id, imei, name, cmd_id in sent_devices: + resp_text = responded.get(dev_id) + if resp_text is None: + details.append({"device_id": dev_id, "imei": imei, "name": name, "status": "无响应"}) + continue + + found_macs = _parse_btmacset_response(resp_text) + matched_beacon_ids = set() + for mac in found_macs: + bid = mac_to_beacon_id.get(mac) + if bid: + matched_beacon_ids.add(bid) + + # Get current bindings for this device + result = await db.execute( + select(DeviceBeaconBinding.beacon_id).where( + DeviceBeaconBinding.device_id == dev_id + ) + ) + current_bindings = set(row[0] for row in result.all()) + + to_add = matched_beacon_ids - current_bindings + for bid in to_add: + db.add(DeviceBeaconBinding(device_id=dev_id, beacon_id=bid)) + + to_remove = current_bindings - matched_beacon_ids + if to_remove: + await db.execute( + sa_delete(DeviceBeaconBinding).where( + DeviceBeaconBinding.device_id == dev_id, + DeviceBeaconBinding.beacon_id.in_(to_remove), + ) + ) + + changes = len(to_add) + len(to_remove) + updated_count += 1 if changes else 0 + details.append({ + "device_id": dev_id, "imei": imei, "name": name, + "status": "已同步", + "device_macs": found_macs, + "matched_beacons": len(matched_beacon_ids), + "added": len(to_add), "removed": len(to_remove), + "response": resp_text, + }) + + await db.flush() + return { + "queried": len(sent_devices), + "responded": len(responded), + "updated": updated_count, + "details": details, + "error": None, + } + + +# --------------------------------------------------------------------------- +# Setup Bluetooth clock-in mode for devices +# --------------------------------------------------------------------------- + +# Full config sequence per P241 docs: +# CLOCKWAY,3# → manual + Bluetooth clock +# MODE,2# → Bluetooth positioning mode +# BTMACSET,...# → write bound beacon MACs +# BTMP3SW,1# → enable voice broadcast + +_BT_SETUP_STEPS = [ + ("CLOCKWAY,3#", "设置打卡方式: 手动+蓝牙"), + # MODE,2# inserted dynamically + # BTMACSET,...# inserted dynamically + ("BTMP3SW,1#", "开启语音播报"), +] + + +async def setup_bluetooth_mode( + db: AsyncSession, + device_ids: list[int] | None = None, +) -> dict: + """Configure devices for Bluetooth beacon clock-in mode. + + Sends the full command sequence to each device: + 1. CLOCKWAY,3# (manual + BT clock) + 2. MODE,2# (BT positioning) + 3. BTMACSET,... (bound beacon MACs) + 4. BTMP3SW,1# (voice broadcast on) + + If device_ids is None, targets all online devices. + """ + if device_ids: + result = await db.execute( + select(Device).where(Device.id.in_(device_ids)) + ) + else: + result = await db.execute( + select(Device).where(Device.status == "online") + ) + devices = list(result.scalars().all()) + + if not devices: + return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"} + + # Pre-load all beacon bindings: device_id → [mac1, mac2, ...] + all_device_ids = [d.id for d in devices] + result = await db.execute( + select(DeviceBeaconBinding.device_id, BeaconConfig.beacon_mac) + .join(BeaconConfig, BeaconConfig.id == DeviceBeaconBinding.beacon_id) + .where(DeviceBeaconBinding.device_id.in_(all_device_ids)) + .order_by(DeviceBeaconBinding.device_id, BeaconConfig.id) + ) + device_macs: dict[int, list[str]] = {} + for row in result.all(): + device_macs.setdefault(row[0], []).append(row[1]) + + details = [] + sent_count = 0 + fail_count = 0 + + for dev in devices: + if not tcp_command_service.is_device_online(dev.imei): + details.append({ + "device_id": dev.id, "imei": dev.imei, "name": dev.name, + "status": "离线", "commands": [], + }) + fail_count += 1 + continue + + macs = device_macs.get(dev.id, []) + # Build command sequence + commands = [ + "CLOCKWAY,3#", + "MODE,2#", + ] + # BTMACSET: split into slots of 10 + slot_names = ["BTMACSET", "BTMACSET1", "BTMACSET2", "BTMACSET3", "BTMACSET4"] + if macs: + for i in range(0, min(len(macs), 50), 10): + slot_idx = i // 10 + chunk = macs[i:i + 10] + commands.append(f"{slot_names[slot_idx]},{','.join(chunk)}#") + commands.append("BTMP3SW,1#") + + # Send commands sequentially with small delay + sent_cmds = [] + has_error = False + for cmd in commands: + try: + ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd) + sent_cmds.append({"cmd": cmd, "ok": ok}) + if not ok: + has_error = True + # Small delay between commands to avoid overwhelming device + await asyncio.sleep(0.3) + except Exception as e: + sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)}) + has_error = True + + if has_error: + fail_count += 1 + else: + sent_count += 1 + + details.append({ + "device_id": dev.id, "imei": dev.imei, "name": dev.name, + "status": "部分失败" if has_error else "已配置", + "beacon_count": len(macs), + "commands": sent_cmds, + }) + + return { + "total": len(devices), + "sent": sent_count, + "failed": fail_count, + "details": details, + "error": None, + } + + +async def restore_normal_mode( + db: AsyncSession, + device_ids: list[int] | None = None, +) -> dict: + """Restore devices from Bluetooth clock-in mode to normal (smart) mode. + + Sends: + 1. CLOCKWAY,1# (manual clock only) + 2. MODE,3# (smart positioning) + 3. BTMP3SW,0# (voice broadcast off) + """ + if device_ids: + result = await db.execute( + select(Device).where(Device.id.in_(device_ids)) + ) + else: + result = await db.execute( + select(Device).where(Device.status == "online") + ) + devices = list(result.scalars().all()) + + if not devices: + return {"total": 0, "sent": 0, "failed": 0, "details": [], "error": "没有可操作的设备"} + + commands = ["CLOCKWAY,1#", "MODE,3#", "BTMP3SW,0#"] + details = [] + sent_count = 0 + fail_count = 0 + + for dev in devices: + if not tcp_command_service.is_device_online(dev.imei): + details.append({ + "device_id": dev.id, "imei": dev.imei, "name": dev.name, + "status": "离线", "commands": [], + }) + fail_count += 1 + continue + + sent_cmds = [] + has_error = False + for cmd in commands: + try: + ok = await tcp_command_service.send_command(dev.imei, "online_cmd", cmd) + sent_cmds.append({"cmd": cmd, "ok": ok}) + if not ok: + has_error = True + await asyncio.sleep(0.3) + except Exception as e: + sent_cmds.append({"cmd": cmd, "ok": False, "error": str(e)}) + has_error = True + + if has_error: + fail_count += 1 + else: + sent_count += 1 + + details.append({ + "device_id": dev.id, "imei": dev.imei, "name": dev.name, + "status": "部分失败" if has_error else "已恢复", + "commands": sent_cmds, + }) + + return { + "total": len(devices), + "sent": sent_count, + "failed": fail_count, + "details": details, + "error": None, + } diff --git a/app/services/export_utils.py b/app/services/export_utils.py new file mode 100644 index 0000000..7d7878f --- /dev/null +++ b/app/services/export_utils.py @@ -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" diff --git a/app/static/admin.html b/app/static/admin.html index c0a0a2c..6841972 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -241,7 +241,13 @@
- +
+ + + + + +
@@ -338,14 +344,21 @@ + + + + +
+
+ +
-
@@ -438,7 +451,10 @@ + + +
@@ -505,6 +521,9 @@ + + ~ + @@ -581,8 +600,11 @@ + + +
@@ -667,6 +689,7 @@ +
@@ -753,6 +776,7 @@ +
@@ -816,42 +840,70 @@
-
+
-
- - - - -
- + +
+ +
-
- -
- - - - - - - - - - - - - - - - -
MAC 地址名称UUID / Major / Minor楼层 / 区域坐标状态更新时间操作
加载中...
+ +
+
+ + + + +
+ +
+
+ +
+ + + + + + + + + + + + + + + + +
MAC 地址名称UUID / Major / Minor楼层 / 区域坐标状态更新时间操作
加载中...
+
+ +
+
+ +
@@ -1156,6 +1208,8 @@ const API_BASE = '/api'; let currentPage = 'dashboard'; let dashboardInterval = null; + let _ws = null; // WebSocket connection + let _wsReconnectTimer = null; let locationMap = null; let mapMarkers = []; let mapPolyline = null; @@ -1579,6 +1633,75 @@ return _devIdToImei[deviceId] || deviceId || '-'; } + async function exportCSV(type) { + const params = new URLSearchParams(); + if (type === 'devices') { + const search = document.getElementById('deviceSearch')?.value; + const status = document.getElementById('deviceStatusFilter')?.value; + if (search) params.set('search', search); + if (status) params.set('status', status); + } else if (type === 'locations') { + const did = document.getElementById('locDeviceSelect')?.value; + const lt = document.getElementById('locTypeFilter')?.value; + const sd = document.getElementById('locStartDate')?.value; + const ed = document.getElementById('locEndDate')?.value; + if (did) params.set('device_id', did); + if (lt) params.set('location_type', lt); + if (sd) params.set('start_time', sd + 'T00:00:00'); + if (ed) params.set('end_time', ed + 'T23:59:59'); + } else if (type === 'alarms') { + const did = document.getElementById('alarmDeviceFilter')?.value; + const at = document.getElementById('alarmTypeFilter')?.value; + const ack = document.getElementById('alarmAckFilter')?.value; + const sd = document.getElementById('alarmStartDate')?.value; + const ed = document.getElementById('alarmEndDate')?.value; + if (did) params.set('device_id', did); + if (at) params.set('alarm_type', at); + if (ack) params.set('acknowledged', ack); + if (sd) params.set('start_time', sd + 'T00:00:00'); + if (ed) params.set('end_time', ed + 'T23:59:59'); + } else if (type === 'attendance') { + const did = document.getElementById('attDeviceFilter')?.value; + const at = document.getElementById('attTypeFilter')?.value; + const src = document.getElementById('attSourceFilter')?.value; + const sd = document.getElementById('attStartDate')?.value; + const ed = document.getElementById('attEndDate')?.value; + if (did) params.set('device_id', did); + if (at) params.set('attendance_type', at); + if (src) params.set('attendance_source', src); + if (sd) params.set('start_time', sd + 'T00:00:00'); + if (ed) params.set('end_time', ed + 'T23:59:59'); + } else if (type === 'bluetooth') { + const did = document.getElementById('btDeviceFilter')?.value; + const rt = document.getElementById('btTypeFilter')?.value; + const sd = document.getElementById('btStartDate')?.value; + const ed = document.getElementById('btEndDate')?.value; + if (did) params.set('device_id', did); + if (rt) params.set('record_type', rt); + if (sd) params.set('start_time', sd + 'T00:00:00'); + if (ed) params.set('end_time', ed + 'T23:59:59'); + } + const qs = params.toString(); + const url = `${API_BASE}/${type}/export${qs ? '?' + qs : ''}`; + try { + showToast('正在导出...'); + const resp = await fetch(url); + if (!resp.ok) throw new Error(`导出失败: HTTP ${resp.status}`); + const blob = await resp.blob(); + const cd = resp.headers.get('Content-Disposition') || ''; + const match = cd.match(/filename=(.+)/); + const filename = match ? match[1] : `${type}_export.csv`; + const a = document.createElement('a'); + a.href = URL.createObjectURL(blob); + a.download = filename; + a.click(); + URL.revokeObjectURL(a.href); + showToast('导出完成'); + } catch (err) { + showToast('导出失败: ' + err.message, 'error'); + } + } + async function loadDeviceSelectors() { try { const data = await apiCall(`${API_BASE}/devices?page=1&page_size=100`); @@ -1849,6 +1972,8 @@ + + `; @@ -2216,28 +2341,138 @@ // --- Quick command sender for device detail panel --- async function _devQuickCmd(deviceId, cmd, btnEl) { - if (btnEl) { btnEl.disabled = true; btnEl.classList.add('sent'); } + // Find device info + const dev = cachedDevices.find(d => d.id == deviceId); + const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`; + + // Show modal immediately + const modalId = '_qcmd_modal_' + Date.now(); + showModal(` +

指令发送 — ${escapeHtml(devLabel)}

+
+
+ 指令: + ${escapeHtml(cmd)} +
+
+
+ 发送中... +
+
+
+
+ `); + try { const res = await apiCall(`${API_BASE}/commands/send`, { method: 'POST', body: JSON.stringify({ device_id: parseInt(deviceId), command_type: 'online_cmd', command_content: cmd }), }); - showToast(`${cmd} → 已发送`); - // Poll for response + + const statusEl = document.getElementById(`${modalId}_status`); + if (!statusEl) return; + + // Update modal: sent successfully, waiting for response + statusEl.innerHTML = ` +
已发送
+
+ 等待设备回复... +
+ + `; + + // Poll for response in background (don't block) const cmdId = res && res.id; if (cmdId) { - for (let i = 0; i < 6; i++) { - await new Promise(r => setTimeout(r, 1500)); - try { - const c = await apiCall(`${API_BASE}/commands/${cmdId}`); - if (c.response_content) { showToast(`${cmd} → ${c.response_content}`); break; } - } catch (_) {} - } + (async () => { + for (let i = 0; i < 8; i++) { + await new Promise(r => setTimeout(r, 1500)); + try { + const c = await apiCall(`${API_BASE}/commands/${cmdId}`); + if (c.response_content) { + const waitEl = document.getElementById(`${modalId}_wait`); + const respEl = document.getElementById(`${modalId}_resp`); + if (waitEl) waitEl.style.display = 'none'; + if (respEl) { + respEl.style.display = ''; + respEl.innerHTML = ` + 设备回复: +
${escapeHtml(c.response_content)}
+ `; + } + return; + } + } catch (_) {} + } + const waitEl = document.getElementById(`${modalId}_wait`); + if (waitEl) waitEl.innerHTML = ' 暂无回复(设备可能稍后响应)'; + })(); } } catch (err) { - showToast(`${cmd} 发送失败: ${err.message}`, 'error'); + const statusEl = document.getElementById(`${modalId}_status`); + if (statusEl) { + statusEl.innerHTML = ` +
发送失败: ${escapeHtml(err.message)}
+ `; + } + } + } + + async function _devSetupBtMode(deviceId, btnEl) { + const origHTML = btnEl ? btnEl.innerHTML : ''; + if (btnEl) { btnEl.disabled = true; btnEl.innerHTML = ''; } + + const dev = cachedDevices.find(d => d.id == deviceId); + const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`; + + showModal(` +

蓝牙打卡模式 — ${escapeHtml(devLabel)}

+
+
+
+ 正在配置蓝牙打卡模式... +
+
+
+
+ `); + + try { + const result = await apiCall(`${API_BASE}/beacons/setup-bluetooth-mode?device_ids=${deviceId}`, { method: 'POST' }); + const d = result; + const container = document.getElementById('_btmode_result'); + if (!container) return; + + if (d.error) { + container.innerHTML = `
${escapeHtml(d.error)}
`; + return; + } + const info = (d.details || [])[0]; + if (!info) { container.innerHTML = '
无结果
'; return; } + + const statusColor = info.status === '已配置' ? '#22c55e' : info.status === '离线' ? '#6b7280' : '#f59e0b'; + const cmdRows = (info.commands || []).map((c, i) => ` +
+ ${c.ok ? '✓' : '✗'} + ${escapeHtml(c.cmd)} +
+ `).join(''); + + container.innerHTML = ` +
+ ${info.status} + ${info.beacon_count !== undefined ? `· ${info.beacon_count} 个信标MAC` : ''} +
+
+
已发送指令:
+ ${cmdRows || '
无指令(设备离线)
'} +
+ `; + } catch (err) { + const container = document.getElementById('_btmode_result'); + if (container) container.innerHTML = `
配置失败: ${escapeHtml(err.message)}
`; } finally { - if (btnEl) { btnEl.disabled = false; btnEl.classList.remove('sent'); } + if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = origHTML; } } } @@ -2254,6 +2489,170 @@ } } + async function _setupBluetoothMode() { + showModal(` +

蓝牙打卡模式配置

+
+

将向所有在线设备依次发送以下指令:

+
+
1. CLOCKWAY,3# — 打卡方式: 手动+蓝牙
+
2. MODE,2# — 定位模式: 蓝牙
+
3. BTMACSET,MAC...# — 写入绑定的信标MAC
+
4. BTMP3SW,1# — 开启语音播报
+
+

信标MAC来自信标管理页面的设备绑定配置,未绑定信标的设备将不写入MAC

+
+
+ + +
+ `); + } + + async function _doSetupBluetoothMode() { + const btn = document.getElementById('btnBtModeConfirm'); + btn.disabled = true; + btn.innerHTML = ' 配置中...'; + try { + const result = await apiCall(`${API_BASE}/beacons/setup-bluetooth-mode`, { method: 'POST' }); + closeModal(); + const d = result; + if (d.error) { showToast(d.error, 'error'); return; } + // Show detail modal + let rows = ''; + (d.details || []).forEach(x => { + const label = x.name || x.imei; + const statusColor = x.status === '已配置' ? '#22c55e' : x.status === '离线' ? '#6b7280' : '#f59e0b'; + const beaconInfo = x.beacon_count !== undefined ? `${x.beacon_count}个信标` : ''; + const cmds = (x.commands || []).map(c => + `${c.ok ? '✓' : '✗'} ${escapeHtml(c.cmd)}` + ).join('
'); + rows += ` + ${escapeHtml(String(label))} + ${x.status} + ${beaconInfo} + ${cmds} + `; + }); + showModal(` +

蓝牙打卡模式 — 配置结果

+

共 ${d.total} 台设备: ${d.sent} 台成功, ${d.failed} 台失败

+
+ ${rows}
设备状态信标指令详情
+
+
+ `); + } catch (err) { + showToast('配置失败: ' + err.message, 'error'); + } + } + + async function _restoreNormalMode() { + showModal(` +

恢复正常模式

+
+

将向所有在线设备依次发送以下指令:

+
+
1. CLOCKWAY,1# — 打卡方式: 仅手动打卡
+
2. MODE,3# — 定位模式: 智能模式
+
3. BTMP3SW,0# — 关闭语音播报
+
+
+
+ + +
+ `); + } + + async function _doRestoreNormalMode() { + const btn = document.getElementById('btnRestoreConfirm'); + btn.disabled = true; + btn.innerHTML = ' 恢复中...'; + try { + const result = await apiCall(`${API_BASE}/beacons/restore-normal-mode`, { method: 'POST' }); + closeModal(); + const d = result; + if (d.error) { showToast(d.error, 'error'); return; } + let rows = ''; + (d.details || []).forEach(x => { + const label = x.name || x.imei; + const statusColor = x.status === '已恢复' ? '#22c55e' : x.status === '离线' ? '#6b7280' : '#f59e0b'; + const cmds = (x.commands || []).map(c => + `${c.ok ? '✓' : '✗'} ${escapeHtml(c.cmd)}` + ).join('
'); + rows += `${escapeHtml(String(label))}${x.status}${cmds}`; + }); + showModal(` +

恢复正常模式 — 结果

+

共 ${d.total} 台设备: ${d.sent} 台成功, ${d.failed} 台失败

+
+ ${rows}
设备状态指令详情
+
+
+ `); + } catch (err) { + showToast('恢复失败: ' + err.message, 'error'); + } + } + + async function _devRestoreNormal(deviceId, btnEl) { + const origHTML = btnEl ? btnEl.innerHTML : ''; + if (btnEl) { btnEl.disabled = true; btnEl.innerHTML = ''; } + + const dev = cachedDevices.find(d => d.id == deviceId); + const devLabel = dev ? (dev.name || dev.imei) : `设备${deviceId}`; + + showModal(` +

恢复正常模式 — ${escapeHtml(devLabel)}

+
+
+
+ 正在恢复正常模式... +
+
+
+
+ `); + + try { + const result = await apiCall(`${API_BASE}/beacons/restore-normal-mode?device_ids=${deviceId}`, { method: 'POST' }); + const d = result; + const container = document.getElementById('_restore_result'); + if (!container) return; + + if (d.error) { + container.innerHTML = `
${escapeHtml(d.error)}
`; + return; + } + const info = (d.details || [])[0]; + if (!info) { container.innerHTML = '
无结果
'; return; } + + const statusColor = info.status === '已恢复' ? '#22c55e' : info.status === '离线' ? '#6b7280' : '#f59e0b'; + const cmdRows = (info.commands || []).map((c, i) => ` +
+ ${c.ok ? '✓' : '✗'} + ${escapeHtml(c.cmd)} +
+ `).join(''); + + container.innerHTML = ` +
+ ${info.status} +
+
+
已发送指令:
+ ${cmdRows || '
无指令(设备离线)
'} +
+ `; + } catch (err) { + const container = document.getElementById('_restore_result'); + if (container) container.innerHTML = `
恢复失败: ${escapeHtml(err.message)}
`; + } finally { + if (btnEl) { btnEl.disabled = false; btnEl.innerHTML = origHTML; } + } + } + function _showBroadcastModal() { showModal(`

广播指令 — 发送给所有在线设备

@@ -2590,6 +2989,9 @@ if (!cachedDevices || !cachedDevices.length) await loadDeviceSelectors(); if (!_ovInited) { _ovInited = true; + const todayStr = new Date().toISOString().split('T')[0]; + document.getElementById('ovTrackStart').value = todayStr; + document.getElementById('ovTrackEnd').value = todayStr; cachedDevices.forEach(d => _ovSelectedDevices.add(d.id)); } _ovBuildDeviceList(); @@ -2849,7 +3251,10 @@ const ids = [..._ovSelectedDevices]; if (!ids.length) { showToast('请勾选至少一台设备', 'error'); return; } - const today = new Date().toISOString().split('T')[0]; + const startDate = document.getElementById('ovTrackStart').value; + const endDate = document.getElementById('ovTrackEnd').value; + if (!startDate || !endDate) { showToast('请选择日期范围', 'error'); return; } + if (startDate > endDate) { showToast('开始日期不能晚于结束日期', 'error'); return; } _ovClearTrack(); let totalPoints = 0; @@ -2863,7 +3268,7 @@ const color = trackColors[idx % trackColors.length]; try { - const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${today}T00:00:00&end_time=${today}T23:59:59`); + const data = await apiCall(`${API_BASE}/locations/track/${did}?start_time=${startDate}T00:00:00&end_time=${endDate}T23:59:59`); const locs = Array.isArray(data) ? data : (data.items || []); if (!locs.length) continue; @@ -2920,7 +3325,7 @@ } } - if (totalPoints === 0) { showToast('今天没有轨迹数据', 'info'); return; } + if (totalPoints === 0) { showToast('所选日期范围内没有轨迹数据', 'info'); return; } if (_ovTrackMarkers.length > 1) { _overviewMap.setFitView(_ovTrackMarkers.filter(m => m.getPosition), false, [80,80,80,80]); @@ -4462,6 +4867,9 @@ } async function saveBindingMatrix() { + const btn = document.getElementById('fenceBindSaveBtn'); + if (btn && btn.disabled) return; + if (btn) { btn.disabled = true; btn.innerHTML = ' 保存中...'; } // Compute diffs per fence: which devices to bind, which to unbind const toBind = {}; // fenceId -> [deviceIds] const toUnbind = {}; // fenceId -> [deviceIds] @@ -4485,13 +4893,19 @@ method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[fid] }), })); } - if (!ops.length) { showToast('没有更改', 'info'); return; } + if (!ops.length) { + showToast('没有更改', 'info'); + if (btn) { btn.disabled = false; btn.innerHTML = ' 保存更改'; } + return; + } try { await Promise.all(ops); showToast(`保存成功 (${ops.length} 项操作)`, 'success'); loadBindingMatrix(); // reload to sync state } catch (err) { showToast('保存失败: ' + err.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' 保存更改'; } } } @@ -4746,6 +5160,219 @@ } } + // ==================== BEACON BINDING MATRIX ==================== + + function switchBeaconTab(tab) { + document.getElementById('beaconTabList').classList.toggle('active', tab === 'list'); + document.getElementById('beaconTabBindings').classList.toggle('active', tab === 'bindings'); + document.getElementById('beaconTabContentList').style.display = tab === 'list' ? 'flex' : 'none'; + document.getElementById('beaconTabContentBindings').style.display = tab === 'bindings' ? 'flex' : 'none'; + if (tab === 'bindings') loadBeaconBindingMatrix(); + } + + let _bbMatrixState = {}; + let _bbMatrixOriginal = {}; + let _bbBeacons = []; + let _bbDevices = []; + + async function loadBeaconBindingMatrix() { + const thead = document.getElementById('beaconBindMatrixHead'); + const tbody = document.getElementById('beaconBindMatrixBody'); + try { + const [beaconData, deviceData] = await Promise.all([ + apiCall(`${API_BASE}/beacons?page=1&page_size=100`), + apiCall(`${API_BASE}/devices?page=1&page_size=100`), + ]); + _bbBeacons = beaconData.items || []; + _bbDevices = deviceData.items || []; + + if (!_bbBeacons.length) { + thead.innerHTML = ''; + tbody.innerHTML = '暂无信标,请先添加'; + return; + } + if (!_bbDevices.length) { + thead.innerHTML = ''; + tbody.innerHTML = '暂无设备'; + return; + } + + const bindingsArr = await Promise.all( + _bbBeacons.map(b => apiCall(`${API_BASE}/beacons/${b.id}/devices`).catch(() => [])) + ); + + _bbMatrixState = {}; + _bbMatrixOriginal = {}; + _bbBeacons.forEach((b, i) => { + const bound = bindingsArr[i] || []; + const boundIds = new Set(bound.map(x => x.device_id)); + _bbDevices.forEach(d => { + const key = `${b.id}-${d.id}`; + const val = boundIds.has(d.id); + _bbMatrixState[key] = val; + _bbMatrixOriginal[key] = val; + }); + }); + + thead.innerHTML = ` + 设备 \\ 信标 + ${_bbBeacons.map(b => `${escapeHtml(b.name)}
${b.floor || ''} ${b.area || ''}`).join('')} + 全选 + `; + + tbody.innerHTML = _bbDevices.map(d => { + const label = d.name || d.imei || d.id; + const statusDot = d.status === 'online' ? '🟢' : '⚪'; + return ` + ${statusDot} ${escapeHtml(String(label))} + ${_bbBeacons.map(b => { + const key = `${b.id}-${d.id}`; + const checked = _bbMatrixState[key] ? 'checked' : ''; + return ``; + }).join('')} + + `; + }).join('') + ` + 全选列 + ${_bbBeacons.map(b => ``).join('')} + + `; + _updateBBSaveBtn(); + } catch (err) { + thead.innerHTML = ''; + tbody.innerHTML = `加载失败: ${escapeHtml(err.message)}`; + } + } + + function _bbToggleDeviceRow(deviceId, checked) { + _bbBeacons.forEach(b => { _bbMatrixState[`${b.id}-${deviceId}`] = checked; }); + _bbRefreshCheckboxes(); + } + + function _bbToggleBeaconCol(beaconId, checked) { + _bbDevices.forEach(d => { _bbMatrixState[`${beaconId}-${d.id}`] = checked; }); + _bbRefreshCheckboxes(); + } + + function _bbToggleAll(checked) { + _bbBeacons.forEach(b => _bbDevices.forEach(d => { _bbMatrixState[`${b.id}-${d.id}`] = checked; })); + _bbRefreshCheckboxes(); + } + + function _bbRefreshCheckboxes() { + const tbody = document.getElementById('beaconBindMatrixBody'); + tbody.querySelectorAll('input[type="checkbox"]').forEach(cb => { + const onchange = cb.getAttribute('onchange') || ''; + const m = onchange.match(/_bbMatrixState\['(\d+-\d+)'\]/); + if (m) cb.checked = _bbMatrixState[m[1]] || false; + }); + _updateBBSaveBtn(); + } + + function _updateBBSaveBtn() { + let changes = 0; + for (const key in _bbMatrixState) { + if (_bbMatrixState[key] !== _bbMatrixOriginal[key]) changes++; + } + const btn = document.getElementById('beaconBindSaveBtn'); + if (btn) btn.innerHTML = changes > 0 + ? ` 保存更改 (${changes})` + : ` 保存更改`; + } + + async function saveBeaconBindingMatrix() { + const btn = document.getElementById('beaconBindSaveBtn'); + if (btn && btn.disabled) return; + if (btn) { btn.disabled = true; btn.innerHTML = ' 保存中...'; } + const toBind = {}; + const toUnbind = {}; + const affectedDeviceIds = new Set(); + for (const key in _bbMatrixState) { + if (_bbMatrixState[key] === _bbMatrixOriginal[key]) continue; + const [beaconId, deviceId] = key.split('-').map(Number); + affectedDeviceIds.add(deviceId); + if (_bbMatrixState[key]) { + (toBind[beaconId] = toBind[beaconId] || []).push(deviceId); + } else { + (toUnbind[beaconId] = toUnbind[beaconId] || []).push(deviceId); + } + } + const ops = []; + for (const bid in toBind) { + ops.push(apiCall(`${API_BASE}/beacons/${bid}/devices`, { + method: 'POST', body: JSON.stringify({ device_ids: toBind[bid] }), + })); + } + for (const bid in toUnbind) { + ops.push(apiCall(`${API_BASE}/beacons/${bid}/devices`, { + method: 'DELETE', body: JSON.stringify({ device_ids: toUnbind[bid] }), + })); + } + if (!ops.length) { + showToast('没有更改', 'info'); + if (btn) { btn.disabled = false; btn.innerHTML = ' 保存更改'; } + return; + } + try { + await Promise.all(ops); + if (btn) btn.innerHTML = ' 同步指令到设备...'; + + // Sync BTMACSET commands to each affected device + const syncResults = await Promise.all( + [...affectedDeviceIds].map(did => + apiCall(`${API_BASE}/beacons/sync-device/${did}`, { method: 'POST' }).catch(e => ({ error: e.message })) + ) + ); + let sentCount = 0, failCount = 0; + syncResults.forEach(r => { + if (r && r.sent) sentCount++; + else failCount++; + }); + if (failCount > 0) { + showToast(`已保存绑定。指令同步: ${sentCount} 台成功, ${failCount} 台失败(可能离线)`, 'warning'); + } else { + showToast(`已保存绑定并同步 BTMACSET 指令到 ${sentCount} 台设备`); + } + loadBeaconBindingMatrix(); + } catch (err) { + showToast('保存失败: ' + err.message, 'error'); + } finally { + if (btn) { btn.disabled = false; btn.innerHTML = ' 保存更改'; } + } + } + + async function _bbReverseSync() { + const btn = document.getElementById('bbReverseSyncBtn'); + btn.disabled = true; + btn.innerHTML = ' 查询设备中...'; + try { + const result = await apiCall(`${API_BASE}/beacons/reverse-sync`, { method: 'POST' }); + const d = result; + if (d.error) { + showToast(d.error, 'error'); + return; + } + // Build detail summary + let msg = `查询 ${d.queried} 台,${d.responded} 台响应,${d.updated} 台有变更`; + if (d.details && d.details.length) { + const lines = d.details.map(x => { + const label = x.name || x.imei; + if (x.status === '无响应') return `${label}: 无响应`; + return `${label}: ${x.device_macs.length}个MAC, 匹配${x.matched_beacons}个信标` + + (x.added || x.removed ? ` (+${x.added} -${x.removed})` : ' 无变更'); + }); + msg += '\n' + lines.join('\n'); + } + showToast(msg, d.updated > 0 ? 'success' : 'info'); + loadBeaconBindingMatrix(); + } catch (err) { + showToast('同步失败: ' + err.message, 'error'); + } finally { + btn.disabled = false; + btn.innerHTML = ' 从设备同步'; + } + } + // ==================== COMMANDS ==================== async function sendCommand() { const deviceId = document.getElementById('cmdUnifiedDevice').value; @@ -4879,6 +5506,592 @@ } } + // ==================== ALARM CONDITIONAL DELETE ==================== + function showAlarmCleanupModal() { + showModal(` +

按条件批量删除告警

+
+
+
+
+
+
+
+
+
+
+

此操作不可撤销!请确认筛选条件。

+
+ + +
+ `); + // populate device selector + if (cachedDevices) { + const sel = document.getElementById('alarmCleanDevice'); + cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = d.name || d.imei; sel.appendChild(o); }); + } + } + async function submitAlarmCleanup() { + const body = {}; + const did = document.getElementById('alarmCleanDevice').value; + const at = document.getElementById('alarmCleanType').value; + const ack = document.getElementById('alarmCleanAck').value; + const sd = document.getElementById('alarmCleanStart').value; + const ed = document.getElementById('alarmCleanEnd').value; + if (did) body.device_id = parseInt(did); + if (at) body.alarm_type = at; + if (ack) body.acknowledged = ack === 'true'; + if (sd) body.start_time = sd + 'T00:00:00'; + if (ed) body.end_time = ed + 'T23:59:59'; + if (!Object.keys(body).length) { showToast('请至少选择一个筛选条件', 'info'); return; } + if (!confirm('确定按所选条件删除告警记录?此操作不可撤销!')) return; + try { + const result = await apiCall(`${API_BASE}/alarms/batch-delete`, { method: 'POST', body: JSON.stringify(body) }); + showToast(`已删除 ${result.deleted} 条告警`); + closeModal(); loadAlarms(); + } catch (err) { showToast('删除失败: ' + err.message, 'error'); } + } + + // ==================== LOCATION CLEANUP ==================== + function showLocationCleanupModal() { + showModal(` +

清理旧位置数据

+
+ +

将删除指定天数之前的位置记录

+
+
+
+
+

此操作不可撤销!

+
+ + +
+ `); + if (cachedDevices) { + const sel = document.getElementById('cleanupDevice'); + cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = d.name || d.imei; sel.appendChild(o); }); + } + } + async function submitLocationCleanup() { + const days = parseInt(document.getElementById('cleanupDays').value); + if (!days || days < 1) { showToast('请输入有效的天数', 'info'); return; } + const body = { days }; + const did = document.getElementById('cleanupDevice').value; + const lt = document.getElementById('cleanupLocType').value; + if (did) body.device_id = parseInt(did); + if (lt) body.location_type = lt; + if (!confirm(`确定删除 ${days} 天前的位置记录?此操作不可撤销!`)) return; + try { + const result = await apiCall(`${API_BASE}/locations/cleanup`, { method: 'POST', body: JSON.stringify(body) }); + showToast(`已清理 ${result.deleted} 条记录`); + closeModal(); loadLocationRecords(); + } catch (err) { showToast('清理失败: ' + err.message, 'error'); } + } + + // ==================== HEATMAP ==================== + let _heatmapLayer = null; + async function showLocationHeatmap() { + if (!locationMap) { showToast('请等待地图加载完成', 'info'); return; } + const did = document.getElementById('locDeviceSelect')?.value; + const sd = document.getElementById('locStartDate')?.value; + const ed = document.getElementById('locEndDate')?.value; + const params = new URLSearchParams(); + if (did) params.set('device_id', did); + if (sd) params.set('start_time', sd + 'T00:00:00'); + if (ed) params.set('end_time', ed + 'T23:59:59'); + try { + showToast('加载热力图数据...', 'info'); + const points = await apiCall(`${API_BASE}/locations/heatmap?${params}`); + if (!points || !points.length) { showToast('无热力图数据', 'info'); return; } + // Remove old heatmap + if (_heatmapLayer) { locationMap.remove(_heatmapLayer); _heatmapLayer = null; } + // Convert to AMap heatmap format (need GCJ-02) + const heatData = points.map(p => ({ + lng: p.lng + 0.0065, // rough WGS84->GCJ02 + lat: p.lat + 0.006, + count: p.weight, + })); + if (typeof AMap !== 'undefined' && AMap.HeatMap) { + _heatmapLayer = new AMap.HeatMap(locationMap, { + radius: 25, opacity: [0, 0.8], + gradient: { 0.4: 'blue', 0.65: 'lime', 0.85: 'yellow', 1.0: 'red' }, + }); + _heatmapLayer.setDataSet({ data: heatData, max: Math.max(...points.map(p => p.weight)) }); + showToast(`热力图已加载 (${points.length} 个网格点)`); + } else { + // Fallback: load heatmap plugin + AMap.plugin(['AMap.HeatMap'], () => { + _heatmapLayer = new AMap.HeatMap(locationMap, { + radius: 25, opacity: [0, 0.8], + gradient: { 0.4: 'blue', 0.65: 'lime', 0.85: 'yellow', 1.0: 'red' }, + }); + _heatmapLayer.setDataSet({ data: heatData, max: Math.max(...points.map(p => p.weight)) }); + showToast(`热力图已加载 (${points.length} 个网格点)`); + }); + } + } catch (err) { showToast('加载热力图失败: ' + err.message, 'error'); } + } + + // ==================== DEVICE GROUPS ==================== + async function showDeviceGroupsModal() { + showModal(` +

设备分组管理

+
+
+ + +
+ `); + await loadGroupList(); + } + async function loadGroupList() { + try { + const groups = await apiCall(`${API_BASE}/groups`); + const container = document.getElementById('groupListContainer'); + if (!groups || !groups.length) { + container.innerHTML = '

暂无分组

'; + return; + } + container.innerHTML = groups.map(g => ` +
+
+ ${g.name} + ${g.device_count} 台设备 + ${g.description ? `

${g.description}

` : ''} +
+
+ + +
+
+ `).join(''); + } catch (err) { showToast('加载分组失败: ' + err.message, 'error'); } + } + function showCreateGroupForm() { + showModal(` +

新建分组

+
+
+
+
+
+
+
+ + +
+ `); + } + async function submitCreateGroup() { + const name = document.getElementById('newGroupName').value.trim(); + if (!name) { showToast('请输入分组名称', 'info'); return; } + try { + await apiCall(`${API_BASE}/groups`, { method: 'POST', body: JSON.stringify({ + name, description: document.getElementById('newGroupDesc').value.trim() || null, + color: document.getElementById('newGroupColor').value, + })}); + showToast('分组创建成功'); + showDeviceGroupsModal(); + } catch (err) { showToast('创建失败: ' + err.message, 'error'); } + } + async function deleteGroup(groupId) { + if (!confirm('确定删除此分组?(不会删除设备)')) return; + try { + await apiCall(`${API_BASE}/groups/${groupId}`, { method: 'DELETE' }); + showToast('分组已删除'); loadGroupList(); + } catch (err) { showToast('删除失败: ' + err.message, 'error'); } + } + async function showGroupDevices(groupId, groupName) { + showModal(` +

${groupName} - 设备列表

+
+
+
+
+ + +
+ `); + // Load group devices + try { + const devices = await apiCall(`${API_BASE}/groups/${groupId}/devices`); + const container = document.getElementById('groupDeviceList'); + if (!devices.length) { container.innerHTML = '

暂无设备

'; } + else { + container.innerHTML = devices.map(d => ` +
+ ${d.name || d.imei} (${d.imei}) + +
+ `).join(''); + } + } catch (err) { document.getElementById('groupDeviceList').innerHTML = '

加载失败

'; } + // Populate device selector (exclude already in group) + if (cachedDevices) { + const sel = document.getElementById('addToGroupDevice'); + cachedDevices.forEach(d => { const o = document.createElement('option'); o.value = d.id; o.textContent = (d.name || d.imei) + ' (' + d.imei + ')'; sel.appendChild(o); }); + } + } + async function addDeviceToGroup(groupId) { + const did = document.getElementById('addToGroupDevice').value; + if (!did) { showToast('请选择设备', 'info'); return; } + try { + await apiCall(`${API_BASE}/groups/${groupId}/devices`, { method: 'POST', body: JSON.stringify({ device_ids: [parseInt(did)] }) }); + showToast('设备已添加'); + const groupName = document.querySelector('#modalContainer h3')?.textContent?.split(' - ')[0]?.replace(/.*\s/, '') || ''; + showGroupDevices(groupId, groupName); + } catch (err) { showToast('添加失败: ' + err.message, 'error'); } + } + async function removeDeviceFromGroup(groupId, deviceId, groupName) { + try { + await apiCall(`${API_BASE}/groups/${groupId}/devices`, { method: 'DELETE', body: JSON.stringify({ device_ids: [deviceId] }) }); + showToast('设备已移除'); + showGroupDevices(groupId, groupName); + } catch (err) { showToast('移除失败: ' + err.message, 'error'); } + } + + // ==================== ALERT RULES ==================== + async function showAlertRulesModal() { + showModal(` +

告警规则管理

+
+
+ + +
+ `); + await loadAlertRules(); + } + async function loadAlertRules() { + try { + const rules = await apiCall(`${API_BASE}/alert-rules`); + const container = document.getElementById('alertRuleList'); + const typeLabels = { low_battery: '低电量', no_heartbeat: '心跳超时', fence_stay: '围栏停留', speed_limit: '超速', offline_duration: '离线时长' }; + if (!rules || !rules.length) { container.innerHTML = '

暂无规则

'; return; } + container.innerHTML = rules.map(r => ` +
+
+ ${r.name} + ${r.is_active ? '启用' : '禁用'} +

类型: ${typeLabels[r.rule_type] || r.rule_type} | 条件: ${JSON.stringify(r.conditions)}

+ ${r.description ? `

${r.description}

` : ''} +
+
+ + +
+
+ `).join(''); + } catch (err) { showToast('加载规则失败: ' + err.message, 'error'); } + } + function showCreateAlertRuleForm() { + showModal(` +

新建告警规则

+
+
+
+
+
+ +

电量百分比 (低于此值触发告警)

+
+
+
+ + +
+ `); + } + function updateRuleConditionHint() { + const type = document.getElementById('newRuleType').value; + const hints = { + low_battery: '电量百分比 (低于此值触发告警)', + no_heartbeat: '分钟数 (超过此时长无心跳触发)', + fence_stay: '分钟数 (围栏内停留超过此时长触发)', + speed_limit: '速度km/h (超过此速度触发)', + offline_duration: '分钟数 (离线超过此时长触发)', + }; + document.getElementById('ruleCondHint').textContent = hints[type] || ''; + } + async function submitCreateAlertRule() { + const name = document.getElementById('newRuleName').value.trim(); + const ruleType = document.getElementById('newRuleType').value; + const threshold = parseInt(document.getElementById('newRuleThreshold').value); + if (!name) { showToast('请输入规则名称', 'info'); return; } + if (!threshold || threshold < 1) { showToast('请输入有效阈值', 'info'); return; } + try { + await apiCall(`${API_BASE}/alert-rules`, { method: 'POST', body: JSON.stringify({ + name, rule_type: ruleType, + conditions: { threshold }, + description: document.getElementById('newRuleDesc').value.trim() || null, + })}); + showToast('规则创建成功'); + showAlertRulesModal(); + } catch (err) { showToast('创建失败: ' + err.message, 'error'); } + } + async function toggleAlertRule(ruleId, active) { + try { + await apiCall(`${API_BASE}/alert-rules/${ruleId}`, { method: 'PUT', body: JSON.stringify({ is_active: active }) }); + showToast(active ? '规则已启用' : '规则已禁用'); + loadAlertRules(); + } catch (err) { showToast('操作失败: ' + err.message, 'error'); } + } + async function deleteAlertRule(ruleId) { + if (!confirm('确定删除此告警规则?')) return; + try { + await apiCall(`${API_BASE}/alert-rules/${ruleId}`, { method: 'DELETE' }); + showToast('规则已删除'); loadAlertRules(); + } catch (err) { showToast('删除失败: ' + err.message, 'error'); } + } + + // ==================== SYSTEM MANAGEMENT ==================== + + // --- System Config Modal --- + async function showSystemConfigModal() { + showModal(` +

系统运行时配置

+
+
+
+
+ + +
+

修改仅影响当前进程,重启后恢复 .env 配置值

+ `, { maxWidth: '550px' }); + try { + const c = await apiCall(`${API_BASE}/system/config`); + document.getElementById('sysConfigForm').innerHTML = ` +
+
+
+
+
+
+
+
+
+
+
+

只读: 默认限流 ${c.rate_limit_default}, 写限流 ${c.rate_limit_write}, 缓存 ${c.geocoding_cache_size}

+
+ `; + } catch (err) { document.getElementById('sysConfigForm').innerHTML = '

加载失败: ' + err.message + '

'; } + } + + async function submitSystemConfig() { + const body = { + data_retention_days: parseInt(document.getElementById('cfgRetDays').value), + data_cleanup_interval_hours: parseInt(document.getElementById('cfgCleanInt').value), + tcp_idle_timeout: parseInt(document.getElementById('cfgTcpTimeout').value), + track_max_points: parseInt(document.getElementById('cfgTrackMax').value), + fence_lbs_tolerance_meters: parseInt(document.getElementById('cfgFenceLbs').value), + fence_wifi_tolerance_meters: parseInt(document.getElementById('cfgFenceWifi').value), + fence_min_inside_seconds: parseInt(document.getElementById('cfgFenceDebounce').value), + fence_check_enabled: document.getElementById('cfgFenceCheck').value === 'true', + }; + try { + await apiCall(`${API_BASE}/system/config`, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + showToast('配置已保存 (进程级)'); + closeModal(); + } catch (err) { showToast('保存失败: ' + err.message, 'error'); } + } + + // --- Audit Log Modal --- + async function showAuditLogModal() { + showModal(` +

操作审计日志

+
+
+
+
+
+ +
+
+
+ + - + +
+ + +
+ `, { maxWidth: '700px' }); + loadAuditLogs(1); + } + + let _auditPage = 1; + async function loadAuditLogs(page) { + if (page < 1) return; + _auditPage = page; + const method = document.getElementById('auditMethod')?.value || ''; + const pathQ = document.getElementById('auditPath')?.value || ''; + let url = `${API_BASE}/system/audit-logs?page=${page}&page_size=15`; + if (method) url += `&method=${method}`; + if (pathQ) url += `&path_contains=${encodeURIComponent(pathQ)}`; + try { + const d = await apiCall(url); + const container = document.getElementById('auditLogContainer'); + if (!d.items.length) { container.innerHTML = '

暂无审计日志

'; } + else { + const methodColors = { POST: '#22c55e', PUT: '#3b82f6', DELETE: '#ef4444' }; + container.innerHTML = '' + + d.items.map(a => ` + + + + + + + + `).join('') + '
时间方法路径状态操作人IP耗时
${new Date(a.created_at).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit',second:'2-digit'})}${a.method}${a.path}${a.status_code}${a.operator||'-'}${a.client_ip||'-'}${a.duration_ms!=null?a.duration_ms+'ms':'-'}
'; + } + document.getElementById('auditPageInfo').textContent = `${d.page} / ${d.total_pages} (${d.total}条)`; + document.getElementById('auditPrevBtn').disabled = d.page <= 1; + document.getElementById('auditNextBtn').disabled = d.page >= d.total_pages; + } catch (err) { document.getElementById('auditLogContainer').innerHTML = '

加载失败: ' + err.message + '

'; } + } + + async function cleanAuditLogs() { + const days = prompt('删除多少天前的审计日志?', '90'); + if (!days || isNaN(days) || parseInt(days) < 1) return; + try { + const res = await apiCall(`${API_BASE}/system/audit-logs?days=${parseInt(days)}`, { method: 'DELETE' }); + showToast(`已清理 ${res?.deleted || 0} 条审计日志`); + loadAuditLogs(1); + } catch (err) { showToast('清理失败: ' + err.message, 'error'); } + } + + // --- Backup Modal --- + async function showBackupModal() { + showModal(` +

数据库备份管理

+
+ + +
+
+ + `, { maxWidth: '500px' }); + loadBackupList(); + } + + async function loadBackupList() { + try { + const backups = await apiCall(`${API_BASE}/system/backups`); + const container = document.getElementById('backupListContainer'); + if (!backups.length) { container.innerHTML = '

暂无备份文件

'; return; } + container.innerHTML = backups.map(b => ` +
+
+ ${b.filename} + ${b.size_mb} MB + ${new Date(b.created_at).toLocaleString('zh-CN')} +
+ +
+ `).join(''); + } catch (err) { document.getElementById('backupListContainer').innerHTML = '

加载失败: ' + err.message + '

'; } + } + + async function createBackup() { + showToast('正在创建备份...', 'info'); + try { + const resp = await fetch(`${API_BASE}/system/backup`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + if (!resp.ok) throw new Error(`HTTP ${resp.status}`); + const blob = await resp.blob(); + const cd = resp.headers.get('content-disposition'); + let filename = 'backup.db'; + if (cd) { const m = cd.match(/filename="?(.+?)"?$/); if (m) filename = m[1]; } + const url = URL.createObjectURL(blob); + const a = document.createElement('a'); a.href = url; a.download = filename; a.click(); + URL.revokeObjectURL(url); + showToast('备份已创建并开始下载'); + loadBackupList(); + } catch (err) { showToast('备份失败: ' + err.message, 'error'); } + } + + async function deleteBackup(filename) { + if (!confirm(`确定删除备份 ${filename}?`)) return; + try { + await apiCall(`${API_BASE}/system/backups/${filename}`, { method: 'DELETE' }); + showToast('备份已删除'); + loadBackupList(); + } catch (err) { showToast('删除失败: ' + err.message, 'error'); } + } + + // --- Firmware Info Modal --- + async function showFirmwareModal() { + showModal(` +

设备固件信息

+
+ + +
+
+ + `, { maxWidth: '650px' }); + loadFirmwareList(); + } + + async function loadFirmwareList() { + const status = document.getElementById('fwStatusFilter')?.value || ''; + let url = `${API_BASE}/system/firmware`; + if (status) url += `?status=${status}`; + try { + const devices = await apiCall(url); + const container = document.getElementById('fwListContainer'); + if (!devices.length) { container.innerHTML = '

暂无设备

'; return; } + container.innerHTML = '' + + devices.map(d => ` + + + + + + + + `).join('') + '
IMEI名称型号状态ICCIDIMSI最后登录
${d.imei}${d.name||'-'}${d.device_type}${d.status==='online'?'在线':'离线'}${d.iccid||'-'}${d.imsi||'-'}${d.last_login?new Date(d.last_login).toLocaleString('zh-CN',{month:'2-digit',day:'2-digit',hour:'2-digit',minute:'2-digit'}):'-'}
'; + } catch (err) { document.getElementById('fwListContainer').innerHTML = '

加载失败: ' + err.message + '

'; } + } + + async function batchQueryVersion() { + if (!confirm('向所有在线设备发送 VERSION# 查询指令?')) return; + try { + const res = await apiCall(`${API_BASE}/commands/broadcast`, { + method: 'POST', + headers: {'Content-Type':'application/json'}, + body: JSON.stringify({ command_type: 'online_cmd', command_content: 'VERSION#' }), + }); + showToast(`已发送: ${res.sent}台, 未连接: ${res.failed}台`); + } catch (err) { showToast('发送失败: ' + err.message, 'error'); } + } + // ==================== INITIALIZATION ==================== document.addEventListener('DOMContentLoaded', () => { navigateTo('dashboard');