feat: 高德IoT v5 API升级、电子围栏管理、设备绑定自动考勤
- 前向地理编码升级为高德IoT v5 API (POST restapi.amap.com/v5/position/IoT) - 修复LBS定位偏差: 添加network=LTE参数区分4G/2G, bts格式补充cage字段 - 新增电子围栏管理模块 (circle/polygon/rectangle), 支持地图绘制和POI搜索 - 新增设备-围栏多对多绑定 (DeviceFenceBinding/DeviceFenceState) - 围栏自动考勤引擎 (fence_checker.py): haversine距离、ray-casting多边形判定、容差机制、防抖 - TCP位置上报自动检测围栏进出, 生成考勤记录并WebSocket广播 - 前端围栏页面: 绑定设备弹窗、POI搜索定位、左侧围栏面板 - 新增fence_attendance WebSocket topic via [HAPI](https://hapi.run) Co-Authored-By: HAPI <noreply@hapi.run>
This commit is contained in:
@@ -5,7 +5,7 @@ API endpoints for sending commands / messages to devices and viewing command his
|
||||
|
||||
import logging
|
||||
import math
|
||||
from datetime import datetime, timezone
|
||||
from app.config import now_cst
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Request
|
||||
from pydantic import BaseModel, Field
|
||||
@@ -129,7 +129,7 @@ async def _send_to_device(
|
||||
)
|
||||
|
||||
command_log.status = "sent"
|
||||
command_log.sent_at = datetime.now(timezone.utc)
|
||||
command_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(command_log)
|
||||
|
||||
@@ -290,7 +290,7 @@ async def batch_send_command(request: Request, body: BatchCommandRequest, db: As
|
||||
device.imei, body.command_type, body.command_content
|
||||
)
|
||||
cmd_log.status = "sent"
|
||||
cmd_log.sent_at = datetime.now(timezone.utc)
|
||||
cmd_log.sent_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(cmd_log)
|
||||
results.append(BatchCommandResult(
|
||||
|
||||
119
app/routers/fences.py
Normal file
119
app/routers/fences.py
Normal file
@@ -0,0 +1,119 @@
|
||||
"""Fences Router - geofence management API endpoints."""
|
||||
|
||||
import math
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.dependencies import require_write
|
||||
from app.database import get_db
|
||||
from app.schemas import (
|
||||
APIResponse,
|
||||
DeviceFenceBindRequest,
|
||||
FenceConfigCreate,
|
||||
FenceConfigResponse,
|
||||
FenceConfigUpdate,
|
||||
FenceDeviceDetail,
|
||||
PaginatedList,
|
||||
)
|
||||
from app.services import fence_service
|
||||
|
||||
router = APIRouter(prefix="/api/fences", tags=["Fences"])
|
||||
|
||||
|
||||
@router.get("", response_model=APIResponse[PaginatedList[FenceConfigResponse]])
|
||||
async def list_fences(
|
||||
is_active: bool | None = Query(default=None),
|
||||
search: str | None = Query(default=None),
|
||||
page: int = Query(default=1, ge=1),
|
||||
page_size: int = Query(default=20, ge=1, le=100),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
records, total = await fence_service.get_fences(db, page, page_size, is_active, search)
|
||||
return APIResponse(
|
||||
data=PaginatedList(
|
||||
items=[FenceConfigResponse.model_validate(r) for r in records],
|
||||
total=total, page=page, page_size=page_size,
|
||||
total_pages=math.ceil(total / page_size) if total else 0,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@router.get("/all-active", response_model=APIResponse[list[FenceConfigResponse]])
|
||||
async def get_all_active(db: AsyncSession = Depends(get_db)):
|
||||
fences = await fence_service.get_all_active_fences(db)
|
||||
return APIResponse(data=[FenceConfigResponse.model_validate(f) for f in fences])
|
||||
|
||||
|
||||
@router.get("/{fence_id}", response_model=APIResponse[FenceConfigResponse])
|
||||
async def get_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.post("", response_model=APIResponse[FenceConfigResponse], status_code=201, dependencies=[Depends(require_write)])
|
||||
async def create_fence(body: FenceConfigCreate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.create_fence(db, body)
|
||||
return APIResponse(message="Fence created", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.put("/{fence_id}", response_model=APIResponse[FenceConfigResponse], dependencies=[Depends(require_write)])
|
||||
async def update_fence(fence_id: int, body: FenceConfigUpdate, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.update_fence(db, fence_id, body)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence updated", data=FenceConfigResponse.model_validate(fence))
|
||||
|
||||
|
||||
@router.delete("/{fence_id}", response_model=APIResponse, dependencies=[Depends(require_write)])
|
||||
async def delete_fence(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if not await fence_service.delete_fence(db, fence_id):
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
return APIResponse(message="Fence deleted")
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding endpoints
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
@router.get(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse[list[FenceDeviceDetail]],
|
||||
summary="获取围栏绑定的设备列表",
|
||||
)
|
||||
async def get_fence_devices(fence_id: int, db: AsyncSession = Depends(get_db)):
|
||||
fence = await fence_service.get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
raise HTTPException(status_code=404, detail="Fence not found")
|
||||
items = await fence_service.get_fence_devices(db, fence_id)
|
||||
return APIResponse(data=[FenceDeviceDetail(**item) for item in items])
|
||||
|
||||
|
||||
@router.post(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="绑定设备到围栏",
|
||||
)
|
||||
async def bind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
result = await fence_service.bind_devices_to_fence(db, fence_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(
|
||||
"/{fence_id}/devices",
|
||||
response_model=APIResponse,
|
||||
dependencies=[Depends(require_write)],
|
||||
summary="解绑设备与围栏",
|
||||
)
|
||||
async def unbind_devices(fence_id: int, body: DeviceFenceBindRequest, db: AsyncSession = Depends(get_db)):
|
||||
count = await fence_service.unbind_devices_from_fence(db, fence_id, body.device_ids)
|
||||
return APIResponse(message=f"已解绑 {count} 个设备")
|
||||
@@ -7,9 +7,10 @@ import math
|
||||
from datetime import datetime
|
||||
|
||||
from fastapi import APIRouter, Body, Depends, HTTPException, Query
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy import select, delete
|
||||
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.schemas import (
|
||||
@@ -153,3 +154,22 @@ async def get_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
return APIResponse(data=LocationRecordResponse.model_validate(record))
|
||||
|
||||
|
||||
@router.delete(
|
||||
"/{location_id}",
|
||||
response_model=APIResponse,
|
||||
summary="删除位置记录 / Delete location record",
|
||||
dependencies=[Depends(require_write)],
|
||||
)
|
||||
async def delete_location(location_id: int, db: AsyncSession = Depends(get_db)):
|
||||
"""按ID删除位置记录 / Delete location record by ID."""
|
||||
result = await db.execute(
|
||||
select(LocationRecord).where(LocationRecord.id == location_id)
|
||||
)
|
||||
record = result.scalar_one_or_none()
|
||||
if record is None:
|
||||
raise HTTPException(status_code=404, detail=f"Location {location_id} not found")
|
||||
await db.delete(record)
|
||||
await db.flush()
|
||||
return APIResponse(message="Location record deleted")
|
||||
|
||||
Reference in New Issue
Block a user