feat(workbench): align v2 GIS and SCADA API
This commit is contained in:
@@ -0,0 +1,73 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { fetchScadaDeviceCollection } from "./scada-client";
|
||||
|
||||
const device = {
|
||||
device_id: "SCADA-1",
|
||||
device_type: "pressure",
|
||||
node_id: "J-1",
|
||||
link_id: null,
|
||||
api_query_id: "pressure-query",
|
||||
transmission_mode: "realtime",
|
||||
transmission_frequency: "2s",
|
||||
reliability: 100,
|
||||
x: 10,
|
||||
y: 20,
|
||||
longitude: 121.5,
|
||||
latitude: 30.9
|
||||
};
|
||||
|
||||
describe("SCADA API client", () => {
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it("resolves the active project and builds GeoJSON keyed by device_id", async () => {
|
||||
const fetchMock = vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "tjwater_next", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }))
|
||||
.mockResolvedValueOnce(new Response(JSON.stringify({
|
||||
items: [device, { ...device, device_id: "SCADA-NO-LOCATION", longitude: null }],
|
||||
total: 2,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
const getAccessToken = vi.fn().mockResolvedValue("access-token");
|
||||
|
||||
const collection = await fetchScadaDeviceCollection(getAccessToken);
|
||||
|
||||
expect(collection.features).toEqual([
|
||||
expect.objectContaining({
|
||||
id: "SCADA-1",
|
||||
geometry: { type: "Point", coordinates: [121.5, 30.9] },
|
||||
properties: expect.objectContaining({ device_id: "SCADA-1", node_id: "J-1" })
|
||||
})
|
||||
]);
|
||||
expect(getAccessToken).toHaveBeenCalledOnce();
|
||||
expect(fetchMock).toHaveBeenCalledTimes(2);
|
||||
const projectHeaders = new Headers(fetchMock.mock.calls[0]?.[1]?.headers);
|
||||
const deviceHeaders = new Headers(fetchMock.mock.calls[1]?.[1]?.headers);
|
||||
expect(projectHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||
expect(deviceHeaders.get("Authorization")).toBe("Bearer access-token");
|
||||
expect(deviceHeaders.get("X-Project-Id")).toBe("project-1");
|
||||
expect(String(fetchMock.mock.calls[1]?.[0])).toContain("/api/v1/scada-devices?limit=1000&offset=0");
|
||||
});
|
||||
|
||||
it("does not query devices when no active project matches the workspace", async () => {
|
||||
const fetchMock = vi.fn().mockResolvedValue(new Response(JSON.stringify({
|
||||
items: [{ project_id: "project-1", gs_workspace: "other", status: "active" }],
|
||||
total: 1,
|
||||
limit: 1000,
|
||||
offset: 0
|
||||
}), { status: 200 }));
|
||||
vi.stubGlobal("fetch", fetchMock);
|
||||
|
||||
await expect(fetchScadaDeviceCollection()).rejects.toThrow("未找到GeoServer工作空间");
|
||||
expect(fetchMock).toHaveBeenCalledOnce();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,109 @@
|
||||
import type { Feature, FeatureCollection, Point } from "geojson";
|
||||
import { z } from "zod";
|
||||
import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth";
|
||||
import { env } from "@/shared/config/env";
|
||||
import { GEOSERVER_WORKSPACE } from "../map/geoserver-config";
|
||||
|
||||
const projectSchema = z.object({
|
||||
project_id: z.string().min(1),
|
||||
gs_workspace: z.string().min(1),
|
||||
status: z.string()
|
||||
});
|
||||
const projectPageSchema = z.object({
|
||||
items: z.array(projectSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
const scadaDeviceSchema = z.object({
|
||||
device_id: z.string().trim().min(1),
|
||||
device_type: z.string(),
|
||||
node_id: z.string().nullable(),
|
||||
link_id: z.string().nullable(),
|
||||
api_query_id: z.string().nullable(),
|
||||
transmission_mode: z.string(),
|
||||
transmission_frequency: z.string(),
|
||||
reliability: z.number().int().nullable(),
|
||||
x: z.number().nullable(),
|
||||
y: z.number().nullable(),
|
||||
longitude: z.number().nullable(),
|
||||
latitude: z.number().nullable()
|
||||
});
|
||||
|
||||
const scadaDevicePageSchema = z.object({
|
||||
items: z.array(scadaDeviceSchema),
|
||||
total: z.number().int().nonnegative(),
|
||||
limit: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative()
|
||||
});
|
||||
|
||||
export type ScadaDevice = z.infer<typeof scadaDeviceSchema>;
|
||||
|
||||
const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, "");
|
||||
|
||||
export async function fetchScadaDeviceCollection(
|
||||
getAccessToken?: AccessTokenProvider,
|
||||
signal?: AbortSignal
|
||||
): Promise<FeatureCollection<Point, ScadaDevice>> {
|
||||
const accessToken = await getAccessToken?.();
|
||||
const projects = projectPageSchema.parse(
|
||||
await requestJson("/api/v1/projects?limit=1000&offset=0", accessToken, undefined, signal)
|
||||
);
|
||||
const project = projects.items.find(
|
||||
(item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE
|
||||
);
|
||||
if (!project) {
|
||||
throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的项目`);
|
||||
}
|
||||
|
||||
const page = scadaDevicePageSchema.parse(
|
||||
await requestJson(
|
||||
"/api/v1/scada-devices?limit=1000&offset=0",
|
||||
accessToken,
|
||||
project.project_id,
|
||||
signal
|
||||
)
|
||||
);
|
||||
if (page.total > page.items.length) {
|
||||
throw new Error(`SCADA设备数量 ${page.total} 超过单次加载上限 ${page.items.length}`);
|
||||
}
|
||||
|
||||
return {
|
||||
type: "FeatureCollection",
|
||||
features: page.items.flatMap(toScadaFeature)
|
||||
};
|
||||
}
|
||||
|
||||
function toScadaFeature(device: ScadaDevice): Array<Feature<Point, ScadaDevice>> {
|
||||
if (device.longitude === null || device.latitude === null) return [];
|
||||
return [{
|
||||
type: "Feature",
|
||||
id: device.device_id,
|
||||
geometry: {
|
||||
type: "Point",
|
||||
coordinates: [device.longitude, device.latitude]
|
||||
},
|
||||
properties: device
|
||||
}];
|
||||
}
|
||||
|
||||
async function requestJson(
|
||||
path: string,
|
||||
accessToken: string | null | undefined,
|
||||
projectId: string | undefined,
|
||||
signal: AbortSignal | undefined
|
||||
) {
|
||||
const headers = new Headers({ Accept: "application/json" });
|
||||
if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`);
|
||||
if (projectId) headers.set("X-Project-Id", projectId);
|
||||
const response = await fetch(`${SERVER_API_BASE_URL}${path}`, { headers, signal });
|
||||
const payload: unknown = await response.json().catch(() => null);
|
||||
if (!response.ok) {
|
||||
const detail = payload && typeof payload === "object" && "detail" in payload
|
||||
? String(payload.detail)
|
||||
: `HTTP ${response.status}`;
|
||||
throw new Error(`SCADA API请求失败:${detail}`);
|
||||
}
|
||||
return payload;
|
||||
}
|
||||
Reference in New Issue
Block a user