Files
HSAP/platform/as_platform/api/fleet_routes.py

345 lines
11 KiB
Python
Raw Normal View History

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