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

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