diff --git a/app/geocoding.py b/app/geocoding.py index f9b687a..bb5b23d 100644 --- a/app/geocoding.py +++ b/app/geocoding.py @@ -145,32 +145,35 @@ async def geocode_location( location_type: "lbs"/"wifi" for 2G(GSM), "lbs_4g"/"wifi_4g" for 4G(LTE). """ - # Check cache first + # Build cache key (include neighbor cells hash for accuracy) + nb_hash = tuple(sorted((nc.get("lac", 0), nc.get("cell_id", 0)) for nc in neighbor_cells)) if neighbor_cells else () + 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) + cache_key = (mcc, mnc or 0, lac, cell_id, nb_hash) cached = _cell_cache.get_cached(cache_key) if cached is not None: return cached - api_key = AMAP_KEY - if not api_key: - return (None, None) + # Map location_type to v5 network parameter + # Valid: GSM, GPRS, EDGE, HSUPA, HSDPA, WCDMA, NR (LTE is NOT valid!) + _NETWORK_MAP = {"lbs_4g": "WCDMA", "wifi_4g": "WCDMA", "gps_4g": "WCDMA"} + network = _NETWORK_MAP.get(location_type or "", "GSM") - # Determine network type from location_type - is_4g = location_type in ("lbs_4g", "wifi_4g", "gps_4g") + result: tuple[Optional[float], Optional[float]] = (None, None) - # 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, - ) + # Try v5 API first (requires bts with cage field + network param) + if AMAP_KEY: + result = await _geocode_amap_v5( + mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, + imei=imei, api_key=AMAP_KEY, network=network, + ) - # Fallback to legacy API if v5 fails and hardware key is available + # Fallback to legacy API if v5 fails if result[0] is None and AMAP_HARDWARE_KEY: result = await _geocode_amap_legacy( mcc, mnc, lac, cell_id, wifi_list, neighbor_cells, @@ -181,20 +184,25 @@ async def geocode_location( 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) + _cell_cache.put(cache_key, result) return result -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""" +def _build_bts( + mcc: Optional[int], mnc: Optional[int], lac: Optional[int], cell_id: Optional[int], + *, include_cage: bool = False, +) -> str: + """Build bts parameter. v5 API uses cage field, legacy does not.""" if mcc is not None and lac is not None and cell_id is not None: - return f"{mcc},{mnc or 0},{lac},{cell_id},-65,0" + base = f"{mcc},{mnc or 0},{lac},{cell_id},-65" + return f"{base},0" if include_cage else base return "" def _build_nearbts( - neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int] + neighbor_cells: Optional[list[dict]], mcc: Optional[int], mnc: Optional[int], + *, include_cage: bool = False, ) -> list[str]: """Build nearbts (neighbor cell) parts.""" parts = [] @@ -203,7 +211,8 @@ def _build_nearbts( 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 - parts.append(f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal},0") + base = f"{mcc or 460},{mnc or 0},{nc_lac},{nc_cid},{nc_signal}" + parts.append(f"{base},0" if include_cage else base) return parts @@ -259,22 +268,20 @@ def _select_mmac(wifi_parts: list[str]) -> tuple[str, list[str]]: 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, + *, imei: Optional[str] = None, api_key: str, network: str = "GSM", ) -> 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 + Key requirements: + - POST method, key in URL params, data in form body + - bts MUST have 6 fields: mcc,mnc,lac,cellid,signal,cage + - network MUST be valid: GSM/GPRS/EDGE/HSUPA/HSDPA/WCDMA/NR (LTE is NOT valid!) + - For 4G LTE, use WCDMA as network value + - accesstype: 1=移动网络, 2=WiFi (requires mmac + 2+ macs) """ - bts = _build_bts(mcc, mnc, lac, cell_id) - nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc) + bts = _build_bts(mcc, mnc, lac, cell_id, include_cage=True) + nearbts_parts = _build_nearbts(neighbor_cells, mcc, mnc, include_cage=True) wifi_parts = _build_wifi_parts(wifi_list) if not bts and not wifi_parts: @@ -288,7 +295,7 @@ async def _geocode_amap_v5( body: dict[str, str] = { "accesstype": accesstype, "cdma": "0", - "network": "LTE" if is_4g else "GSM", + "network": network, "diu": imei or _settings.GEOCODING_DEFAULT_IMEI, "show_fields": "formatted_address", } @@ -309,6 +316,8 @@ async def _geocode_amap_v5( url = f"https://restapi.amap.com/v5/position/IoT?key={api_key}" + logger.info("Amap v5 request body: %s", body) + try: async with aiohttp.ClientSession() as session: async with session.post( @@ -316,6 +325,7 @@ async def _geocode_amap_v5( ) as resp: if resp.status == 200: data = await resp.json(content_type=None) + logger.info("Amap v5 response: %s", data) if data.get("status") == "1": position = data.get("position", {}) location = position.get("location", "") if isinstance(position, dict) else "" @@ -333,8 +343,8 @@ async def _geocode_amap_v5( else: infocode = data.get("infocode", "") logger.warning( - "Amap v5 geocode error: %s (code=%s)", - data.get("info", ""), infocode, + "Amap v5 geocode error: %s (code=%s) body=%s", + data.get("info", ""), infocode, body, ) else: logger.warning("Amap v5 geocode HTTP %d", resp.status) @@ -390,6 +400,8 @@ async def _geocode_amap_legacy( url = "https://apilocate.amap.com/position" + logger.info("Amap legacy request params: %s", {k: v for k, v in params.items() if k != 'key'}) + try: async with aiohttp.ClientSession() as session: async with session.get( @@ -397,6 +409,7 @@ async def _geocode_amap_legacy( ) as resp: if resp.status == 200: data = await resp.json(content_type=None) + logger.info("Amap legacy response: %s", data) if data.get("status") == "1" and data.get("result"): result = data["result"] location = result.get("location", "") diff --git a/app/models.py b/app/models.py index 6191da2..e01f3e8 100644 --- a/app/models.py +++ b/app/models.py @@ -199,6 +199,10 @@ class AttendanceRecord(Base): attendance_type: Mapped[str] = mapped_column( String(20), nullable=False ) # clock_in, clock_out + attendance_source: Mapped[str] = mapped_column( + String(20), nullable=False, default="device", + server_default="device", + ) # device (0xB0/0xB1), bluetooth (0xB2), fence (auto) protocol_number: Mapped[int] = mapped_column(Integer, nullable=False) gps_positioned: Mapped[bool] = mapped_column(Boolean, default=False, nullable=False) latitude: Mapped[float | None] = mapped_column(Float, nullable=True) diff --git a/app/routers/attendance.py b/app/routers/attendance.py index f259369..a99864d 100644 --- a/app/routers/attendance.py +++ b/app/routers/attendance.py @@ -30,6 +30,7 @@ router = APIRouter(prefix="/api/attendance", tags=["Attendance / 考勤管理"]) async def list_attendance( device_id: int | None = Query(default=None, description="设备ID / Device ID"), attendance_type: str | None = Query(default=None, description="考勤类型 / Attendance type"), + attendance_source: str | None = Query(default=None, description="考勤来源 / Source (device/bluetooth/fence)"), start_time: datetime | None = Query(default=None, description="开始时间 / Start time (ISO 8601)"), end_time: datetime | None = Query(default=None, description="结束时间 / End time (ISO 8601)"), page: int = Query(default=1, ge=1, description="页码 / Page number"), @@ -37,8 +38,8 @@ async def list_attendance( db: AsyncSession = Depends(get_db), ): """ - 获取考勤记录列表,支持按设备、考勤类型、时间范围过滤。 - List attendance records with filters for device, type, and time range. + 获取考勤记录列表,支持按设备、考勤类型、来源、时间范围过滤。 + List attendance records with filters for device, type, source, and time range. """ query = select(AttendanceRecord) count_query = select(func.count(AttendanceRecord.id)) @@ -51,6 +52,10 @@ async def list_attendance( query = query.where(AttendanceRecord.attendance_type == attendance_type) count_query = count_query.where(AttendanceRecord.attendance_type == attendance_type) + if attendance_source: + query = query.where(AttendanceRecord.attendance_source == attendance_source) + count_query = count_query.where(AttendanceRecord.attendance_source == attendance_source) + if start_time: query = query.where(AttendanceRecord.recorded_at >= start_time) count_query = count_query.where(AttendanceRecord.recorded_at >= start_time) diff --git a/app/routers/locations.py b/app/routers/locations.py index ebb48fc..bed6e26 100644 --- a/app/routers/locations.py +++ b/app/routers/locations.py @@ -7,7 +7,7 @@ import math from datetime import datetime from fastapi import APIRouter, Body, Depends, HTTPException, Query -from sqlalchemy import select, delete +from sqlalchemy import func, select, delete from sqlalchemy.ext.asyncio import AsyncSession from app.dependencies import require_write @@ -140,6 +140,68 @@ async def device_track( ) +@router.post( + "/batch-delete", + response_model=APIResponse[dict], + summary="批量删除位置记录 / Batch delete location records", + dependencies=[Depends(require_write)], +) +async def batch_delete_locations( + location_ids: list[int] = Body(..., min_length=1, max_length=500, embed=True), + db: AsyncSession = Depends(get_db), +): + """批量删除位置记录(最多500条)。""" + result = await db.execute( + delete(LocationRecord).where(LocationRecord.id.in_(location_ids)) + ) + await db.flush() + return APIResponse( + message=f"已删除 {result.rowcount} 条位置记录", + data={"deleted": result.rowcount, "requested": len(location_ids)}, + ) + + +@router.post( + "/delete-no-coords", + response_model=APIResponse[dict], + summary="删除无坐标位置记录 / Delete location records without coordinates", + dependencies=[Depends(require_write)], +) +async def delete_no_coord_locations( + device_id: int | None = Body(default=None, description="设备ID (可选,不传则所有设备)"), + start_time: str | None = Body(default=None, description="开始时间 ISO 8601"), + end_time: str | None = Body(default=None, description="结束时间 ISO 8601"), + db: AsyncSession = Depends(get_db), +): + """删除经纬度为空的位置记录,可按设备和时间范围过滤。""" + from datetime import datetime as dt + + conditions = [ + (LocationRecord.latitude.is_(None)) | (LocationRecord.longitude.is_(None)) + ] + if device_id is not None: + conditions.append(LocationRecord.device_id == device_id) + if start_time: + conditions.append(LocationRecord.recorded_at >= dt.fromisoformat(start_time)) + if end_time: + conditions.append(LocationRecord.recorded_at <= dt.fromisoformat(end_time)) + + # Count first + count_result = await db.execute( + select(func.count(LocationRecord.id)).where(*conditions) + ) + count = count_result.scalar() or 0 + + if count > 0: + await db.execute(delete(LocationRecord).where(*conditions)) + await db.flush() + + return APIResponse( + message=f"已删除 {count} 条无坐标记录", + data={"deleted": count}, + ) + + @router.get( "/{location_id}", response_model=APIResponse[LocationRecordResponse], diff --git a/app/schemas.py b/app/schemas.py index 2c87170..7062b49 100644 --- a/app/schemas.py +++ b/app/schemas.py @@ -229,6 +229,7 @@ class HeartbeatListResponse(APIResponse[PaginatedList[HeartbeatRecordResponse]]) class AttendanceRecordBase(BaseModel): device_id: int attendance_type: str = Field(..., max_length=20) + attendance_source: str = Field(default="device", max_length=20) # device, bluetooth, fence protocol_number: int gps_positioned: bool = False latitude: float | None = Field(None, ge=-90, le=90) @@ -243,7 +244,7 @@ class AttendanceRecordBase(BaseModel): lac: int | None = None cell_id: int | None = None wifi_data: list[dict[str, Any]] | None = None - lbs_data: list[dict[str, Any]] | None = None + lbs_data: list[dict[str, Any]] | dict[str, Any] | None = None address: str | None = None recorded_at: datetime diff --git a/app/services/fence_checker.py b/app/services/fence_checker.py index aaec1c2..9109c4b 100644 --- a/app/services/fence_checker.py +++ b/app/services/fence_checker.py @@ -4,18 +4,18 @@ 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 datetime import datetime, timedelta, timezone from typing import Optional -from sqlalchemy import select +from sqlalchemy import func, select from sqlalchemy.ext.asyncio import AsyncSession from app.config import now_cst, settings from app.models import ( AttendanceRecord, + Device, DeviceFenceBinding, DeviceFenceState, FenceConfig, @@ -159,6 +159,31 @@ def _get_tolerance_for_location_type(location_type: str) -> float: return 0.0 +# --------------------------------------------------------------------------- +# Daily dedup helper +# --------------------------------------------------------------------------- + + +async def _has_attendance_today( + session: AsyncSession, + device_id: int, + attendance_type: str, +) -> bool: + """Check if device already has an attendance record of given type today (CST).""" + cst_now = datetime.now(timezone(timedelta(hours=8))) + day_start = cst_now.replace(hour=0, minute=0, second=0, microsecond=0).replace(tzinfo=None) + day_end = day_start + timedelta(days=1) + result = await session.execute( + select(func.count()).select_from(AttendanceRecord).where( + AttendanceRecord.device_id == device_id, + AttendanceRecord.attendance_type == attendance_type, + AttendanceRecord.recorded_at >= day_start, + AttendanceRecord.recorded_at < day_end, + ) + ) + return (result.scalar() or 0) > 0 + + # --------------------------------------------------------------------------- # Main fence check entry point # --------------------------------------------------------------------------- @@ -173,6 +198,11 @@ async def check_device_fences( location_type: str, address: Optional[str], recorded_at: datetime, + *, + mcc: Optional[int] = None, + mnc: Optional[int] = None, + lac: Optional[int] = None, + cell_id: Optional[int] = None, ) -> list[dict]: """Check all bound active fences for a device. Returns attendance events. @@ -192,6 +222,17 @@ async def check_device_fences( if not fences: return [] + # Query device for latest battery/signal info (from heartbeats) + device = await session.get(Device, device_id) + device_info = { + "battery_level": device.battery_level if device else None, + "gsm_signal": device.gsm_signal if device else None, + "mcc": mcc, + "mnc": mnc, + "lac": lac, + "cell_id": cell_id, + } + tolerance = _get_tolerance_for_location_type(location_type) events: list[dict] = [] now = now_cst() @@ -224,21 +265,28 @@ async def check_device_fences( _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) + # Daily dedup: only one clock_in per device per day + if await _has_attendance_today(session, device_id, "clock_in"): + logger.info( + "Fence skip clock_in: device=%d fence=%d(%s) already clocked in today", + device_id, fence.id, fence.name, + ) + else: + attendance = _create_attendance( + device_id, imei, "clock_in", latitude, longitude, + address, recorded_at, fence, device_info, + ) + 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, - ) + 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 @@ -252,21 +300,28 @@ async def check_device_fences( _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) + # Daily dedup: only one clock_out per device per day + if await _has_attendance_today(session, device_id, "clock_out"): + logger.info( + "Fence skip clock_out: device=%d fence=%d(%s) already clocked out today", + device_id, fence.id, fence.name, + ) + else: + attendance = _create_attendance( + device_id, imei, "clock_out", latitude, longitude, + address, recorded_at, fence, device_info, + ) + 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, - ) + 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: @@ -315,18 +370,27 @@ def _create_attendance( address: Optional[str], recorded_at: datetime, fence: FenceConfig, + device_info: Optional[dict] = None, ) -> AttendanceRecord: """Create an auto-generated fence attendance record.""" + info = device_info or {} return AttendanceRecord( device_id=device_id, imei=imei, attendance_type=attendance_type, + attendance_source="fence", protocol_number=0, # synthetic, not from device protocol gps_positioned=True, latitude=latitude, longitude=longitude, address=address, recorded_at=recorded_at, + battery_level=info.get("battery_level"), + gsm_signal=info.get("gsm_signal"), + mcc=info.get("mcc"), + mnc=info.get("mnc"), + lac=info.get("lac"), + cell_id=info.get("cell_id"), lbs_data={ "source": "fence_auto", "fence_id": fence.id, diff --git a/app/static/admin.html b/app/static/admin.html index 20c5022..cc0f2e5 100644 --- a/app/static/admin.html +++ b/app/static/admin.html @@ -132,6 +132,9 @@ .panel-item:hover .panel-item-actions { display: flex; } .panel-action-btn { width: 24px; height: 24px; border-radius: 4px; border: none; background: #4b5563; color: #e5e7eb; font-size: 10px; cursor: pointer; display: flex; align-items: center; justify-content: center; } .panel-action-btn:hover { background: #2563eb; } + .fence-tab { padding: 8px 16px; border: none; background: transparent; color: #9ca3af; cursor: pointer; font-size: 13px; border-bottom: 2px solid transparent; transition: all 0.2s; } + .fence-tab:hover { color: #e5e7eb; background: #374151; } + .fence-tab.active { color: #60a5fa; border-bottom-color: #3b82f6; background: rgba(59,130,246,0.1); } .panel-footer { padding: 6px 12px; border-top: 1px solid #374151; font-size: 11px; color: #6b7280; text-align: center; flex-shrink: 0; } .panel-expand-btn { position: absolute; left: 0; top: 50%; transform: translateY(-50%); background: #1f2937; border: 1px solid #374151; border-left: none; border-radius: 0 6px 6px 0; padding: 8px 4px; color: #9ca3af; cursor: pointer; z-index: 5; display: none; } .side-panel.collapsed ~ .page-main-content .panel-expand-btn { display: block; } @@ -402,7 +405,7 @@
| 设备ID | 类型 | 纬度 | @@ -468,7 +480,7 @@|||||||
|---|---|---|---|---|---|---|---|---|---|
| 请选择设备并查询 | |||||||||
| 请选择设备并查询 | |||||||||