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(
|
||||
|
||||
109
app/routers/alert_rules.py
Normal file
109
app/routers/alert_rules.py
Normal 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="规则已删除")
|
||||
@@ -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]],
|
||||
|
||||
@@ -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} 个设备")
|
||||
|
||||
@@ -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],
|
||||
|
||||
210
app/routers/device_groups.py
Normal file
210
app/routers/device_groups.py
Normal 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},
|
||||
)
|
||||
@@ -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],
|
||||
|
||||
@@ -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
299
app/routers/system.py
Normal 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)
|
||||
Reference in New Issue
Block a user