From 1d06cc5415ce153da91835997651d2555ae90ff1 Mon Sep 17 00:00:00 2001 From: default Date: Fri, 27 Mar 2026 13:04:11 +0000 Subject: [PATCH] =?UTF-8?q?feat:=20=E9=AB=98=E5=BE=B7IoT=20v5=20API?= =?UTF-8?q?=E5=8D=87=E7=BA=A7=E3=80=81=E7=94=B5=E5=AD=90=E5=9B=B4=E6=A0=8F?= =?UTF-8?q?=E7=AE=A1=E7=90=86=E3=80=81=E8=AE=BE=E5=A4=87=E7=BB=91=E5=AE=9A?= =?UTF-8?q?=E8=87=AA=E5=8A=A8=E8=80=83=E5=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 前向地理编码升级为高德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 --- .env.example | 20 +- app/config.py | 20 +- app/dependencies.py | 3 +- app/geocoding.py | 269 +++++++- app/main.py | 8 +- app/models.py | 86 ++- app/routers/commands.py | 6 +- app/routers/fences.py | 119 ++++ app/routers/locations.py | 22 +- app/schemas.py | 99 +++ app/services/beacon_service.py | 3 +- app/services/device_service.py | 7 +- app/services/fence_checker.py | 360 ++++++++++ app/services/fence_service.py | 208 ++++++ app/static/admin.html | 1179 +++++++++++++++++++++++++++++--- app/tcp_server.py | 75 +- app/websocket_manager.py | 6 +- 17 files changed, 2303 insertions(+), 187 deletions(-) create mode 100644 app/routers/fences.py create mode 100644 app/services/fence_checker.py create mode 100644 app/services/fence_service.py diff --git a/.env.example b/.env.example index 1a05bcd..1da016b 100644 --- a/.env.example +++ b/.env.example @@ -25,19 +25,13 @@ # Track query max points (default: 10000) # TRACK_MAX_POINTS=10000 -# 天地图 API key (reverse geocoding, free 10k/day) -# Sign up: https://lbs.tianditu.gov.cn/ -# TIANDITU_API_KEY=your_tianditu_key - -# Google Geolocation API (optional, for cell/WiFi geocoding) -# GOOGLE_API_KEY=your_google_key - -# Unwired Labs API (optional, for cell/WiFi geocoding) -# UNWIRED_API_TOKEN=your_unwired_token - -# 高德地图 API (optional, requires enterprise auth for IoT positioning) -# AMAP_KEY=your_amap_key -# AMAP_SECRET=your_amap_secret +# 高德地图 API +# Web服务 key (逆地理编码 + v5 IoT定位, 企业订阅) +# AMAP_KEY=your_amap_web_service_key +# AMAP_SECRET=your_amap_web_service_secret +# 智能硬件定位 key (旧版 apilocate.amap.com 回退, 可选) +# AMAP_HARDWARE_KEY=your_amap_hardware_key +# AMAP_HARDWARE_SECRET=your_amap_hardware_secret # Geocoding cache size # GEOCODING_CACHE_SIZE=10000 diff --git a/app/config.py b/app/config.py index d8798f4..e596cf3 100644 --- a/app/config.py +++ b/app/config.py @@ -1,9 +1,17 @@ +from datetime import datetime, timedelta, timezone from pathlib import Path from typing import Literal from pydantic import Field from pydantic_settings import BaseSettings +CST = timezone(timedelta(hours=8)) + + +def now_cst() -> datetime: + """Return current time in CST (UTC+8) as naive datetime for SQLite.""" + return datetime.now(CST).replace(tzinfo=None) + # Project root directory (where config.py lives → parent = app/ → parent = project root) _PROJECT_ROOT = Path(__file__).resolve().parent.parent _DEFAULT_DB_PATH = _PROJECT_ROOT / "badge_admin.db" @@ -30,8 +38,10 @@ class Settings(BaseSettings): RATE_LIMIT_WRITE: str = Field(default="30/minute", description="Rate limit for write operations") # 高德地图 API (geocoding) - AMAP_KEY: str | None = Field(default=None, description="高德地图 Web API key") - AMAP_SECRET: str | None = Field(default=None, description="高德地图安全密钥") + AMAP_KEY: str | None = Field(default=None, description="高德地图 Web服务 key (逆地理编码/POI搜索)") + AMAP_SECRET: str | None = Field(default=None, description="高德地图 Web服务安全密钥") + AMAP_HARDWARE_KEY: str | None = Field(default=None, description="高德地图智能硬件定位 key (基站/WiFi定位)") + AMAP_HARDWARE_SECRET: str | None = Field(default=None, description="高德地图智能硬件定位安全密钥 (与 HARDWARE_KEY 配对)") # Geocoding GEOCODING_DEFAULT_IMEI: str = Field(default="868120334031363", description="Default IMEI for AMAP geocoding API") @@ -40,6 +50,12 @@ class Settings(BaseSettings): # Track query limit TRACK_MAX_POINTS: int = Field(default=10000, description="Maximum points returned by track endpoint") + # Fence auto-attendance + FENCE_CHECK_ENABLED: bool = Field(default=True, description="Enable automatic fence attendance check on location report") + FENCE_LBS_TOLERANCE_METERS: int = Field(default=200, description="Extra tolerance (meters) for LBS locations in fence check") + FENCE_WIFI_TOLERANCE_METERS: int = Field(default=100, description="Extra tolerance (meters) for WiFi locations in fence check") + FENCE_MIN_INSIDE_SECONDS: int = Field(default=60, description="Minimum seconds between fence attendance transitions (debounce)") + # Data retention DATA_RETENTION_DAYS: int = Field(default=90, description="Days to keep location/heartbeat/alarm/attendance/bluetooth records") DATA_CLEANUP_INTERVAL_HOURS: int = Field(default=24, description="Hours between automatic cleanup runs") diff --git a/app/dependencies.py b/app/dependencies.py index 0cc66cd..dc2a998 100644 --- a/app/dependencies.py +++ b/app/dependencies.py @@ -57,7 +57,8 @@ async def verify_api_key( raise HTTPException(status_code=401, detail="Invalid API key / 无效的 API Key") # Update last_used_at - db_key.last_used_at = datetime.now(timezone.utc) + from app.config import now_cst + db_key.last_used_at = now_cst() await db.flush() return {"permissions": db_key.permissions, "key_id": db_key.id, "name": db_key.name} diff --git a/app/geocoding.py b/app/geocoding.py index d255b68..f9b687a 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -3,7 +3,7 @@ Geocoding service - Convert cell tower / WiFi AP data to lat/lon coordinates, and reverse geocode coordinates to addresses. All services use 高德 (Amap) API exclusively. -- Forward geocoding (cell/WiFi → coords): 高德智能硬件定位 +- Forward geocoding (cell/WiFi → coords): 高德 IoT 定位 v5 API - Reverse geocoding (coords → address): 高德逆地理编码 """ @@ -21,6 +21,8 @@ from app.config import settings as _settings AMAP_KEY: Optional[str] = _settings.AMAP_KEY AMAP_SECRET: Optional[str] = _settings.AMAP_SECRET +AMAP_HARDWARE_KEY: Optional[str] = _settings.AMAP_HARDWARE_KEY +AMAP_HARDWARE_SECRET: Optional[str] = _settings.AMAP_HARDWARE_SECRET _CACHE_MAX_SIZE = _settings.GEOCODING_CACHE_SIZE @@ -68,6 +70,14 @@ def wgs84_to_gcj02(lat: float, lon: float) -> tuple[float, float]: return (lat + d_lat, lon + d_lon) +def gcj02_to_wgs84(lat: float, lon: float) -> tuple[float, float]: + """Convert GCJ-02 to WGS-84 (reverse of wgs84_to_gcj02).""" + if _out_of_china(lat, lon): + return (lat, lon) + gcj_lat, gcj_lon = wgs84_to_gcj02(lat, lon) + return (lat * 2 - gcj_lat, lon * 2 - gcj_lon) + + # --------------------------------------------------------------------------- # LRU Cache # --------------------------------------------------------------------------- @@ -125,52 +135,229 @@ async def geocode_location( wifi_list: Optional[list[dict]] = None, neighbor_cells: Optional[list[dict]] = None, imei: Optional[str] = None, + location_type: Optional[str] = None, ) -> tuple[Optional[float], Optional[float]]: """ Convert cell tower and/or WiFi AP data to lat/lon. - Uses 高德智能硬件定位 API exclusively. + Uses 高德 IoT 定位 v5 API (restapi.amap.com/v5/position/IoT). + Falls back to legacy API (apilocate.amap.com/position) if v5 fails. + + location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE). """ # Check cache first - if mcc is not None and lac is not None and cell_id is not None: + if wifi_list: + wifi_cache_key = tuple(sorted((ap.get("mac", "") for ap in wifi_list))) + cached = _wifi_cache.get_cached(wifi_cache_key) + if cached is not None: + return cached + elif mcc is not None and lac is not None and cell_id is not None: cache_key = (mcc, mnc or 0, lac, cell_id) cached = _cell_cache.get_cached(cache_key) if cached is not None: return cached - if AMAP_KEY: - result = await _geocode_amap(mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, imei=imei) - if result[0] is not None: - if mcc is not None and lac is not None and cell_id is not None: - _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) - return result + api_key = AMAP_KEY + if not api_key: + return (None, None) - return (None, None) + # Determine network type from location_type + is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g") + + # Try v5 API first (POST restapi.amap.com/v5/position/IoT) + result = await _geocode_amap_v5( + mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, + imei=imei, api_key=api_key, is_4g=is_4g, + ) + + # Fallback to legacy API if v5 fails and hardware key is available + if result[0] is None and AMAP_HARDWARE_KEY: + result = await _geocode_amap_legacy( + mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, + imei=imei, api_key=AMAP_HARDWARE_KEY, + ) + + if result[0] is not None: + if wifi_list: + _wifi_cache.put(wifi_cache_key, result) + elif mcc is not None and lac is not None and cell_id is not None: + _cell_cache.put((mcc, mnc or 0, lac, cell_id), result) + + return result -async def _geocode_amap( - mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, *, imei: Optional[str] = None -) -> tuple[Optional[float], Optional[float]]: - """ - Use 高德智能硬件定位 API (apilocate.amap.com/position). - - Returns coordinates (高德 returns GCJ-02). - """ - # Build bts (base station) parameter: mcc,mnc,lac,cellid,signal - bts = "" +def _build_bts(mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int]) -> str: + """Build bts (base station) parameter: mcc,mnc,lac,cellid,signal,cage""" if mcc is not None and lac is not None and cell_id is not None: - bts = f"{mcc},{mnc or 0},{lac},{cell_id},-65" + return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0" + return "" - # Build nearbts (neighbor cells) - nearbts_parts = [] + +def _build_nearbts( + neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int] +) -> list[str]: + """Build nearbts (neighbor cell) parts.""" + parts = [] if neighbor_cells: for nc in neighbor_cells: nc_lac = nc.get("lac", 0) nc_cid = nc.get("cell_id", 0) nc_signal = -(nc.get("rssi", 0)) if nc.get("rssi") else -80 - nearbts_parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}") + parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0") + return parts - # Build macs (WiFi APs): mac,signal,ssid + +def _build_wifi_parts(wifi_list: Optional[list[dict]]) -> list[str]: + """Build WiFi MAC parts: mac,signal,ssid,fresh""" + parts = [] + if wifi_list: + for ap in wifi_list: + mac = ap.get("mac", "") + # v5 API requires colon-separated lowercase MAC + if ":" not in mac: + # Convert raw hex to colon-separated + mac_clean = mac.lower().replace("-", "") + if len(mac_clean) == 12: + mac = ":".join(mac_clean[i:i+2] for i in range(0, 12, 2)) + else: + mac = mac.lower() + else: + mac = mac.lower() + signal = -(ap.get("signal", 0)) if ap.get("signal") else -70 + ssid = ap.get("ssid", "") + parts.append(f"{mac},{signal},{ssid},0") + return parts + + +def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]: + """Select strongest WiFi AP as mmac (connected WiFi), rest as macs. + + v5 API requires mmac when accesstype=2. + Returns (mmac_str, remaining_macs_parts). + """ + if not wifi_parts: + return ("", []) + # Find strongest signal (most negative = weakest, so max of negative values) + # Parts format: "mac,signal,ssid,fresh" + best_idx = 0 + best_signal = -999 + for i, part in enumerate(wifi_parts): + fields = part.split(",") + if len(fields) >= 2: + try: + sig = int(fields[1]) + if sig > best_signal: + best_signal = sig + best_idx = i + except ValueError: + pass + mmac = wifi_parts[best_idx] + remaining = [p for i, p in enumerate(wifi_parts) if i != best_idx] + return (mmac, remaining) + + +async def _geocode_amap_v5( + mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], + wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]], + *, imei: Optional[str] = None, api_key: str, is_4g: bool = False, +) -> tuple[Optional[float], Optional[float]]: + """ + Use 高德 IoT 定位 v5 API (POST restapi.amap.com/v5/position/IoT). + + Key differences from legacy: + - POST method, key in URL params, data in body + - accesstype: 0=未知, 1=移动网络, 2=WiFi + - WiFi requires mmac (connected WiFi) + macs (nearby, 2-30) + - network: GSM(default)/LTE/WCDMA/NR — critical for 4G accuracy + - diu replaces imei + - No digital signature needed + - show_fields can return address directly + """ + bts = _build_bts(mcc, mnc, lac, cell_id) + nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc) + wifi_parts = _build_wifi_parts(wifi_list) + + if not bts and not wifi_parts: + return (None, None) + + # Determine accesstype: 2=WiFi (when we have WiFi data), 1=mobile network + has_wifi = len(wifi_parts) >= 2 # v5 requires 2+ WiFi APs + accesstype = "2" if has_wifi else "1" + + # Build POST body + body: dict[str, str] = { + "accesstype": accesstype, + "cdma": "0", + "network": "LTE" if is_4g else "GSM", + "diu": imei or _settings.GEOCODING_DEFAULT_IMEI, + "show_fields": "formatted_address", + } + + if bts: + body["bts"] = bts + if nearbts_parts: + body["nearbts"] = "|".join(nearbts_parts) + + if has_wifi: + mmac, remaining_macs = _select_mmac(wifi_parts) + body["mmac"] = mmac + if remaining_macs: + body["macs"] = "|".join(remaining_macs) + elif wifi_parts: + # Less than 2 WiFi APs: include as macs anyway, use accesstype=1 + body["macs"] = "|".join(wifi_parts) + + url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}" + + try: + async with aiohttp.ClientSession() as session: + async with session.post( + url, data=body, timeout=aiohttp.ClientTimeout(total=5) + ) as resp: + if resp.status == 200: + data = await resp.json(content_type=None) + if data.get("status") == "1": + position = data.get("position", {}) + location = position.get("location", "") if isinstance(position, dict) else "" + if location and "," in location: + lon_str, lat_str = location.split(",") + gcj_lat = float(lat_str) + gcj_lon = float(lon_str) + lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) + radius = position.get("radius", "?") if isinstance(position, dict) else "?" + logger.info( + "Amap v5 geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f) radius=%s", + gcj_lat, gcj_lon, lat, lon, radius, + ) + return (lat, lon) + else: + infocode = data.get("infocode", "") + logger.warning( + "Amap v5 geocode error: %s (code=%s)", + data.get("info", ""), infocode, + ) + else: + logger.warning("Amap v5 geocode HTTP %d", resp.status) + except Exception as e: + logger.warning("Amap v5 geocode error: %s", e) + + return (None, None) + + +async def _geocode_amap_legacy( + mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], + wifi_list: Optional[list[dict]], neighbor_cells: Optional[list[dict]], + *, imei: Optional[str] = None, api_key: str, +) -> tuple[Optional[float], Optional[float]]: + """ + Legacy 高德智能硬件定位 API (GET apilocate.amap.com/position). + + Used as fallback when v5 API fails. + """ + bts = _build_bts(mcc, mnc, lac, cell_id) + nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc) + + # Build macs (legacy format without fresh field) macs_parts = [] if wifi_list: for ap in wifi_list: @@ -182,7 +369,11 @@ async def _geocode_amap( if not bts and not macs_parts: return (None, None) - params = {"accesstype": "0", "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, "key": AMAP_KEY} + params: dict[str, str] = { + "accesstype": "0", + "imei": imei or _settings.GEOCODING_DEFAULT_IMEI, + "key": api_key, + } if bts: params["bts"] = bts if nearbts_parts: @@ -190,9 +381,11 @@ async def _geocode_amap( if macs_parts: params["macs"] = "|".join(macs_parts) - # Add digital signature - sig = _amap_sign(params) - if sig: + # Only sign if using a key that has its own secret + hw_secret = AMAP_HARDWARE_SECRET + if hw_secret: + sorted_str = "&".join(f"{k}={params[k]}" for k in sorted(params.keys())) + sig = hashlib.md5((sorted_str + hw_secret).encode()).hexdigest() params["sig"] = sig url = "https://apilocate.amap.com/position" @@ -209,20 +402,24 @@ async def _geocode_amap( location = result.get("location", "") if location and "," in location: lon_str, lat_str = location.split(",") - lat = float(lat_str) - lon = float(lon_str) - logger.info("Amap geocode: lat=%.6f, lon=%.6f", lat, lon) + gcj_lat = float(lat_str) + gcj_lon = float(lon_str) + lat, lon = gcj02_to_wgs84(gcj_lat, gcj_lon) + logger.info( + "Amap legacy geocode: GCJ-02(%.6f,%.6f) -> WGS-84(%.6f,%.6f)", + gcj_lat, gcj_lon, lat, lon, + ) return (lat, lon) else: infocode = data.get("infocode", "") if infocode == "10012": - logger.debug("Amap geocode: insufficient permissions (enterprise cert needed)") + logger.debug("Amap legacy geocode: insufficient permissions (enterprise cert needed)") else: - logger.warning("Amap geocode error: %s (code=%s)", data.get("info", ""), infocode) + logger.warning("Amap legacy geocode error: %s (code=%s)", data.get("info", ""), infocode) else: - logger.warning("Amap geocode HTTP %d", resp.status) + logger.warning("Amap legacy geocode HTTP %d", resp.status) except Exception as e: - logger.warning("Amap geocode error: %s", e) + logger.warning("Amap legacy geocode error: %s", e) return (None, None) diff --git a/app/main.py b/app/main.py index cd878e7..ce5747b 100644 --- a/app/main.py +++ b/app/main.py @@ -13,7 +13,7 @@ from slowapi.errors import RateLimitExceeded from app.database import init_db, async_session, engine from app.tcp_server import tcp_manager from app.config import settings -from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, heartbeats, api_keys, ws, geocoding +from app.routers import devices, locations, alarms, attendance, commands, bluetooth, beacons, fences, heartbeats, api_keys, ws, geocoding from app.dependencies import verify_api_key, require_write, require_admin import asyncio @@ -28,11 +28,12 @@ from app.extensions import limiter async def run_data_cleanup(): """Delete records older than DATA_RETENTION_DAYS.""" - from datetime import datetime, timezone, timedelta + from datetime import timedelta from sqlalchemy import delete from app.models import LocationRecord, HeartbeatRecord, AlarmRecord, AttendanceRecord, BluetoothRecord + from app.config import now_cst - cutoff = datetime.now(timezone.utc) - timedelta(days=settings.DATA_RETENTION_DAYS) + cutoff = now_cst() - timedelta(days=settings.DATA_RETENTION_DAYS) total_deleted = 0 async with async_session() as session: async with session.begin(): @@ -176,6 +177,7 @@ app.include_router(attendance.router, dependencies=[*_api_deps]) app.include_router(commands.router, dependencies=[*_api_deps]) app.include_router(bluetooth.router, dependencies=[*_api_deps]) app.include_router(beacons.router, dependencies=[*_api_deps]) +app.include_router(fences.router, dependencies=[*_api_deps]) app.include_router(heartbeats.router, dependencies=[*_api_deps]) app.include_router(api_keys.router, dependencies=[*_api_deps]) app.include_router(ws.router) # WebSocket handles auth internally diff --git a/app/models.py b/app/models.py index 9a9bf2f..6191da2 100644 --- a/app/models.py +++ b/app/models.py @@ -1,4 +1,4 @@ -from datetime import datetime, timezone +from datetime import datetime from sqlalchemy import ( BigInteger, @@ -14,13 +14,10 @@ from sqlalchemy import ( from sqlalchemy.dialects.sqlite import JSON from sqlalchemy.orm import Mapped, mapped_column, relationship +from app.config import now_cst as _utcnow from app.database import Base -def _utcnow() -> datetime: - return datetime.now(timezone.utc) - - class Device(Base): """Registered Bluetooth badge devices.""" @@ -80,6 +77,7 @@ class LocationRecord(Base): device_id: Mapped[int] = mapped_column( Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False ) + imei: Mapped[str | None] = mapped_column(String(20), nullable=True) location_type: Mapped[str] = mapped_column( String(10), nullable=False ) # gps, lbs, wifi, gps_4g, lbs_4g, wifi_4g @@ -125,6 +123,7 @@ class AlarmRecord(Base): device_id: Mapped[int] = mapped_column( Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False ) + imei: Mapped[str | None] = mapped_column(String(20), nullable=True) alarm_type: Mapped[str] = mapped_column( String(30), nullable=False ) # sos, low_battery, power_on, power_off, enter_fence, exit_fence, ... @@ -170,6 +169,7 @@ class HeartbeatRecord(Base): device_id: Mapped[int] = mapped_column( Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False ) + imei: Mapped[str | None] = mapped_column(String(20), nullable=True) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) # 0x13 or 0x36 terminal_info: Mapped[int] = mapped_column(Integer, nullable=False) battery_level: Mapped[int] = mapped_column(Integer, nullable=False) @@ -195,6 +195,7 @@ class AttendanceRecord(Base): device_id: Mapped[int] = mapped_column( Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False ) + imei: Mapped[str | None] = mapped_column(String(20), nullable=True) attendance_type: Mapped[str] = mapped_column( String(20), nullable=False ) # clock_in, clock_out @@ -238,6 +239,7 @@ class BluetoothRecord(Base): device_id: Mapped[int] = mapped_column( Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False ) + imei: Mapped[str | None] = mapped_column(String(20), nullable=True) record_type: Mapped[str] = mapped_column( String(20), nullable=False ) # punch, location @@ -291,6 +293,80 @@ class BeaconConfig(Base): return f"" +class FenceConfig(Base): + """Geofence configuration for area monitoring.""" + + __tablename__ = "fence_configs" + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + name: Mapped[str] = mapped_column(String(100), nullable=False) + fence_type: Mapped[str] = mapped_column(String(20), nullable=False) # circle / polygon / rectangle + # Circle center (WGS-84) or polygon centroid for display + center_lat: Mapped[float | None] = mapped_column(Float, nullable=True) + center_lng: Mapped[float | None] = mapped_column(Float, nullable=True) + radius: Mapped[float | None] = mapped_column(Float, nullable=True) # meters, for circle + # Polygon/rectangle vertices as JSON: [[lng,lat], [lng,lat], ...] (WGS-84) + points: Mapped[str | None] = mapped_column(Text, nullable=True) + color: Mapped[str] = mapped_column(String(20), default="#3b82f6", nullable=False) + fill_color: Mapped[str | None] = mapped_column(String(20), nullable=True) + fill_opacity: Mapped[float] = mapped_column(Float, default=0.2, nullable=False) + description: Mapped[str | None] = mapped_column(Text, nullable=True) + is_active: Mapped[bool] = mapped_column(Integer, default=1, nullable=False) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + updated_at: Mapped[datetime | None] = mapped_column( + DateTime, default=_utcnow, onupdate=_utcnow, nullable=True + ) + + def __repr__(self) -> str: + return f"" + + +class DeviceFenceBinding(Base): + """Many-to-many binding between devices and geofences.""" + + __tablename__ = "device_fence_bindings" + __table_args__ = ( + Index("ix_dfb_device_fence", "device_id", "fence_id", unique=True), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[int] = mapped_column( + Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False + ) + fence_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False + ) + created_at: Mapped[datetime] = mapped_column(DateTime, default=_utcnow, nullable=False) + + def __repr__(self) -> str: + return f"" + + +class DeviceFenceState(Base): + """Runtime state tracking: is a device currently inside a fence?""" + + __tablename__ = "device_fence_states" + __table_args__ = ( + Index("ix_dfs_device_fence", "device_id", "fence_id", unique=True), + ) + + id: Mapped[int] = mapped_column(Integer, primary_key=True, autoincrement=True) + device_id: Mapped[int] = mapped_column( + Integer, ForeignKey("devices.id", ondelete="CASCADE"), index=True, nullable=False + ) + fence_id: Mapped[int] = mapped_column( + Integer, ForeignKey("fence_configs.id", ondelete="CASCADE"), index=True, nullable=False + ) + is_inside: Mapped[bool] = mapped_column(Integer, default=0, nullable=False) + last_transition_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_check_at: Mapped[datetime | None] = mapped_column(DateTime, nullable=True) + last_latitude: Mapped[float | None] = mapped_column(Float, nullable=True) + last_longitude: Mapped[float | None] = mapped_column(Float, nullable=True) + + def __repr__(self) -> str: + return f"" + + class CommandLog(Base): """Log of commands sent to devices.""" diff --git a/app/routers/commands.py b/app/routers/commands.py index c61df1f..05e49c3 100644 --- a/app/routers/commands.py +++ b/app/routers/commands.py @@ -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( diff --git a/app/routers/fences.py b/app/routers/fences.py new file mode 100644 index 0000000..9789d50 --- /dev/null +++ b/app/routers/fences.py @@ -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} 个设备") diff --git a/app/routers/locations.py b/app/routers/locations.py index 45543fd..ebb48fc 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -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") diff --git a/app/schemas.py b/app/schemas.py index 7f7f58b..2c87170 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -121,6 +121,7 @@ class LocationRecordResponse(LocationRecordBase): model_config = ConfigDict(from_attributes=True) id: int + imei: str | None = None created_at: datetime @@ -169,6 +170,7 @@ class AlarmRecordResponse(AlarmRecordBase): model_config = ConfigDict(from_attributes=True) id: int + imei: str | None = None acknowledged: bool created_at: datetime @@ -211,6 +213,7 @@ class HeartbeatRecordResponse(HeartbeatRecordBase): model_config = ConfigDict(from_attributes=True) id: int + imei: str | None = None created_at: datetime @@ -253,6 +256,7 @@ class AttendanceRecordResponse(AttendanceRecordBase): model_config = ConfigDict(from_attributes=True) id: int + imei: str | None = None created_at: datetime @@ -298,6 +302,7 @@ class BluetoothRecordResponse(BluetoothRecordBase): model_config = ConfigDict(from_attributes=True) id: int + imei: str | None = None created_at: datetime @@ -368,6 +373,100 @@ class BeaconConfigResponse(BaseModel): updated_at: datetime | None = None +# --------------------------------------------------------------------------- +# Fence Config schemas +# --------------------------------------------------------------------------- + + +class FenceConfigCreate(BaseModel): + name: str = Field(..., max_length=100, description="围栏名称") + fence_type: Literal["circle", "polygon", "rectangle"] = Field(..., description="围栏类型") + center_lat: float | None = Field(None, ge=-90, le=90, description="中心纬度 (WGS-84)") + center_lng: float | None = Field(None, ge=-180, le=180, description="中心经度 (WGS-84)") + radius: float | None = Field(None, ge=0, description="半径 (米)") + points: str | None = Field(None, description="多边形顶点 JSON [[lng,lat],...]") + color: str = Field(default="#3b82f6", max_length=20, description="边框颜色") + fill_color: str | None = Field(None, max_length=20, description="填充颜色") + fill_opacity: float = Field(default=0.2, ge=0, le=1, description="填充透明度") + description: str | None = Field(None, description="描述") + is_active: bool = Field(default=True, description="是否启用") + + +class FenceConfigUpdate(BaseModel): + name: str | None = Field(None, max_length=100) + fence_type: Literal["circle", "polygon", "rectangle"] | None = None + center_lat: float | None = Field(None, ge=-90, le=90) + center_lng: float | None = Field(None, ge=-180, le=180) + radius: float | None = Field(None, ge=0) + points: str | None = None + color: str | None = Field(None, max_length=20) + fill_color: str | None = Field(None, max_length=20) + fill_opacity: float | None = Field(None, ge=0, le=1) + description: str | None = None + is_active: bool | None = None + + +class FenceConfigResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + name: str + fence_type: str + center_lat: float | None = None + center_lng: float | None = None + radius: float | None = None + points: str | None = None + color: str + fill_color: str | None = None + fill_opacity: float + description: str | None = None + is_active: bool + created_at: datetime + updated_at: datetime | None = None + + +# --------------------------------------------------------------------------- +# Device-Fence Binding schemas +# --------------------------------------------------------------------------- + + +class DeviceFenceBindRequest(BaseModel): + device_ids: list[int] = Field(..., min_length=1, max_length=100, description="设备ID列表") + + +class FenceBindForDeviceRequest(BaseModel): + fence_ids: list[int] = Field(..., min_length=1, max_length=100, description="围栏ID列表") + + +class DeviceFenceBindingResponse(BaseModel): + model_config = ConfigDict(from_attributes=True) + + id: int + device_id: int + fence_id: int + created_at: datetime + + +class FenceDeviceDetail(BaseModel): + """Binding detail with device info.""" + binding_id: int + device_id: int + device_name: str | None = None + imei: str | None = None + is_inside: bool = False + last_check_at: datetime | None = None + + +class DeviceFenceDetail(BaseModel): + """Binding detail with fence info.""" + binding_id: int + fence_id: int + fence_name: str | None = None + fence_type: str | None = None + is_inside: bool = False + last_check_at: datetime | None = None + + # --------------------------------------------------------------------------- # Command Log schemas # --------------------------------------------------------------------------- diff --git a/app/services/beacon_service.py b/app/services/beacon_service.py index afd3502..357d0b3 100644 --- a/app/services/beacon_service.py +++ b/app/services/beacon_service.py @@ -78,7 +78,8 @@ async def update_beacon( update_data = data.model_dump(exclude_unset=True) for key, value in update_data.items(): setattr(beacon, key, value) - beacon.updated_at = datetime.now(timezone.utc) + from app.config import now_cst + beacon.updated_at = now_cst() await db.flush() await db.refresh(beacon) diff --git a/app/services/device_service.py b/app/services/device_service.py index e42e98b..b035c85 100644 --- a/app/services/device_service.py +++ b/app/services/device_service.py @@ -3,7 +3,8 @@ Device Service - 设备管理服务 Provides CRUD operations and statistics for badge devices. """ -from datetime import datetime, timezone +from datetime import datetime +from app.config import now_cst from sqlalchemy import func, select, or_ from sqlalchemy.ext.asyncio import AsyncSession @@ -158,7 +159,7 @@ async def update_device( for field, value in update_fields.items(): setattr(device, field, value) - device.updated_at = datetime.now(timezone.utc) + device.updated_at = now_cst() await db.flush() await db.refresh(device) return device @@ -245,7 +246,7 @@ async def batch_update_devices( devices = await get_devices_by_ids(db, device_ids) found_map = {d.id: d for d in devices} update_fields = update_data.model_dump(exclude_unset=True) - now = datetime.now(timezone.utc) + now = now_cst() results = [] for device_id in device_ids: diff --git a/app/services/fence_checker.py b/app/services/fence_checker.py new file mode 100644 index 0000000..aaec1c2 --- /dev/null +++ b/app/services/fence_checker.py @@ -0,0 +1,360 @@ +"""Fence checker service - geofence judgment engine with auto-attendance. + +Checks whether a device's reported coordinates fall inside its bound fences. +Creates automatic attendance records (clock_in/clock_out) on state transitions. +""" + +import json +import logging +import math +from datetime import datetime +from typing import Optional + +from sqlalchemy import select +from sqlalchemy.ext.asyncio import AsyncSession + +from app.config import now_cst, settings +from app.models import ( + AttendanceRecord, + DeviceFenceBinding, + DeviceFenceState, + FenceConfig, +) + +logger = logging.getLogger(__name__) + +_EARTH_RADIUS_M = 6_371_000.0 + + +# --------------------------------------------------------------------------- +# Geometry helpers (WGS-84) +# --------------------------------------------------------------------------- + + +def haversine_distance(lat1: float, lon1: float, lat2: float, lon2: float) -> float: + """Return distance in meters between two WGS-84 points.""" + rlat1, rlat2 = math.radians(lat1), math.radians(lat2) + dlat = math.radians(lat2 - lat1) + dlon = math.radians(lon2 - lon1) + a = ( + math.sin(dlat / 2) ** 2 + + math.cos(rlat1) * math.cos(rlat2) * math.sin(dlon / 2) ** 2 + ) + return _EARTH_RADIUS_M * 2 * math.atan2(math.sqrt(a), math.sqrt(1 - a)) + + +def is_inside_circle( + lat: float, lon: float, + center_lat: float, center_lng: float, + radius_m: float, +) -> bool: + """Check if point is inside a circle (haversine).""" + return haversine_distance(lat, lon, center_lat, center_lng) <= radius_m + + +def is_inside_polygon( + lat: float, lon: float, + vertices: list[list[float]], +) -> bool: + """Ray-casting algorithm. vertices = [[lng, lat], ...] in WGS-84.""" + n = len(vertices) + if n < 3: + return False + inside = False + j = n - 1 + for i in range(n): + xi, yi = vertices[i][0], vertices[i][1] # lng, lat + xj, yj = vertices[j][0], vertices[j][1] + if ((yi > lat) != (yj > lat)) and ( + lon < (xj - xi) * (lat - yi) / (yj - yi) + xi + ): + inside = not inside + j = i + return inside + + +def is_inside_fence( + lat: float, lon: float, + fence: FenceConfig, + tolerance_m: float = 0, +) -> bool: + """Check if a point is inside a fence, with optional tolerance buffer.""" + if fence.fence_type == "circle": + if fence.center_lat is None or fence.center_lng is None or fence.radius is None: + return False + return is_inside_circle( + lat, lon, + fence.center_lat, fence.center_lng, + fence.radius + tolerance_m, + ) + + # polygon / rectangle: parse points JSON + if not fence.points: + return False + try: + vertices = json.loads(fence.points) + except (json.JSONDecodeError, TypeError): + logger.warning("Fence %d has invalid points JSON", fence.id) + return False + + if not isinstance(vertices, list) or len(vertices) < 3: + return False + + # For polygon with tolerance, check point-in-polygon first + if is_inside_polygon(lat, lon, vertices): + return True + + # If not inside but tolerance > 0, check distance to nearest edge + if tolerance_m > 0: + return _min_distance_to_polygon(lat, lon, vertices) <= tolerance_m + + return False + + +def _min_distance_to_polygon( + lat: float, lon: float, + vertices: list[list[float]], +) -> float: + """Approximate minimum distance from point to polygon edges (meters).""" + min_dist = float("inf") + n = len(vertices) + for i in range(n): + j = (i + 1) % n + # Each vertex is [lng, lat] + dist = _point_to_segment_distance( + lat, lon, + vertices[i][1], vertices[i][0], + vertices[j][1], vertices[j][0], + ) + if dist < min_dist: + min_dist = dist + return min_dist + + +def _point_to_segment_distance( + plat: float, plon: float, + alat: float, alon: float, + blat: float, blon: float, +) -> float: + """Approximate distance from point P to line segment AB (meters).""" + # Project P onto AB using flat-earth approximation (good for short segments) + dx = blon - alon + dy = blat - alat + if dx == 0 and dy == 0: + return haversine_distance(plat, plon, alat, alon) + + t = max(0, min(1, ((plon - alon) * dx + (plat - alat) * dy) / (dx * dx + dy * dy))) + proj_lat = alat + t * dy + proj_lon = alon + t * dx + return haversine_distance(plat, plon, proj_lat, proj_lon) + + +def _get_tolerance_for_location_type(location_type: str) -> float: + """Return tolerance in meters based on location type accuracy.""" + if location_type in ("lbs", "lbs_4g"): + return float(settings.FENCE_LBS_TOLERANCE_METERS) + if location_type in ("wifi", "wifi_4g"): + return float(settings.FENCE_WIFI_TOLERANCE_METERS) + # GPS: no extra tolerance + return 0.0 + + +# --------------------------------------------------------------------------- +# Main fence check entry point +# --------------------------------------------------------------------------- + + +async def check_device_fences( + session: AsyncSession, + device_id: int, + imei: str, + latitude: float, + longitude: float, + location_type: str, + address: Optional[str], + recorded_at: datetime, +) -> list[dict]: + """Check all bound active fences for a device. Returns attendance events. + + Called after each location report is stored. Creates automatic + AttendanceRecords for fence entry/exit transitions. + """ + # 1. Query active fences bound to this device + result = await session.execute( + select(FenceConfig) + .join(DeviceFenceBinding, DeviceFenceBinding.fence_id == FenceConfig.id) + .where( + DeviceFenceBinding.device_id == device_id, + FenceConfig.is_active == 1, + ) + ) + fences = list(result.scalars().all()) + if not fences: + return [] + + tolerance = _get_tolerance_for_location_type(location_type) + events: list[dict] = [] + now = now_cst() + min_interval = settings.FENCE_MIN_INSIDE_SECONDS + + for fence in fences: + currently_inside = is_inside_fence(latitude, longitude, fence, tolerance) + + # 2. Get or create state record + state_result = await session.execute( + select(DeviceFenceState).where( + DeviceFenceState.device_id == device_id, + DeviceFenceState.fence_id == fence.id, + ) + ) + state = state_result.scalar_one_or_none() + + was_inside = bool(state and state.is_inside) + + # 3. Detect transition + if currently_inside and not was_inside: + # ENTRY: outside -> inside = clock_in + if state and state.last_transition_at: + elapsed = (now - state.last_transition_at).total_seconds() + if elapsed < min_interval: + logger.debug( + "Fence %d debounce: %ds < %ds, skip clock_in for device %d", + fence.id, int(elapsed), min_interval, device_id, + ) + _update_state(state, currently_inside, now, latitude, longitude) + continue + + attendance = _create_attendance( + device_id, imei, "clock_in", latitude, longitude, + address, recorded_at, fence, + ) + session.add(attendance) + + event = _build_event( + device_id, imei, fence, "clock_in", + latitude, longitude, address, recorded_at, + ) + events.append(event) + logger.info( + "Fence auto clock_in: device=%d fence=%d(%s)", + device_id, fence.id, fence.name, + ) + + elif not currently_inside and was_inside: + # EXIT: inside -> outside = clock_out + if state and state.last_transition_at: + elapsed = (now - state.last_transition_at).total_seconds() + if elapsed < min_interval: + logger.debug( + "Fence %d debounce: %ds < %ds, skip clock_out for device %d", + fence.id, int(elapsed), min_interval, device_id, + ) + _update_state(state, currently_inside, now, latitude, longitude) + continue + + attendance = _create_attendance( + device_id, imei, "clock_out", latitude, longitude, + address, recorded_at, fence, + ) + session.add(attendance) + + event = _build_event( + device_id, imei, fence, "clock_out", + latitude, longitude, address, recorded_at, + ) + events.append(event) + logger.info( + "Fence auto clock_out: device=%d fence=%d(%s)", + device_id, fence.id, fence.name, + ) + + # 4. Update state + if state is None: + state = DeviceFenceState( + device_id=device_id, + fence_id=fence.id, + is_inside=currently_inside, + last_transition_at=now if (currently_inside != was_inside) else None, + last_check_at=now, + last_latitude=latitude, + last_longitude=longitude, + ) + session.add(state) + else: + if currently_inside != was_inside: + state.last_transition_at = now + state.is_inside = currently_inside + state.last_check_at = now + state.last_latitude = latitude + state.last_longitude = longitude + + await session.flush() + return events + + +def _update_state( + state: DeviceFenceState, + is_inside: bool, + now: datetime, + lat: float, + lon: float, +) -> None: + """Update state fields without creating a transition.""" + state.last_check_at = now + state.last_latitude = lat + state.last_longitude = lon + # Don't update is_inside or last_transition_at during debounce + + +def _create_attendance( + device_id: int, + imei: str, + attendance_type: str, + latitude: float, + longitude: float, + address: Optional[str], + recorded_at: datetime, + fence: FenceConfig, +) -> AttendanceRecord: + """Create an auto-generated fence attendance record.""" + return AttendanceRecord( + device_id=device_id, + imei=imei, + attendance_type=attendance_type, + protocol_number=0, # synthetic, not from device protocol + gps_positioned=True, + latitude=latitude, + longitude=longitude, + address=address, + recorded_at=recorded_at, + lbs_data={ + "source": "fence_auto", + "fence_id": fence.id, + "fence_name": fence.name, + }, + ) + + +def _build_event( + device_id: int, + imei: str, + fence: FenceConfig, + attendance_type: str, + latitude: float, + longitude: float, + address: Optional[str], + recorded_at: datetime, +) -> dict: + """Build a WebSocket broadcast event dict.""" + return { + "device_id": device_id, + "imei": imei, + "fence_id": fence.id, + "fence_name": fence.name, + "attendance_type": attendance_type, + "latitude": latitude, + "longitude": longitude, + "address": address, + "recorded_at": recorded_at.isoformat() if recorded_at else None, + "source": "fence_auto", + } diff --git a/app/services/fence_service.py b/app/services/fence_service.py new file mode 100644 index 0000000..370664e --- /dev/null +++ b/app/services/fence_service.py @@ -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 diff --git a/app/static/admin.html b/app/static/admin.html index 6befb75..20c5022 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -5,8 +5,7 @@ KKS 工牌管理系统 - - +