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:
@@ -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],
|
||||
|
||||
Reference in New Issue
Block a user