from fastapi import APIRouter, Depends, HTTPException, Query, Path, Body from typing import List from datetime import datetime from uuid import UUID from psycopg import AsyncConnection from pydantic import BaseModel, Field, field_validator from app.domain.schemas.scada import ScadaLatestReadingsResponse, ScadaRawPage from app.auth.project_dependencies import get_project_context, ProjectContext from app.services.scada_raw import read_raw_page, validate_window from app.infra.db.postgresql.scada import ScadaInfoRepository from app.infra.db.timescaledb.repositories.scada import ScadaRepository from .dependencies import get_postgres_connection, get_timescale_connection router = APIRouter() SCADA_BATCH_MAX_ITEMS = 10_000 @router.get("/timeseries/scada-readings/raw", response_model=ScadaRawPage, summary="按时间顺序分页读取 SCADA 原始值") async def get_scada_raw( start_time: datetime, end_time: datetime, device_ids: str, limit: int = Query(500, ge=1, le=500), cursor: str | None = Query(None, max_length=2048), replay_id: UUID | None = None, conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ctx: ProjectContext = Depends(get_project_context), ) -> ScadaRawPage: try: validate_window(start_time, end_time) except ValueError as exc: raise HTTPException(422, str(exc)) from exc ids = list(dict.fromkeys(item.strip() for item in device_ids.split(",") if item.strip())) if not ids or len(ids) > 500: raise HTTPException(422, "请选择 1 至 500 个 SCADA 设备") existing = await ScadaInfoRepository.get_existing_device_ids(postgres_conn, ids) if set(ids) != existing: raise HTTPException(422, "查询包含当前项目未配置的 SCADA 设备") try: through = None if replay_id is not None: from app.infra.db.postgresql.scada_replays import ScadaReplayRepository from app.services.scada_replay import ScadaReplayService repo = ScadaReplayRepository(postgres_conn, ctx.project_id) await ScadaReplayService(repo, ctx).ensure_storage() replay = await repo.get(replay_id) if not set(ids) <= set(replay["config"]["device_ids"]): raise HTTPException(403, "设备超出回放范围") if start_time < datetime.fromisoformat(replay["start_time"]) or end_time > datetime.fromisoformat(replay["end_time"]): raise HTTPException(422, "查询超出选定回放区间") through = datetime.fromisoformat(replay["virtual_now"]) return await read_raw_page(conn, project_id=str(ctx.project_id), device_ids=ids, start=start_time, end=end_time, limit=limit, cursor=cursor, through=through) except ValueError as exc: raise HTTPException(422, str(exc)) from exc class ScadaReadingBatchItem(BaseModel): time: datetime device_id: str = Field(min_length=1) monitored_value: float | None = None cleaned_value: float | None = None @field_validator("device_id") @classmethod def normalize_device_id(cls, value: str) -> str: normalized = value.strip() if not normalized: raise ValueError("device_id must not be blank") return normalized @router.post("/timeseries/scada-readings/batches", status_code=201, summary="批量插入SCADA监测数据") async def insert_scada_data( data: List[ScadaReadingBatchItem] = Body( ..., min_length=1, max_length=SCADA_BATCH_MAX_ITEMS, description="SCADA设备监测数据列表", ), conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ): """ 批量插入SCADA监测数据 将多个设备的实时监测数据批量插入时间序列数据库。 Args: data: SCADA设备监测数据列表,每项包含device_id、时间戳和监测值等信息 Returns: 插入成功的记录数 """ rows = [item.model_dump() for item in data] requested_ids = list(dict.fromkeys(item["device_id"] for item in rows)) existing_ids = await ScadaInfoRepository.get_existing_device_ids( postgres_conn, requested_ids ) missing_ids = [ device_id for device_id in requested_ids if device_id not in existing_ids ] if missing_ids: raise HTTPException( status_code=422, detail=f"SCADA devices do not exist in BizDB: {', '.join(missing_ids)}", ) await ScadaRepository.insert_scada_batch(conn, rows) return {"message": f"Inserted {len(data)} records"} @router.get("/timeseries/scada-readings", summary="按设备ID和时间范围查询SCADA数据") async def get_scada_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), device_ids: str = Query( ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID和时间范围查询SCADA监测数据 查询多个设备在指定时间范围内的所有监测数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 device_ids: 设备ID列表,用逗号分隔 Returns: SCADA监测数据列表 """ device_ids_list = ( [id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else [] ) return await ScadaRepository.get_scada_by_ids_time_range( conn, device_ids_list, start_time, end_time ) @router.get( "/timeseries/scada-readings/latest", response_model=ScadaLatestReadingsResponse, summary="获取SCADA设备最新数据", ) async def get_latest_scada_readings( limit: int = Query(100, ge=1, le=500, description="返回设备数上限"), offset: int = Query(0, ge=0, description="设备目录偏移量"), conn: AsyncConnection = Depends(get_timescale_connection), postgres_conn: AsyncConnection = Depends(get_postgres_connection), ) -> ScadaLatestReadingsResponse: """Read one latest persisted row per project device without applying a time window.""" devices = await ScadaInfoRepository.get_scadas(postgres_conn) selected_devices = devices[offset : offset + limit] latest_by_device = await ScadaRepository.get_latest_scada_readings( conn, [device["device_id"] for device in selected_devices], ) items = [] for device in selected_devices: reading = latest_by_device.get(device["device_id"]) items.append( { "device_id": device["device_id"], "device_type": device["device_type"], "measurement_unit": device["measurement_unit"], "transmission_mode": device["transmission_mode"], "transmission_frequency": device["transmission_frequency"], "observed_at": reading["time"] if reading else None, "monitored_value": reading["monitored_value"] if reading else None, "cleaned_value": reading["cleaned_value"] if reading else None, } ) observed_times = [item["observed_at"] for item in items if item["observed_at"] is not None] return ScadaLatestReadingsResponse.model_validate( { "items": items, "total": len(devices), "limit": limit, "offset": offset, "observed_count": len(observed_times), "latest_observed_at": max(observed_times) if observed_times else None, } ) @router.get( "/timeseries/scada-readings/fields", summary="按设备ID、字段和时间范围查询SCADA数据" ) async def get_scada_field_by_ids_time_range( start_time: datetime = Query(..., description="查询开始时间"), end_time: datetime = Query(..., description="查询结束时间"), field: str = Query(..., description="要查询的字段名称"), device_ids: str = Query( ..., description="设备ID列表,逗号分隔,如 'device1,device2,device3'" ), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 按设备ID、字段和时间范围查询特定SCADA数据 查询多个设备在指定时间范围内的特定字段监测数据。 Args: start_time: 查询开始时间 end_time: 查询结束时间 field: 字段名称 device_ids: 设备ID列表,用逗号分隔 Returns: SCADA字段数据列表 Raises: HTTPException: 当字段不存在或查询参数无效时返回400错误 """ try: device_ids_list = ( [id.strip() for id in device_ids.split(",") if id.strip()] if device_ids else [] ) return await ScadaRepository.get_scada_field_by_id_time_range( conn, device_ids_list, start_time, end_time, field ) except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.patch("/timeseries/scada-readings/{device_id}/field", summary="更新SCADA设备字段") async def update_scada_field( device_id: str = Path(..., description="设备ID"), time: datetime = Query(..., description="更新数据的时间戳"), field: str = Query(..., description="要更新的字段名称"), value: float = Query(..., description="更新的字段值"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 更新指定设备的字段值 更新SCADA设备在特定时间的某个字段监测数据。 Args: device_id: 设备ID time: 数据时间戳 field: 字段名称 value: 字段新值 Returns: 更新结果信息 Raises: HTTPException: 当字段不存在或更新失败时返回400错误 """ try: await ScadaRepository.update_scada_field(conn, time, device_id, field, value) return {"message": "Updated successfully"} except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) @router.delete("/timeseries/scada-readings", summary="按设备ID和时间范围删除SCADA数据") async def delete_scada_data( device_id: str = Query(..., description="设备ID"), start_time: datetime = Query(..., description="删除开始时间"), end_time: datetime = Query(..., description="删除结束时间"), conn: AsyncConnection = Depends(get_timescale_connection), ): """ 删除指定设备和时间范围内的SCADA数据 删除在指定时间范围内的特定设备监测数据。 Args: device_id: 设备ID start_time: 删除开始时间 end_time: 删除结束时间 Returns: 删除结果信息 """ await ScadaRepository.delete_scada_by_id_time_range( conn, device_id, start_time, end_time ) return {"message": "Deleted successfully"}