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

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