Files
desungongpai/app/routers/locations.py

388 lines
14 KiB
Python
Raw Normal View History

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