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:
2026-03-27 13:04:11 +00:00
parent cde5146bfe
commit 1d06cc5415
17 changed files with 2303 additions and 187 deletions

View File

@@ -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
View 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} 个设备")

View File

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