feat(workbench): align v2 GIS and SCADA API
Generic Container CI/CD / test-build-publish (push) Successful in 26s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 26s

This commit is contained in:
2026-09-08 18:18:40 +08:00
parent 28287a1447
commit f9c71cc076
45 changed files with 1025 additions and 254 deletions
@@ -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();
});
});
+109
View File
@@ -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;
}