""" Locations Router - 位置数据接口 API endpoints for querying location records and device tracks. """ import math from datetime import datetime, timedelta from fastapi import APIRouter, Body, Depends, HTTPException, Query from sqlalchemy import func, select, delete, case, extract 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 ( APIResponse, LocationRecordResponse, PaginatedList, ) from app.services import device_service, location_service router = APIRouter(prefix="/api/locations", tags=["Locations / 位置数据"]) @router.get( "", response_model=APIResponse[PaginatedList[LocationRecordResponse]], summary="获取位置记录列表 / List location records", ) async def list_locations( device_id: int | None = Query(default=None, description="设备ID / Device ID"), location_type: str | None = Query(default=None, description="定位类型 / Location type (gps/lbs/wifi)"), exclude_type: str | None = Query(default=None, description="排除定位类型前缀 / Exclude location type prefix (e.g. lbs)"), 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"), page_size: int = Query(default=20, ge=1, le=100, description="每页数量 / Items per page"), db: AsyncSession = Depends(get_db), ): """ 获取位置记录列表,支持按设备、定位类型、时间范围过滤。 List location records with filters for device, location type, and time range. """ records, total = await location_service.get_locations( db, device_id=device_id, location_type=location_type, exclude_type=exclude_type, start_time=start_time, end_time=end_time, page=page, page_size=page_size, ) return APIResponse( data=PaginatedList( items=[LocationRecordResponse.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( "/stats", response_model=APIResponse[dict], summary="位置数据统计 / Location statistics", ) async def location_stats( device_id: int | None = Query(default=None, description="设备ID (可选)"), start_time: datetime | None = Query(default=None, description="开始时间"), end_time: datetime | None = Query(default=None, description="结束时间"), db: AsyncSession = Depends(get_db), ): """ 位置数据统计:总记录数、按定位类型分布、有坐标率、按小时分布(24h)。 Location statistics: total, by type, coordinate rate, hourly distribution. """ filters = [] if device_id is not None: filters.append(LocationRecord.device_id == device_id) if start_time: filters.append(LocationRecord.recorded_at >= start_time) if end_time: filters.append(LocationRecord.recorded_at <= end_time) where = filters if filters else [] # Total q = select(func.count(LocationRecord.id)) if where: q = q.where(*where) total = (await db.execute(q)).scalar() or 0 # With coordinates q2 = select(func.count(LocationRecord.id)).where( LocationRecord.latitude.is_not(None), LocationRecord.longitude.is_not(None) ) if where: q2 = q2.where(*where) with_coords = (await db.execute(q2)).scalar() or 0 # By type q3 = select(LocationRecord.location_type, func.count(LocationRecord.id)).group_by(LocationRecord.location_type) if where: q3 = q3.where(*where) type_result = await db.execute(q3) by_type = {row[0]: row[1] for row in type_result.all()} # Hourly distribution (hour 0-23) q4 = select( extract("hour", LocationRecord.recorded_at).label("hour"), func.count(LocationRecord.id), ).group_by("hour").order_by("hour") if where: q4 = q4.where(*where) hour_result = await db.execute(q4) hourly = {int(row[0]): row[1] for row in hour_result.all()} return APIResponse(data={ "total": total, "with_coordinates": with_coords, "without_coordinates": total - with_coords, "coordinate_rate": round(with_coords / total * 100, 1) if total else 0, "by_type": by_type, "hourly_distribution": hourly, }) @router.get( "/track-summary/{device_id}", response_model=APIResponse[dict], summary="轨迹摘要 / Track summary", ) async def track_summary( device_id: int, start_time: datetime = Query(..., description="开始时间"), end_time: datetime = Query(..., description="结束时间"), db: AsyncSession = Depends(get_db), ): """ 轨迹统计摘要:总距离(km)、运动时长、最高速度、平均速度、轨迹点数。 Track summary: total distance, duration, max/avg speed, point count. """ import math as _math device = await device_service.get_device(db, device_id) if device is None: raise HTTPException(status_code=404, detail=f"Device {device_id} not found") if start_time >= end_time: raise HTTPException(status_code=400, detail="start_time must be before end_time") records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=50000) if not records: return APIResponse(data={ "device_id": device_id, "point_count": 0, "total_distance_km": 0, "duration_minutes": 0, "max_speed_kmh": 0, "avg_speed_kmh": 0, "by_type": {}, }) # Haversine distance calculation def _haversine(lat1: float, lon1: float, lat2: float, lon2: float) -> float: R = 6371.0 # km dlat = _math.radians(lat2 - lat1) dlon = _math.radians(lon2 - lon1) a = _math.sin(dlat / 2) ** 2 + _math.cos(_math.radians(lat1)) * _math.cos(_math.radians(lat2)) * _math.sin(dlon / 2) ** 2 return R * 2 * _math.atan2(_math.sqrt(a), _math.sqrt(1 - a)) total_distance = 0.0 max_speed = 0.0 speeds = [] type_counts: dict[str, int] = {} prev = None for r in records: t = r.location_type or "unknown" type_counts[t] = type_counts.get(t, 0) + 1 if r.speed is not None and r.speed > max_speed: max_speed = r.speed if prev is not None and r.latitude is not None and r.longitude is not None and prev.latitude is not None and prev.longitude is not None: d = _haversine(prev.latitude, prev.longitude, r.latitude, r.longitude) total_distance += d if r.latitude is not None and r.longitude is not None: prev = r first_time = records[0].recorded_at last_time = records[-1].recorded_at duration_min = (last_time - first_time).total_seconds() / 60 if last_time > first_time else 0 avg_speed = (total_distance / (duration_min / 60)) if duration_min > 0 else 0 return APIResponse(data={ "device_id": device_id, "point_count": len(records), "total_distance_km": round(total_distance, 2), "duration_minutes": round(duration_min, 1), "max_speed_kmh": round(max_speed, 1), "avg_speed_kmh": round(avg_speed, 1), "start_time": str(first_time), "end_time": str(last_time), "by_type": type_counts, }) @router.get( "/latest/{device_id}", response_model=APIResponse[LocationRecordResponse | None], summary="获取设备最新位置 / Get latest location", ) async def latest_location(device_id: int, db: AsyncSession = Depends(get_db)): """ 获取指定设备的最新位置信息。 Get the most recent location record for a device. """ # Verify device exists device = await device_service.get_device(db, device_id) if device is None: raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}") record = await location_service.get_latest_location(db, device_id) if record is None: return APIResponse( code=0, message="No location data available / 暂无位置数据", data=None, ) return APIResponse(data=LocationRecordResponse.model_validate(record)) @router.post( "/batch-latest", response_model=APIResponse[list[LocationRecordResponse | None]], summary="批量获取设备最新位置 / Batch get latest locations", ) async def batch_latest_locations( device_ids: list[int] = Body(..., min_length=1, max_length=100, embed=True), db: AsyncSession = Depends(get_db), ): """ 传入 device_ids 列表,返回每台设备的最新位置(按输入顺序)。 Pass device_ids list, returns latest location per device in input order. """ records = await location_service.get_batch_latest_locations(db, device_ids) result_map = {r.device_id: r for r in records} return APIResponse(data=[ LocationRecordResponse.model_validate(result_map[did]) if did in result_map else None for did in device_ids ]) @router.get( "/track/{device_id}", response_model=APIResponse[list[LocationRecordResponse]], summary="获取设备轨迹 / Get device track", ) async def device_track( device_id: int, start_time: datetime = Query(..., description="开始时间 / Start time (ISO 8601)"), end_time: datetime = Query(..., description="结束时间 / End time (ISO 8601)"), max_points: int = Query(default=10000, ge=1, le=50000, description="最大轨迹点数 / Max track points"), db: AsyncSession = Depends(get_db), ): """ 获取设备在指定时间范围内的运动轨迹(按时间正序排列)。 Get device movement track within a time range (ordered chronologically). """ # Verify device exists device = await device_service.get_device(db, device_id) if device is None: raise HTTPException(status_code=404, detail=f"Device {device_id} not found / 未找到设备{device_id}") if start_time >= end_time: raise HTTPException( status_code=400, detail="start_time must be before end_time / 开始时间必须早于结束时间", ) records = await location_service.get_device_track(db, device_id, start_time, end_time, max_points=max_points) return APIResponse( data=[LocationRecordResponse.model_validate(r) for r in records] ) @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], summary="获取位置记录详情 / Get location record", ) async def get_location(location_id: int, db: AsyncSession = Depends(get_db)): """按ID获取位置记录详情 / Get location record details 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") 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")