feat: 信标设备绑定 + 蓝牙模式管理 + 系统管理增强 + 数据导出

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

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

Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
2026-04-01 07:06:37 +00:00
parent 9daa81621c
commit 9cd9dd9d76
19 changed files with 3403 additions and 100 deletions

View File

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

View File

@@ -51,6 +51,7 @@ async def init_db() -> None:
from app.models import ( # noqa: F401
AlarmRecord,
AttendanceRecord,
AuditLog,
BluetoothRecord,
CommandLog,
Device,

View File

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

View File

@@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded
from app.database import init_db, async_session, engine
from app.tcp_server import tcp_manager
from app.config import settings
from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, 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")

101
app/middleware.py Normal file
View File

@@ -0,0 +1,101 @@
"""
Audit logging middleware.
Records POST/PUT/DELETE requests to the audit_logs table.
"""
import json
import time
import logging
from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint
from starlette.requests import Request
from starlette.responses import Response
from app.database import async_session
from app.models import AuditLog
logger = logging.getLogger(__name__)
# Methods to audit
_AUDIT_METHODS = {"POST", "PUT", "DELETE"}
# Paths to skip (noisy or non-business endpoints)
_SKIP_PREFIXES = ("/ws", "/health", "/docs", "/redoc", "/openapi.json")
# Max request body size to store (bytes)
_MAX_BODY_SIZE = 4096
def _get_client_ip(request: Request) -> str:
"""Extract real client IP from proxy headers."""
forwarded = request.headers.get("X-Forwarded-For")
if forwarded:
return forwarded.split(",")[0].strip()
cf_ip = request.headers.get("CF-Connecting-IP")
if cf_ip:
return cf_ip
return request.client.host if request.client else "unknown"
def _get_operator(request: Request) -> str | None:
"""Extract operator name from request state (set by verify_api_key)."""
key_info = getattr(request.state, "key_info", None)
if key_info and isinstance(key_info, dict):
return key_info.get("name")
return None
class AuditMiddleware(BaseHTTPMiddleware):
async def dispatch(self, request: Request, call_next: RequestResponseEndpoint) -> Response:
if request.method not in _AUDIT_METHODS:
return await call_next(request)
path = request.url.path
if any(path.startswith(p) for p in _SKIP_PREFIXES):
return await call_next(request)
# Only audit /api/ routes
if not path.startswith("/api/"):
return await call_next(request)
# Read request body for audit (cache it for downstream)
body_bytes = await request.body()
request_body = None
if body_bytes and len(body_bytes) <= _MAX_BODY_SIZE:
try:
request_body = json.loads(body_bytes)
# Redact sensitive fields
if isinstance(request_body, dict):
for key in ("password", "api_key", "key", "secret", "token"):
if key in request_body:
request_body[key] = "***REDACTED***"
except (json.JSONDecodeError, UnicodeDecodeError):
request_body = None
start = time.monotonic()
response = await call_next(request)
duration_ms = int((time.monotonic() - start) * 1000)
# Extract operator from dependency injection result
operator = _get_operator(request)
# Build response summary
response_summary = f"HTTP {response.status_code}"
try:
async with async_session() as session:
async with session.begin():
session.add(AuditLog(
method=request.method,
path=path,
status_code=response.status_code,
operator=operator,
client_ip=_get_client_ip(request),
request_body=request_body,
response_summary=response_summary,
duration_ms=duration_ms,
))
except Exception:
logger.debug("Failed to write audit log for %s %s", request.method, path)
return response

View File

@@ -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"<BeaconConfig(id={self.id}, mac={self.beacon_mac}, name={self.name})>"
class DeviceBeaconBinding(Base):
"""Many-to-many binding between devices and beacons."""
__tablename__ = "device_beacon_bindings"
__table_args__ = (
Index("ix_dbb_device_beacon", "device_id", "beacon_id", unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
beacon_id: Mapped[int] = mapped_column(
Integer, ForeignKey("beacon_configs.id", ondelete="CASCADE"), index=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<DeviceBeaconBinding(device_id={self.device_id}, beacon_id={self.beacon_id})>"
class FenceConfig(Base):
"""Geofence configuration for area monitoring."""
@@ -403,6 +425,94 @@ class CommandLog(Base):
)
class DeviceGroup(Base):
"""Device groups for organizing devices."""
__tablename__ = "device_groups"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), unique=True, nullable=False)
description: Mapped[str | None] = mapped_column(Text, nullable=True)
color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
def __repr__(self) -> str:
return f"<DeviceGroup(id={self.id}, name={self.name})>"
class DeviceGroupMember(Base):
"""Many-to-many: devices belong to groups."""
__tablename__ = "device_group_members"
__table_args__ = (
Index("ix_dgm_device_group", "device_id", "group_id", unique=True),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
device_id: Mapped[int] = mapped_column(
Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False
)
group_id: Mapped[int] = mapped_column(
Integer, ForeignKey("device_groups.id", ondelete="CASCADE"), index=True, nullable=False
)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<DeviceGroupMember(device_id={self.device_id}, group_id={self.group_id})>"
class AlertRule(Base):
"""Configurable alert rules for custom thresholds."""
__tablename__ = "alert_rules"
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
name: Mapped[str] = mapped_column(String(100), nullable=False)
rule_type: Mapped[str] = mapped_column(String(30), nullable=False)
# rule_type values: low_battery, no_heartbeat, fence_stay, speed_limit, offline_duration
conditions: Mapped[dict] = mapped_column(JSON, nullable=False)
# e.g. {"threshold": 20} for low_battery, {"minutes": 30} for no_heartbeat
is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False)
device_ids: Mapped[str | None] = mapped_column(Text, nullable=True)
# comma-separated device IDs, null = all devices
group_id: Mapped[int | None] = mapped_column(Integer, nullable=True)
# apply to a device group
description: Mapped[str | None] = mapped_column(Text, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
updated_at: Mapped[datetime | None] = mapped_column(
DateTime, default=_utcnow, onupdate=_utcnow, nullable=True
)
def __repr__(self) -> str:
return f"<AlertRule(id={self.id}, name={self.name}, type={self.rule_type})>"
class AuditLog(Base):
"""Audit trail for write operations (POST/PUT/DELETE)."""
__tablename__ = "audit_logs"
__table_args__ = (
Index("ix_audit_created", "created_at"),
)
id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True)
method: Mapped[str] = mapped_column(String(10), nullable=False) # POST, PUT, DELETE
path: Mapped[str] = mapped_column(String(500), nullable=False)
status_code: Mapped[int] = mapped_column(Integer, nullable=False)
operator: Mapped[str | None] = mapped_column(String(100), nullable=True) # API key name or "master"
client_ip: Mapped[str | None] = mapped_column(String(50), nullable=True)
request_body: Mapped[dict | None] = mapped_column(JSON, nullable=True)
response_summary: Mapped[str | None] = mapped_column(String(500), nullable=True)
duration_ms: Mapped[int | None] = mapped_column(Integer, nullable=True)
created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False)
def __repr__(self) -> str:
return f"<AuditLog(id={self.id}, {self.method} {self.path})>"
class ApiKey(Base):
"""API keys for external system authentication."""

View File

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

109
app/routers/alert_rules.py Normal file
View File

@@ -0,0 +1,109 @@
"""
Alert Rules Router - 告警规则配置接口
API endpoints for alert rule CRUD operations.
"""
from fastapi import APIRouter, Depends, HTTPException
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import require_write
from app.models import AlertRule
from app.schemas import (
APIResponse,
AlertRuleCreate,
AlertRuleResponse,
AlertRuleUpdate,
)
router = APIRouter(prefix="/api/alert-rules", tags=["Alert Rules / 告警规则"])
@router.get(
"",
response_model=APIResponse[list[AlertRuleResponse]],
summary="获取告警规则列表 / List alert rules",
)
async def list_rules(db: AsyncSession = Depends(get_db)):
"""获取所有告警规则。"""
result = await db.execute(select(AlertRule).order_by(AlertRule.id))
rules = list(result.scalars().all())
return APIResponse(data=[AlertRuleResponse.model_validate(r) for r in rules])
@router.post(
"",
response_model=APIResponse[AlertRuleResponse],
status_code=201,
summary="创建告警规则 / Create alert rule",
dependencies=[Depends(require_write)],
)
async def create_rule(body: AlertRuleCreate, db: AsyncSession = Depends(get_db)):
"""创建新告警规则。"""
rule = AlertRule(
name=body.name,
rule_type=body.rule_type,
conditions=body.conditions,
is_active=body.is_active,
device_ids=body.device_ids,
group_id=body.group_id,
description=body.description,
)
db.add(rule)
await db.flush()
await db.refresh(rule)
return APIResponse(data=AlertRuleResponse.model_validate(rule))
@router.get(
"/{rule_id}",
response_model=APIResponse[AlertRuleResponse],
summary="获取告警规则详情 / Get alert rule",
)
async def get_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""获取告警规则详情。"""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
return APIResponse(data=AlertRuleResponse.model_validate(rule))
@router.put(
"/{rule_id}",
response_model=APIResponse[AlertRuleResponse],
summary="更新告警规则 / Update alert rule",
dependencies=[Depends(require_write)],
)
async def update_rule(
rule_id: int, body: AlertRuleUpdate, db: AsyncSession = Depends(get_db)
):
"""更新告警规则。"""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
update_data = body.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(rule, k, v)
await db.flush()
await db.refresh(rule)
return APIResponse(data=AlertRuleResponse.model_validate(rule))
@router.delete(
"/{rule_id}",
response_model=APIResponse,
summary="删除告警规则 / Delete alert rule",
dependencies=[Depends(require_write)],
)
async def delete_rule(rule_id: int, db: AsyncSession = Depends(get_db)):
"""删除告警规则。"""
result = await db.execute(select(AlertRule).where(AlertRule.id == rule_id))
rule = result.scalar_one_or_none()
if not rule:
raise HTTPException(status_code=404, detail=f"规则 {rule_id} 不存在")
await db.delete(rule)
await db.flush()
return APIResponse(message="规则已删除")

View File

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

View File

@@ -16,6 +16,8 @@ from app.schemas import (
BeaconConfigCreate,
BeaconConfigResponse,
BeaconConfigUpdate,
BeaconDeviceDetail,
DeviceBeaconBindRequest,
PaginatedList,
)
from app.services import beacon_service
@@ -49,6 +51,76 @@ async def list_beacons(
)
@router.post(
"/setup-bluetooth-mode",
response_model=APIResponse,
summary="批量配置蓝牙打卡模式 / Setup BT clock-in mode for devices",
dependencies=[Depends(require_write)],
)
async def setup_bluetooth_mode(
device_ids: list[int] | None = Query(default=None, description="指定设备ID不传则所有在线设备"),
db: AsyncSession = Depends(get_db),
):
result = await beacon_service.setup_bluetooth_mode(db, device_ids)
if result["error"]:
return APIResponse(code=1, message=result["error"], data=result)
return APIResponse(
message=f"{result['total']} 台: {result['sent']} 台已配置, {result['failed']} 台失败",
data=result,
)
@router.post(
"/restore-normal-mode",
response_model=APIResponse,
summary="恢复正常模式 / Restore devices to normal (smart) mode",
dependencies=[Depends(require_write)],
)
async def restore_normal_mode(
device_ids: list[int] | None = Query(default=None, description="指定设备ID不传则所有在线设备"),
db: AsyncSession = Depends(get_db),
):
result = await beacon_service.restore_normal_mode(db, device_ids)
if result["error"]:
return APIResponse(code=1, message=result["error"], data=result)
return APIResponse(
message=f"{result['total']} 台: {result['sent']} 台已恢复, {result['failed']} 台失败",
data=result,
)
@router.post(
"/reverse-sync",
response_model=APIResponse,
summary="从设备反向同步信标配置 / Query devices and update DB bindings",
dependencies=[Depends(require_write)],
)
async def reverse_sync_beacons(db: AsyncSession = Depends(get_db)):
result = await beacon_service.reverse_sync_from_devices(db)
if result["error"]:
return APIResponse(code=1, message=result["error"], data=result)
return APIResponse(
message=f"查询 {result['queried']} 台设备,{result['responded']} 台响应,{result['updated']} 台有变更",
data=result,
)
@router.post(
"/sync-device/{device_id}",
response_model=APIResponse,
summary="同步信标配置到设备 / Sync beacon MACs to device via BTMACSET",
dependencies=[Depends(require_write)],
)
async def sync_device_beacons(device_id: int, db: AsyncSession = Depends(get_db)):
result = await beacon_service.sync_device_beacons(db, device_id)
if result["error"]:
return APIResponse(code=1, message=result["error"], data=result)
return APIResponse(
message=f"已发送 {len(result['commands'])} 条指令,共 {result['mac_count']} 个信标MAC",
data=result,
)
@router.get(
"/{beacon_id}",
response_model=APIResponse[BeaconConfigResponse],
@@ -102,3 +174,52 @@ async def delete_beacon(beacon_id: int, db: AsyncSession = Depends(get_db)):
if not success:
raise HTTPException(status_code=404, detail="Beacon not found")
return APIResponse(message="Beacon deleted")
# ---------------------------------------------------------------------------
# Device-Beacon Binding endpoints
# ---------------------------------------------------------------------------
@router.get(
"/{beacon_id}/devices",
response_model=APIResponse[list[BeaconDeviceDetail]],
summary="获取信标绑定的设备 / Get beacon devices",
)
async def get_beacon_devices(beacon_id: int, db: AsyncSession = Depends(get_db)):
beacon = await beacon_service.get_beacon(db, beacon_id)
if beacon is None:
raise HTTPException(status_code=404, detail="Beacon not found")
items = await beacon_service.get_beacon_devices(db, beacon_id)
return APIResponse(data=[BeaconDeviceDetail(**item) for item in items])
@router.post(
"/{beacon_id}/devices",
response_model=APIResponse,
summary="绑定设备到信标 / Bind devices to beacon",
dependencies=[Depends(require_write)],
)
async def bind_devices(
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
):
result = await beacon_service.bind_devices_to_beacon(db, beacon_id, body.device_ids)
if result.get("error"):
raise HTTPException(status_code=404, detail=result["error"])
return APIResponse(
message=f"绑定完成: 新增{result['created']}, 已绑定{result['already_bound']}, 未找到{result['not_found']}",
data=result,
)
@router.delete(
"/{beacon_id}/devices",
response_model=APIResponse,
summary="解绑设备 / Unbind devices from beacon",
dependencies=[Depends(require_write)],
)
async def unbind_devices(
beacon_id: int, body: DeviceBeaconBindRequest, db: AsyncSession = Depends(get_db),
):
count = await beacon_service.unbind_devices_from_beacon(db, beacon_id, body.device_ids)
return APIResponse(message=f"已解绑 {count} 个设备")

View File

@@ -8,11 +8,13 @@ from datetime import datetime
from typing import Literal
from fastapi import APIRouter, Depends, HTTPException, Query
from fastapi.responses import Response
from sqlalchemy import func, select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import BluetoothRecord
from app.services.export_utils import build_csv_content, csv_filename
from app.schemas import (
APIResponse,
BluetoothRecordResponse,
@@ -86,6 +88,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],

View File

@@ -0,0 +1,210 @@
"""
Device Groups Router - 设备分组管理接口
API endpoints for device group CRUD and membership management.
"""
from fastapi import APIRouter, Body, Depends, HTTPException, Query
from pydantic import BaseModel, Field
from sqlalchemy import func, select, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.dependencies import require_write
from app.models import Device, DeviceGroup, DeviceGroupMember
from app.schemas import (
APIResponse,
DeviceGroupCreate,
DeviceGroupResponse,
DeviceGroupUpdate,
DeviceGroupWithCount,
DeviceResponse,
)
router = APIRouter(prefix="/api/groups", tags=["Device Groups / 设备分组"])
@router.get(
"",
response_model=APIResponse[list[DeviceGroupWithCount]],
summary="获取分组列表 / List device groups",
)
async def list_groups(db: AsyncSession = Depends(get_db)):
"""获取所有设备分组及各组设备数。"""
result = await db.execute(
select(
DeviceGroup,
func.count(DeviceGroupMember.id).label("cnt"),
)
.outerjoin(DeviceGroupMember, DeviceGroup.id == DeviceGroupMember.group_id)
.group_by(DeviceGroup.id)
.order_by(DeviceGroup.name)
)
groups = []
for row in result.all():
g = DeviceGroupResponse.model_validate(row[0])
groups.append(DeviceGroupWithCount(**g.model_dump(), device_count=row[1]))
return APIResponse(data=groups)
@router.post(
"",
response_model=APIResponse[DeviceGroupResponse],
status_code=201,
summary="创建分组 / Create group",
dependencies=[Depends(require_write)],
)
async def create_group(body: DeviceGroupCreate, db: AsyncSession = Depends(get_db)):
"""创建新设备分组。"""
existing = await db.execute(
select(DeviceGroup).where(DeviceGroup.name == body.name)
)
if existing.scalar_one_or_none():
raise HTTPException(status_code=400, detail=f"分组名 '{body.name}' 已存在")
group = DeviceGroup(name=body.name, description=body.description, color=body.color)
db.add(group)
await db.flush()
await db.refresh(group)
return APIResponse(data=DeviceGroupResponse.model_validate(group))
@router.put(
"/{group_id}",
response_model=APIResponse[DeviceGroupResponse],
summary="更新分组 / Update group",
dependencies=[Depends(require_write)],
)
async def update_group(
group_id: int, body: DeviceGroupUpdate, db: AsyncSession = Depends(get_db)
):
"""更新分组信息。"""
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
update_data = body.model_dump(exclude_unset=True)
for k, v in update_data.items():
setattr(group, k, v)
await db.flush()
await db.refresh(group)
return APIResponse(data=DeviceGroupResponse.model_validate(group))
@router.delete(
"/{group_id}",
response_model=APIResponse,
summary="删除分组 / Delete group",
dependencies=[Depends(require_write)],
)
async def delete_group(group_id: int, db: AsyncSession = Depends(get_db)):
"""删除分组(级联删除成员关系,不删除设备)。"""
result = await db.execute(select(DeviceGroup).where(DeviceGroup.id == group_id))
group = result.scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
# Clear group_id on devices
devices_result = await db.execute(select(Device).where(Device.group_id == group_id))
for d in devices_result.scalars().all():
d.group_id = None
await db.delete(group)
await db.flush()
return APIResponse(message="分组已删除")
@router.get(
"/{group_id}/devices",
response_model=APIResponse[list[DeviceResponse]],
summary="获取分组设备 / Get group devices",
)
async def get_group_devices(group_id: int, db: AsyncSession = Depends(get_db)):
"""获取分组内的设备列表。"""
result = await db.execute(
select(Device)
.join(DeviceGroupMember, Device.id == DeviceGroupMember.device_id)
.where(DeviceGroupMember.group_id == group_id)
.order_by(Device.name)
)
devices = list(result.scalars().all())
return APIResponse(data=[DeviceResponse.model_validate(d) for d in devices])
class GroupMemberRequest(BaseModel):
device_ids: list[int] = Field(..., min_length=1, max_length=500, description="设备ID列表")
@router.post(
"/{group_id}/devices",
response_model=APIResponse[dict],
summary="添加设备到分组 / Add devices to group",
dependencies=[Depends(require_write)],
)
async def add_devices_to_group(
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
):
"""添加设备到分组(幂等,已存在的跳过)。"""
# Verify group exists
group = (await db.execute(
select(DeviceGroup).where(DeviceGroup.id == group_id)
)).scalar_one_or_none()
if not group:
raise HTTPException(status_code=404, detail=f"分组 {group_id} 不存在")
# Get existing members
existing = await db.execute(
select(DeviceGroupMember.device_id).where(
DeviceGroupMember.group_id == group_id,
DeviceGroupMember.device_id.in_(body.device_ids),
)
)
existing_ids = {row[0] for row in existing.all()}
added = 0
for did in body.device_ids:
if did not in existing_ids:
db.add(DeviceGroupMember(device_id=did, group_id=group_id))
added += 1
# Also update device.group_id
if body.device_ids:
devices = await db.execute(
select(Device).where(Device.id.in_(body.device_ids))
)
for d in devices.scalars().all():
d.group_id = group_id
await db.flush()
return APIResponse(
message=f"已添加 {added} 台设备到分组",
data={"added": added, "already_in_group": len(existing_ids)},
)
@router.delete(
"/{group_id}/devices",
response_model=APIResponse[dict],
summary="从分组移除设备 / Remove devices from group",
dependencies=[Depends(require_write)],
)
async def remove_devices_from_group(
group_id: int, body: GroupMemberRequest, db: AsyncSession = Depends(get_db)
):
"""从分组移除设备。"""
result = await db.execute(
delete(DeviceGroupMember).where(
DeviceGroupMember.group_id == group_id,
DeviceGroupMember.device_id.in_(body.device_ids),
)
)
# Clear group_id on removed devices
devices = await db.execute(
select(Device).where(
Device.id.in_(body.device_ids),
Device.group_id == group_id,
)
)
for d in devices.scalars().all():
d.group_id = None
await db.flush()
return APIResponse(
message=f"已移除 {result.rowcount} 台设备",
data={"removed": result.rowcount},
)

View File

@@ -6,9 +6,13 @@ API endpoints for device CRUD operations and statistics.
import math
from fastapi import APIRouter, Depends, HTTPException, Query, Request
from fastapi.responses import Response
from sqlalchemy import select
from sqlalchemy.ext.asyncio import AsyncSession
from app.database import get_db
from app.models import Device
from app.services.export_utils import build_csv_content, csv_filename
from app.schemas import (
APIResponse,
BatchDeviceCreateRequest,
@@ -74,6 +78,40 @@ async def device_stats(db: AsyncSession = Depends(get_db)):
return APIResponse(data=stats)
@router.get(
"/export",
summary="导出设备列表 CSV / Export devices CSV",
)
async def export_devices(
status: str | None = Query(default=None, description="状态过滤 (online/offline)"),
search: str | None = Query(default=None, description="搜索IMEI或名称"),
db: AsyncSession = Depends(get_db),
):
"""导出设备列表为 CSV 文件,支持状态/搜索筛选。"""
from sqlalchemy import or_
query = select(Device)
if status:
query = query.where(Device.status == status)
if search:
pattern = f"%{search}%"
query = query.where(or_(Device.imei.ilike(pattern), Device.name.ilike(pattern)))
query = query.order_by(Device.id)
result = await db.execute(query)
devices = list(result.scalars().all())
headers = ["ID", "IMEI", "名称", "类型", "状态", "电量%", "信号", "ICCID", "IMSI", "最后心跳", "最后登录", "创建时间"]
fields = ["id", "imei", "name", "device_type", "status", "battery_level", "gsm_signal", "iccid", "imsi", "last_heartbeat", "last_login", "created_at"]
content = build_csv_content(headers, devices, fields)
return Response(
content=content,
media_type="text/csv; charset=utf-8",
headers={"Content-Disposition": f"attachment; filename={csv_filename('devices')}"},
)
@router.get(
"/imei/{imei}",
response_model=APIResponse[DeviceResponse],

View File

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

299
app/routers/system.py Normal file
View File

@@ -0,0 +1,299 @@
"""
System Management Router - 系统管理接口
Audit logs, runtime config, database backup, firmware info.
"""
import shutil
import time
from datetime import datetime, timedelta
from pathlib import Path
from fastapi import APIRouter, Depends, Query, Request
from fastapi.responses import FileResponse
from sqlalchemy import select, func, delete
from sqlalchemy.ext.asyncio import AsyncSession
from app.config import settings, now_cst
from app.database import get_db
from app.dependencies import require_admin
from app.models import AuditLog, Device
from app.schemas import (
APIResponse,
AuditLogResponse,
PaginatedList,
SystemConfigResponse,
SystemConfigUpdate,
)
router = APIRouter(prefix="/api/system", tags=["System / 系统管理"])
# ---------------------------------------------------------------------------
# Audit Logs
# ---------------------------------------------------------------------------
@router.get(
"/audit-logs",
response_model=APIResponse[PaginatedList[AuditLogResponse]],
summary="获取审计日志 / List audit logs",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def list_audit_logs(
page: int = Query(1, ge=1),
page_size: int = Query(20, ge=1, le=100),
method: str | None = Query(None, description="Filter by HTTP method (POST/PUT/DELETE)"),
path_contains: str | None = Query(None, description="Filter by path keyword"),
operator: str | None = Query(None, description="Filter by operator name"),
start_time: datetime | None = None,
end_time: datetime | None = None,
db: AsyncSession = Depends(get_db),
):
"""查询操作审计日志 (admin only)。"""
query = select(AuditLog)
count_query = select(func.count(AuditLog.id))
if method:
query = query.where(AuditLog.method == method.upper())
count_query = count_query.where(AuditLog.method == method.upper())
if path_contains:
query = query.where(AuditLog.path.contains(path_contains))
count_query = count_query.where(AuditLog.path.contains(path_contains))
if operator:
query = query.where(AuditLog.operator == operator)
count_query = count_query.where(AuditLog.operator == operator)
if start_time:
query = query.where(AuditLog.created_at >= start_time)
count_query = count_query.where(AuditLog.created_at >= start_time)
if end_time:
query = query.where(AuditLog.created_at <= end_time)
count_query = count_query.where(AuditLog.created_at <= end_time)
total = (await db.execute(count_query)).scalar() or 0
total_pages = (total + page_size - 1) // page_size
rows = await db.execute(
query.order_by(AuditLog.id.desc())
.offset((page - 1) * page_size)
.limit(page_size)
)
items = [AuditLogResponse.model_validate(r) for r in rows.scalars().all()]
return APIResponse(
data=PaginatedList(
items=items, total=total, page=page,
page_size=page_size, total_pages=total_pages,
)
)
@router.delete(
"/audit-logs",
response_model=APIResponse,
summary="清理审计日志 / Clean audit logs",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def clean_audit_logs(
days: int = Query(..., ge=1, le=3650, description="Delete logs older than N days"),
db: AsyncSession = Depends(get_db),
):
"""删除N天前的审计日志 (admin only)。"""
cutoff = now_cst() - timedelta(days=days)
result = await db.execute(
delete(AuditLog).where(AuditLog.created_at < cutoff)
)
return APIResponse(
message=f"已删除 {result.rowcount}{days} 天前的审计日志",
data={"deleted": result.rowcount},
)
# ---------------------------------------------------------------------------
# System Config
# ---------------------------------------------------------------------------
@router.get(
"/config",
response_model=APIResponse[SystemConfigResponse],
summary="获取运行时配置 / Get runtime config",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def get_config():
"""获取当前运行时配置参数 (admin only)。"""
return APIResponse(data=SystemConfigResponse(
data_retention_days=settings.DATA_RETENTION_DAYS,
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_write=settings.RATE_LIMIT_WRITE,
track_max_points=settings.TRACK_MAX_POINTS,
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
))
@router.put(
"/config",
response_model=APIResponse[SystemConfigResponse],
summary="更新运行时配置 / Update runtime config",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def update_config(body: SystemConfigUpdate):
"""更新运行时配置参数 (admin only)。仅影响当前进程,重启后恢复 .env 值。"""
update_data = body.model_dump(exclude_unset=True)
for key, value in update_data.items():
attr_name = key.upper()
if hasattr(settings, attr_name):
object.__setattr__(settings, attr_name, value)
return APIResponse(
message=f"已更新 {len(update_data)} 项配置 (进程级,重启后恢复)",
data=SystemConfigResponse(
data_retention_days=settings.DATA_RETENTION_DAYS,
data_cleanup_interval_hours=settings.DATA_CLEANUP_INTERVAL_HOURS,
tcp_idle_timeout=settings.TCP_IDLE_TIMEOUT,
fence_check_enabled=settings.FENCE_CHECK_ENABLED,
fence_lbs_tolerance_meters=settings.FENCE_LBS_TOLERANCE_METERS,
fence_wifi_tolerance_meters=settings.FENCE_WIFI_TOLERANCE_METERS,
fence_min_inside_seconds=settings.FENCE_MIN_INSIDE_SECONDS,
rate_limit_default=settings.RATE_LIMIT_DEFAULT,
rate_limit_write=settings.RATE_LIMIT_WRITE,
track_max_points=settings.TRACK_MAX_POINTS,
geocoding_cache_size=settings.GEOCODING_CACHE_SIZE,
),
)
# ---------------------------------------------------------------------------
# Database Backup
# ---------------------------------------------------------------------------
@router.post(
"/backup",
summary="创建数据库备份 / Create database backup",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def create_backup():
"""
触发 SQLite 数据库备份,返回备份文件下载 (admin only)。
使用 SQLite 原生 backup API 确保一致性。
"""
import aiosqlite
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
if not Path(db_path).exists():
return APIResponse(code=500, message="Database file not found")
backup_dir = Path(db_path).parent / "backups"
backup_dir.mkdir(exist_ok=True)
timestamp = now_cst().strftime("%Y%m%d_%H%M%S")
backup_name = f"badge_admin_backup_{timestamp}.db"
backup_path = backup_dir / backup_name
# Use SQLite online backup API for consistency
async with aiosqlite.connect(db_path) as source:
async with aiosqlite.connect(str(backup_path)) as dest:
await source.backup(dest)
size_mb = round(backup_path.stat().st_size / 1024 / 1024, 2)
return FileResponse(
path=str(backup_path),
filename=backup_name,
media_type="application/octet-stream",
headers={
"X-Backup-Size-MB": str(size_mb),
"X-Backup-Timestamp": timestamp,
},
)
@router.get(
"/backups",
response_model=APIResponse[list[dict]],
summary="列出数据库备份 / List backups",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def list_backups():
"""列出所有已有的数据库备份文件 (admin only)。"""
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
backup_dir = Path(db_path).parent / "backups"
if not backup_dir.exists():
return APIResponse(data=[])
backups = []
for f in sorted(backup_dir.glob("*.db"), reverse=True):
backups.append({
"filename": f.name,
"size_mb": round(f.stat().st_size / 1024 / 1024, 2),
"created_at": datetime.fromtimestamp(f.stat().st_mtime).isoformat(),
})
return APIResponse(data=backups)
@router.delete(
"/backups/{filename}",
response_model=APIResponse,
summary="删除数据库备份 / Delete backup",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def delete_backup(filename: str):
"""删除指定的数据库备份文件 (admin only)。"""
db_path = settings.DATABASE_URL.replace("sqlite+aiosqlite:///", "")
backup_path = Path(db_path).parent / "backups" / filename
if not backup_path.exists() or not backup_path.suffix == ".db":
return APIResponse(code=404, message="Backup not found")
# Prevent path traversal
if ".." in filename or "/" in filename:
return APIResponse(code=400, message="Invalid filename")
backup_path.unlink()
return APIResponse(message=f"已删除备份 {filename}")
# ---------------------------------------------------------------------------
# Device Firmware Info
# ---------------------------------------------------------------------------
@router.get(
"/firmware",
response_model=APIResponse[list[dict]],
summary="设备固件信息 / Device firmware info",
dependencies=[Depends(require_admin)] if settings.API_KEY else [],
)
async def list_firmware_info(
status: str | None = Query(None, description="Filter by device status"),
db: AsyncSession = Depends(get_db),
):
"""
获取所有设备的固件相关信息 (型号、ICCID、IMSI等)。
实际固件版本需通过 VERSION# 指令查询 (admin only)。
"""
query = select(Device).order_by(Device.id)
if status:
query = query.where(Device.status == status)
result = await db.execute(query)
devices = result.scalars().all()
firmware_list = []
for d in devices:
firmware_list.append({
"device_id": d.id,
"imei": d.imei,
"name": d.name,
"device_type": d.device_type,
"status": d.status,
"iccid": d.iccid,
"imsi": d.imsi,
"last_login": d.last_login.isoformat() if d.last_login else None,
"last_heartbeat": d.last_heartbeat.isoformat() if d.last_heartbeat else None,
})
return APIResponse(data=firmware_list)

View File

@@ -59,6 +59,7 @@ class DeviceUpdate(BaseModel):
imsi: str | None = Field(None, max_length=20)
timezone: str | None = Field(None, max_length=30)
language: str | None = Field(None, max_length=10)
group_id: int | None = None
class DeviceResponse(DeviceBase):
@@ -72,6 +73,7 @@ class DeviceResponse(DeviceBase):
last_login: datetime | None = None
iccid: str | None = None
imsi: str | None = None
group_id: int | None = None
created_at: datetime
updated_at: datetime | None = None
@@ -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")

View File

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

View File

@@ -0,0 +1,59 @@
"""
CSV Export Utilities - CSV 数据导出工具
Shared helpers for streaming CSV responses.
"""
import csv
import io
from collections.abc import AsyncIterator, Sequence
from datetime import datetime
from typing import Any
def _format_value(value: Any) -> str:
"""Format a single value for CSV output."""
if value is None:
return ""
if isinstance(value, datetime):
return value.strftime("%Y-%m-%d %H:%M:%S")
if isinstance(value, bool):
return "" if value else ""
if isinstance(value, float):
return f"{value:.6f}" if abs(value) < 1000 else f"{value:.2f}"
if isinstance(value, (dict, list)):
import json
return json.dumps(value, ensure_ascii=False)
return str(value)
def build_csv_content(
headers: list[str],
rows: Sequence[Any],
field_extractors: list[Any],
) -> str:
"""Build complete CSV string with BOM for Excel compatibility.
Args:
headers: Column header names (Chinese).
rows: ORM model instances or row tuples.
field_extractors: List of callables or attribute name strings.
"""
buf = io.StringIO()
buf.write("\ufeff") # UTF-8 BOM for Excel
writer = csv.writer(buf)
writer.writerow(headers)
for row in rows:
values = []
for ext in field_extractors:
if callable(ext):
values.append(_format_value(ext(row)))
else:
values.append(_format_value(getattr(row, ext, "")))
writer.writerow(values)
return buf.getvalue()
def csv_filename(prefix: str) -> str:
"""Generate a timestamped CSV filename."""
ts = datetime.now().strftime("%Y%m%d_%H%M%S")
return f"{prefix}_{ts}.csv"

File diff suppressed because it is too large Load Diff