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:
208
app/services/fence_service.py
Normal file
208
app/services/fence_service.py
Normal file
@@ -0,0 +1,208 @@
|
||||
"""Fence Service - CRUD operations for geofence configuration and device bindings."""
|
||||
|
||||
from app.config import now_cst
|
||||
|
||||
from sqlalchemy import delete as sa_delete, func, select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from app.models import Device, DeviceFenceBinding, DeviceFenceState, FenceConfig
|
||||
from app.schemas import FenceConfigCreate, FenceConfigUpdate
|
||||
|
||||
|
||||
async def get_fences(
|
||||
db: AsyncSession,
|
||||
page: int = 1,
|
||||
page_size: int = 20,
|
||||
is_active: bool | None = None,
|
||||
search: str | None = None,
|
||||
) -> tuple[list[FenceConfig], int]:
|
||||
query = select(FenceConfig)
|
||||
count_query = select(func.count(FenceConfig.id))
|
||||
|
||||
if is_active is not None:
|
||||
query = query.where(FenceConfig.is_active == is_active)
|
||||
count_query = count_query.where(FenceConfig.is_active == is_active)
|
||||
|
||||
if search:
|
||||
like = f"%{search}%"
|
||||
cond = FenceConfig.name.ilike(like) | FenceConfig.description.ilike(like)
|
||||
query = query.where(cond)
|
||||
count_query = count_query.where(cond)
|
||||
|
||||
total = (await db.execute(count_query)).scalar() or 0
|
||||
offset = (page - 1) * page_size
|
||||
result = await db.execute(
|
||||
query.order_by(FenceConfig.created_at.desc()).offset(offset).limit(page_size)
|
||||
)
|
||||
return list(result.scalars().all()), total
|
||||
|
||||
|
||||
async def get_all_active_fences(db: AsyncSession) -> list[FenceConfig]:
|
||||
result = await db.execute(
|
||||
select(FenceConfig).where(FenceConfig.is_active == 1).order_by(FenceConfig.name)
|
||||
)
|
||||
return list(result.scalars().all())
|
||||
|
||||
|
||||
async def get_fence(db: AsyncSession, fence_id: int) -> FenceConfig | None:
|
||||
result = await db.execute(select(FenceConfig).where(FenceConfig.id == fence_id))
|
||||
return result.scalar_one_or_none()
|
||||
|
||||
|
||||
async def create_fence(db: AsyncSession, data: FenceConfigCreate) -> FenceConfig:
|
||||
fence = FenceConfig(**data.model_dump())
|
||||
db.add(fence)
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def update_fence(db: AsyncSession, fence_id: int, data: FenceConfigUpdate) -> FenceConfig | None:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return None
|
||||
for key, value in data.model_dump(exclude_unset=True).items():
|
||||
setattr(fence, key, value)
|
||||
fence.updated_at = now_cst()
|
||||
await db.flush()
|
||||
await db.refresh(fence)
|
||||
return fence
|
||||
|
||||
|
||||
async def delete_fence(db: AsyncSession, fence_id: int) -> bool:
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return False
|
||||
# CASCADE FK handles bindings/states, but explicit delete for safety
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(DeviceFenceState.fence_id == fence_id)
|
||||
)
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(DeviceFenceBinding.fence_id == fence_id)
|
||||
)
|
||||
await db.delete(fence)
|
||||
await db.flush()
|
||||
return True
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Device-Fence Binding CRUD
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
async def get_fence_devices(
|
||||
db: AsyncSession, fence_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get devices bound to a fence, with their current fence state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, Device, DeviceFenceState)
|
||||
.join(Device, Device.id == DeviceFenceBinding.device_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.fence_id == fence_id)
|
||||
.order_by(Device.name)
|
||||
)
|
||||
items = []
|
||||
for binding, device, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"device_id": device.id,
|
||||
"device_name": device.name,
|
||||
"imei": device.imei,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def get_device_fences(
|
||||
db: AsyncSession, device_id: int,
|
||||
) -> list[dict]:
|
||||
"""Get fences bound to a device, with current state."""
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding, FenceConfig, DeviceFenceState)
|
||||
.join(FenceConfig, FenceConfig.id == DeviceFenceBinding.fence_id)
|
||||
.outerjoin(
|
||||
DeviceFenceState,
|
||||
(DeviceFenceState.device_id == DeviceFenceBinding.device_id)
|
||||
& (DeviceFenceState.fence_id == DeviceFenceBinding.fence_id),
|
||||
)
|
||||
.where(DeviceFenceBinding.device_id == device_id)
|
||||
.order_by(FenceConfig.name)
|
||||
)
|
||||
items = []
|
||||
for binding, fence, state in result.all():
|
||||
items.append({
|
||||
"binding_id": binding.id,
|
||||
"fence_id": fence.id,
|
||||
"fence_name": fence.name,
|
||||
"fence_type": fence.fence_type,
|
||||
"is_inside": bool(state.is_inside) if state else False,
|
||||
"last_check_at": state.last_check_at if state else None,
|
||||
})
|
||||
return items
|
||||
|
||||
|
||||
async def bind_devices_to_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> dict:
|
||||
"""Bind multiple devices to a fence. Idempotent (skips existing bindings)."""
|
||||
# Verify fence exists
|
||||
fence = await get_fence(db, fence_id)
|
||||
if fence is None:
|
||||
return {"created": 0, "already_bound": 0, "not_found": len(device_ids), "error": "Fence not found"}
|
||||
|
||||
# Verify devices exist
|
||||
result = await db.execute(
|
||||
select(Device.id).where(Device.id.in_(device_ids))
|
||||
)
|
||||
existing_device_ids = set(row[0] for row in result.all())
|
||||
|
||||
# Check existing bindings
|
||||
result = await db.execute(
|
||||
select(DeviceFenceBinding.device_id).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
already_bound_ids = set(row[0] for row in result.all())
|
||||
|
||||
created = 0
|
||||
for did in device_ids:
|
||||
if did not in existing_device_ids:
|
||||
continue
|
||||
if did in already_bound_ids:
|
||||
continue
|
||||
db.add(DeviceFenceBinding(device_id=did, fence_id=fence_id))
|
||||
created += 1
|
||||
|
||||
await db.flush()
|
||||
return {
|
||||
"created": created,
|
||||
"already_bound": len(already_bound_ids & existing_device_ids),
|
||||
"not_found": len(set(device_ids) - existing_device_ids),
|
||||
}
|
||||
|
||||
|
||||
async def unbind_devices_from_fence(
|
||||
db: AsyncSession, fence_id: int, device_ids: list[int],
|
||||
) -> int:
|
||||
"""Unbind devices from a fence. Also cleans up state records."""
|
||||
result = await db.execute(
|
||||
sa_delete(DeviceFenceBinding).where(
|
||||
DeviceFenceBinding.fence_id == fence_id,
|
||||
DeviceFenceBinding.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
# Clean up state records
|
||||
await db.execute(
|
||||
sa_delete(DeviceFenceState).where(
|
||||
DeviceFenceState.fence_id == fence_id,
|
||||
DeviceFenceState.device_id.in_(device_ids),
|
||||
)
|
||||
)
|
||||
await db.flush()
|
||||
return result.rowcount
|
||||
Reference in New Issue
Block a user