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