345 lines
11 KiB
Python
345 lines
11 KiB
Python
|
|
"""Fleet map API routes."""
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import json
|
|||
|
|
from typing import Annotated, Any
|
|||
|
|
|
|||
|
|
from fastapi import APIRouter, Depends, File, Form, Header, HTTPException, Query, UploadFile
|
|||
|
|
from fastapi.responses import StreamingResponse
|
|||
|
|
from pydantic import BaseModel, Field
|
|||
|
|
from sqlalchemy.orm import Session
|
|||
|
|
|
|||
|
|
from as_platform.auth.deps import get_current_user, require_permission
|
|||
|
|
from as_platform.config import AMAP_KEY, FLEET_MAP_ENABLED, MAP_TILE_PROVIDER, TBOX_INGEST_TOKEN
|
|||
|
|
from as_platform.db.engine import get_db
|
|||
|
|
from as_platform.db.models import User
|
|||
|
|
from as_platform.fleet import service as fleet_svc
|
|||
|
|
from as_platform.fleet.mock_seed import reseed_demo_fleet, seed_demo_fleet
|
|||
|
|
|
|||
|
|
router = APIRouter(tags=["fleet"])
|
|||
|
|
|
|||
|
|
|
|||
|
|
class VehicleBody(BaseModel):
|
|||
|
|
plate_no: str
|
|||
|
|
tbox_device_id: str
|
|||
|
|
name: str | None = None
|
|||
|
|
team: str | None = None
|
|||
|
|
status: str | None = "active"
|
|||
|
|
|
|||
|
|
|
|||
|
|
class VehiclePatchBody(BaseModel):
|
|||
|
|
plate_no: str | None = None
|
|||
|
|
tbox_device_id: str | None = None
|
|||
|
|
name: str | None = None
|
|||
|
|
team: str | None = None
|
|||
|
|
status: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TboxGpsBody(BaseModel):
|
|||
|
|
device_id: str
|
|||
|
|
lat: float
|
|||
|
|
lng: float
|
|||
|
|
ts: str | None = None
|
|||
|
|
speed_kmh: float | None = None
|
|||
|
|
heading: float | None = None
|
|||
|
|
plate_no: str | None = None
|
|||
|
|
run_signal: str | None = "active"
|
|||
|
|
engineer: str | None = None
|
|||
|
|
project: str | None = None
|
|||
|
|
batch: str | None = None
|
|||
|
|
|
|||
|
|
|
|||
|
|
class TboxGpsBatchBody(BaseModel):
|
|||
|
|
points: list[TboxGpsBody] = Field(..., min_length=1, max_length=100)
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _check_fleet_enabled() -> None:
|
|||
|
|
if not FLEET_MAP_ENABLED:
|
|||
|
|
raise HTTPException(503, "车队地图功能未启用 (AS_FLEET_MAP_ENABLED)")
|
|||
|
|
|
|||
|
|
|
|||
|
|
def _verify_tbox_token(x_tbox_token: str | None = None, authorization: str | None = None) -> None:
|
|||
|
|
token = (x_tbox_token or "").strip()
|
|||
|
|
if not token and authorization:
|
|||
|
|
parts = authorization.split(None, 1)
|
|||
|
|
if len(parts) == 2 and parts[0].lower() == "bearer":
|
|||
|
|
token = parts[1].strip()
|
|||
|
|
if not TBOX_INGEST_TOKEN:
|
|||
|
|
return
|
|||
|
|
if token != TBOX_INGEST_TOKEN:
|
|||
|
|
raise HTTPException(401, "T-Box Token 无效")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/map-config")
|
|||
|
|
def fleet_map_config(_user: Annotated[User, Depends(require_permission("read:fleet"))]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
tile_provider = MAP_TILE_PROVIDER if MAP_TILE_PROVIDER in ("gaode", "osm") else "gaode"
|
|||
|
|
return {
|
|||
|
|
"enabled": True,
|
|||
|
|
"provider": tile_provider,
|
|||
|
|
"tileProvider": tile_provider,
|
|||
|
|
"amapKey": AMAP_KEY or "",
|
|||
|
|
"pollIntervalSec": 5,
|
|||
|
|
"note": "GPS 为 WGS84,高德底图为 GCJ-02,演示轨迹可能有轻微偏移",
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/live")
|
|||
|
|
def fleet_live(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
return fleet_svc.get_live_fleet(db)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/vehicles")
|
|||
|
|
def fleet_vehicles(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
return {"items": fleet_svc.list_vehicles(db)}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/vehicles/{vehicle_id}")
|
|||
|
|
def fleet_vehicle_get(
|
|||
|
|
vehicle_id: int,
|
|||
|
|
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
item = fleet_svc.get_vehicle(db, vehicle_id)
|
|||
|
|
if not item:
|
|||
|
|
raise HTTPException(404, "车辆不存在")
|
|||
|
|
return item
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/vehicles")
|
|||
|
|
def fleet_vehicle_create(
|
|||
|
|
body: VehicleBody,
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
try:
|
|||
|
|
item = fleet_svc.create_vehicle(db, body.model_dump())
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True, "vehicle": item}
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.patch("/api/v1/fleet/vehicles/{vehicle_id}")
|
|||
|
|
def fleet_vehicle_update(
|
|||
|
|
vehicle_id: int,
|
|||
|
|
body: VehiclePatchBody,
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
try:
|
|||
|
|
item = fleet_svc.update_vehicle(db, vehicle_id, body.model_dump(exclude_unset=True))
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True, "vehicle": item}
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.delete("/api/v1/fleet/vehicles/{vehicle_id}")
|
|||
|
|
def fleet_vehicle_delete(
|
|||
|
|
vehicle_id: int,
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
try:
|
|||
|
|
fleet_svc.delete_vehicle(db, vehicle_id)
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True}
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/runs")
|
|||
|
|
def fleet_runs(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
vehicle_id: int | None = None,
|
|||
|
|
status: str | None = None,
|
|||
|
|
offset: int = Query(0, ge=0),
|
|||
|
|
limit: int = Query(20, ge=1, le=100),
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
return fleet_svc.list_runs(db, vehicle_id=vehicle_id, status=status, offset=offset, limit=limit)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/runs/{run_id}")
|
|||
|
|
def fleet_run_detail(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
detail = fleet_svc.get_run_detail(db, run_id)
|
|||
|
|
if not detail:
|
|||
|
|
raise HTTPException(404, "趟次不存在")
|
|||
|
|
return detail
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/runs/{run_id}/track")
|
|||
|
|
def fleet_run_track(run_id: int, _user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
return fleet_svc.get_run_track_geojson(db, run_id)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/summary")
|
|||
|
|
def fleet_summary(_user: Annotated[User, Depends(require_permission("read:fleet"))], db: Annotated[Session, Depends(get_db)]) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
return fleet_svc.fleet_summary(db)
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/mock/seed")
|
|||
|
|
def fleet_mock_seed(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
created = seed_demo_fleet(db)
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True, "created": created}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/mock/reseed")
|
|||
|
|
def fleet_mock_reseed(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
"""清空车队演示数据并重新注入(切换区域后使用)。"""
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
created = reseed_demo_fleet(db)
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True, "created": created, "region": "changsha"}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/mock/tick")
|
|||
|
|
def fleet_mock_tick(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
n = fleet_svc.simulate_tick(db)
|
|||
|
|
db.commit()
|
|||
|
|
return {"ok": True, "advanced": n}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/runs/import-gpx")
|
|||
|
|
async def fleet_import_gpx(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
vehicle_id: int = Form(...),
|
|||
|
|
file: UploadFile = File(...),
|
|||
|
|
note: str | None = Form(None),
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
raw = await file.read()
|
|||
|
|
try:
|
|||
|
|
text = raw.decode("utf-8")
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
text = raw.decode("utf-8", errors="ignore")
|
|||
|
|
try:
|
|||
|
|
result = fleet_svc.import_gpx_run(db, vehicle_id=vehicle_id, gpx_content=text, note=note)
|
|||
|
|
db.commit()
|
|||
|
|
return result
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.get("/api/v1/fleet/stream")
|
|||
|
|
def fleet_stream(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("read:fleet"))],
|
|||
|
|
) -> StreamingResponse:
|
|||
|
|
"""SSE:推送 fleet.gps 事件(需 Redis)。"""
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
from as_platform.redis.bus import get_redis
|
|||
|
|
|
|||
|
|
def event_generator():
|
|||
|
|
r = get_redis()
|
|||
|
|
if not r:
|
|||
|
|
yield f"data: {json.dumps({'event': 'error', 'message': 'redis unavailable'}, ensure_ascii=False)}\n\n"
|
|||
|
|
return
|
|||
|
|
pubsub = r.pubsub()
|
|||
|
|
pubsub.subscribe("as:events")
|
|||
|
|
yield f"data: {json.dumps({'event': 'fleet.connected'}, ensure_ascii=False)}\n\n"
|
|||
|
|
while True:
|
|||
|
|
msg = pubsub.get_message(timeout=25)
|
|||
|
|
if msg is None:
|
|||
|
|
yield ": ping\n\n"
|
|||
|
|
continue
|
|||
|
|
if msg.get("type") != "message":
|
|||
|
|
continue
|
|||
|
|
raw = msg.get("data")
|
|||
|
|
if isinstance(raw, bytes):
|
|||
|
|
raw = raw.decode("utf-8", errors="ignore")
|
|||
|
|
if isinstance(raw, str) and "fleet." in raw:
|
|||
|
|
yield f"data: {raw}\n\n"
|
|||
|
|
|
|||
|
|
return StreamingResponse(event_generator(), media_type="text/event-stream")
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/tbox/gps")
|
|||
|
|
def tbox_gps(
|
|||
|
|
body: TboxGpsBody,
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
|
|||
|
|
authorization: Annotated[str | None, Header()] = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
_verify_tbox_token(x_tbox_token, authorization)
|
|||
|
|
try:
|
|||
|
|
fleet_svc.close_idle_runs(db)
|
|||
|
|
result = fleet_svc.ingest_tbox_gps(db, body.model_dump())
|
|||
|
|
db.commit()
|
|||
|
|
return result
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/tbox/gps/batch")
|
|||
|
|
def tbox_gps_batch(
|
|||
|
|
body: TboxGpsBatchBody,
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
x_tbox_token: Annotated[str | None, Header(alias="X-Tbox-Token")] = None,
|
|||
|
|
authorization: Annotated[str | None, Header()] = None,
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
_verify_tbox_token(x_tbox_token, authorization)
|
|||
|
|
try:
|
|||
|
|
fleet_svc.close_idle_runs(db)
|
|||
|
|
result = fleet_svc.ingest_tbox_gps_batch(db, [p.model_dump() for p in body.points])
|
|||
|
|
db.commit()
|
|||
|
|
return result
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|
|||
|
|
|
|||
|
|
|
|||
|
|
@router.post("/api/v1/fleet/runs/import-csv")
|
|||
|
|
async def fleet_import_csv(
|
|||
|
|
_user: Annotated[User, Depends(require_permission("write:fleet"))],
|
|||
|
|
db: Annotated[Session, Depends(get_db)],
|
|||
|
|
vehicle_id: int = Form(...),
|
|||
|
|
file: UploadFile = File(...),
|
|||
|
|
note: str | None = Form(None),
|
|||
|
|
project: str | None = Form(None),
|
|||
|
|
batch: str | None = Form(None),
|
|||
|
|
) -> dict[str, Any]:
|
|||
|
|
_check_fleet_enabled()
|
|||
|
|
raw = await file.read()
|
|||
|
|
try:
|
|||
|
|
text = raw.decode("utf-8")
|
|||
|
|
except UnicodeDecodeError:
|
|||
|
|
text = raw.decode("utf-8", errors="ignore")
|
|||
|
|
try:
|
|||
|
|
result = fleet_svc.import_csv_run(
|
|||
|
|
db,
|
|||
|
|
vehicle_id=vehicle_id,
|
|||
|
|
csv_content=text,
|
|||
|
|
note=note,
|
|||
|
|
project=project,
|
|||
|
|
batch=batch,
|
|||
|
|
)
|
|||
|
|
db.commit()
|
|||
|
|
return result
|
|||
|
|
except ValueError as e:
|
|||
|
|
raise HTTPException(400, str(e)) from e
|