Files
desungongpai/app/routers/alarms.py
default 9cd9dd9d76 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>
2026-04-01 07:06:37 +00:00

376 lines
14 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
Alarms Router - 报警管理接口
API endpoints for alarm record queries, acknowledgement, and statistics.
"""
import math
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
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,
APIResponse,
PaginatedList,
)
router = APIRouter(prefix="/api/alarms", tags=["Alarms / 报警管理"])
@router.get(
"",
response_model=APIResponse[PaginatedList[AlarmRecordResponse]],
summary="获取报警记录列表 / List alarms",
)
async def list_alarms(
device_id: int | None = Query(default=None, description="设备ID / Device ID"),
alarm_type: str | None = Query(default=None, description="报警类型 / Alarm type"),
alarm_source: str | None = Query(default=None, description="报警来源 / Alarm source (single_fence/multi_fence/lbs/wifi)"),
acknowledged: bool | None = Query(default=None, description="是否已确认 / Acknowledged status"),
start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"),
end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"),
sort_order: Literal["asc", "desc"] = Query(default="desc", description="排序方向 / Sort order (asc/desc)"),
page: int = Query(default=1, ge=1, description="页码 / Page number"),
page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"),
db: AsyncSession = Depends(get_db),
):
"""
获取报警记录列表,支持按设备、报警类型、来源、确认状态、时间范围过滤。
List alarm records with filters for device, alarm type, source, acknowledged status, and time range.
"""
query = select(AlarmRecord)
count_query = select(func.count(AlarmRecord.id))
if device_id is not None:
query = query.where(AlarmRecord.device_id == device_id)
count_query = count_query.where(AlarmRecord.device_id == device_id)
if alarm_type:
query = query.where(AlarmRecord.alarm_type == alarm_type)
count_query = count_query.where(AlarmRecord.alarm_type == alarm_type)
if alarm_source:
query = query.where(AlarmRecord.alarm_source == alarm_source)
count_query = count_query.where(AlarmRecord.alarm_source == alarm_source)
if acknowledged is not None:
query = query.where(AlarmRecord.acknowledged == acknowledged)
count_query = count_query.where(AlarmRecord.acknowledged == acknowledged)
if start_time:
query = query.where(AlarmRecord.recorded_at >= start_time)
count_query = count_query.where(AlarmRecord.recorded_at >= start_time)
if end_time:
query = query.where(AlarmRecord.recorded_at <= end_time)
count_query = count_query.where(AlarmRecord.recorded_at <= end_time)
total_result = await db.execute(count_query)
total = total_result.scalar() or 0
offset = (page - 1) * page_size
order = AlarmRecord.recorded_at.asc() if sort_order == "asc" else AlarmRecord.recorded_at.desc()
query = query.order_by(order).offset(offset).limit(page_size)
result = await db.execute(query)
alarms = list(result.scalars().all())
return APIResponse(
data=PaginatedList(
items=[AlarmRecordResponse.model_validate(a) for a in alarms],
total=total,
page=page,
page_size=page_size,
total_pages=math.ceil(total / page_size) if total else 0,
)
)
@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],
summary="获取报警统计(增强版)/ Get enhanced alarm statistics",
)
async def alarm_stats(
days: int = Query(default=7, ge=1, le=90, description="趋势天数 / Trend days"),
db: AsyncSession = Depends(get_db),
):
"""
增强版报警统计总数、未确认数、按类型分组、按天趋势、平均响应时间、TOP10设备。
Enhanced alarm stats: totals, by type, daily trend, avg response time, top 10 devices.
"""
from app.config import now_cst
now = now_cst()
# Total alarms
total_result = await db.execute(select(func.count(AlarmRecord.id)))
total = total_result.scalar() or 0
# Unacknowledged alarms
unack_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.acknowledged == False) # noqa: E712
)
unacknowledged = unack_result.scalar() or 0
# By type
type_result = await db.execute(
select(AlarmRecord.alarm_type, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_type)
.order_by(func.count(AlarmRecord.id).desc())
)
by_type = {row[0]: row[1] for row in type_result.all()}
# By source
source_result = await db.execute(
select(AlarmRecord.alarm_source, func.count(AlarmRecord.id))
.group_by(AlarmRecord.alarm_source)
)
by_source = {(row[0] or "unknown"): row[1] for row in source_result.all()}
# Daily trend (last N days)
cutoff = now - timedelta(days=days)
trend_result = await db.execute(
select(
func.date(AlarmRecord.recorded_at).label("day"),
func.count(AlarmRecord.id),
)
.where(AlarmRecord.recorded_at >= cutoff)
.group_by("day")
.order_by("day")
)
daily_trend = {str(row[0]): row[1] for row in trend_result.all()}
# Today count
today_start = now.replace(hour=0, minute=0, second=0, microsecond=0)
today_result = await db.execute(
select(func.count(AlarmRecord.id)).where(AlarmRecord.recorded_at >= today_start)
)
today_count = today_result.scalar() or 0
# Top 10 devices by alarm count
top_result = await db.execute(
select(AlarmRecord.device_id, AlarmRecord.imei, func.count(AlarmRecord.id).label("cnt"))
.group_by(AlarmRecord.device_id, AlarmRecord.imei)
.order_by(func.count(AlarmRecord.id).desc())
.limit(10)
)
top_devices = [{"device_id": row[0], "imei": row[1], "count": row[2]} for row in top_result.all()]
return APIResponse(
data={
"total": total,
"unacknowledged": unacknowledged,
"acknowledged": total - unacknowledged,
"today": today_count,
"by_type": by_type,
"by_source": by_source,
"daily_trend": daily_trend,
"top_devices": top_devices,
}
)
class BatchAcknowledgeRequest(BaseModel):
alarm_ids: list[int] = Field(..., min_length=1, max_length=500, description="告警ID列表")
acknowledged: bool = Field(default=True, description="确认状态")
@router.post(
"/batch-acknowledge",
response_model=APIResponse[dict],
summary="批量确认告警 / Batch acknowledge alarms",
dependencies=[Depends(require_write)],
)
async def batch_acknowledge_alarms(
body: BatchAcknowledgeRequest,
db: AsyncSession = Depends(get_db),
):
"""
批量确认或取消确认告警记录最多500条。
Batch acknowledge (or un-acknowledge) alarm records (max 500).
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id.in_(body.alarm_ids))
)
records = list(result.scalars().all())
for r in records:
r.acknowledged = body.acknowledged
await db.flush()
return APIResponse(
message=f"{'确认' if body.acknowledged else '取消确认'} {len(records)} 条告警",
data={"updated": len(records), "requested": len(body.alarm_ids)},
)
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],
summary="批量删除告警记录 / Batch delete alarms",
dependencies=[Depends(require_write)],
)
async def batch_delete_alarms(
body: BatchDeleteAlarmRequest,
db: AsyncSession = Depends(get_db),
):
"""
批量删除告警记录。两种模式:
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},
)
@router.get(
"/{alarm_id}",
response_model=APIResponse[AlarmRecordResponse],
summary="获取报警详情 / Get alarm details",
)
async def get_alarm(alarm_id: int, db: AsyncSession = Depends(get_db)):
"""
按ID获取报警记录详情。
Get alarm record details by ID.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
return APIResponse(data=AlarmRecordResponse.model_validate(alarm))
@router.put(
"/{alarm_id}/acknowledge",
response_model=APIResponse[AlarmRecordResponse],
summary="确认报警 / Acknowledge alarm",
dependencies=[Depends(require_write)],
)
async def acknowledge_alarm(
alarm_id: int,
body: AlarmAcknowledge | None = None,
db: AsyncSession = Depends(get_db),
):
"""
确认(或取消确认)报警记录。
Acknowledge (or un-acknowledge) an alarm record.
"""
result = await db.execute(
select(AlarmRecord).where(AlarmRecord.id == alarm_id)
)
alarm = result.scalar_one_or_none()
if alarm is None:
raise HTTPException(status_code=404, detail=f"Alarm {alarm_id} not found / 未找到报警记录{alarm_id}")
acknowledged = body.acknowledged if body else True
alarm.acknowledged = acknowledged
await db.flush()
await db.refresh(alarm)
return APIResponse(
message="Alarm acknowledged / 报警已确认" if acknowledged else "Alarm un-acknowledged / 已取消确认",
data=AlarmRecordResponse.model_validate(alarm),
)