Files
next-tjwater-frontend/src/features/workbench/peripheral-sample/client.ts
T
jiang c0e4231b83
Generic Container CI/CD / test-build-publish (push) Successful in 1m52s
Frontend CI/CD / build-test-publish-and-deploy (push) Successful in 1m52s
feat: expand intelligent investigation workspace
2026-10-30 17:30:19 +08:00

357 lines
15 KiB
TypeScript

import { getWorkbenchAccessToken } from "../api/request-context";
import type { WorkbenchAccess } from "../api/request-context";
import { env } from "@/shared/config/env";
import { createWorkbenchHeaders, findOwnedWorkbenchProject } from "../api/project-client";
import type { components } from "./generated/schema";
type Schemas = components["schemas"];
export type PeripheralContext = Schemas["Context"];
export type PeripheralScenarioState = Schemas["ScenarioState"];
export type PeripheralScenario = PeripheralScenarioState["scenario"];
export type PeripheralPointPage = Schemas["PointPage"];
export type PeripheralAlertPage = Schemas["AlertPage"];
export type PeripheralObservations = Schemas["Observations"];
export type PeripheralDemoDataset = Schemas["DemoDataset"];
export type PeripheralWorkOrder = Schemas["WorkOrder"];
export type PeripheralWorkOrderPage = Schemas["WorkOrderPage"];
export type PeripheralCreateWorkOrder = Schemas["CreateWorkOrder"];
export type PeripheralUpdateWorkOrder = Schemas["UpdateWorkOrder"];
export type PeripheralTestDataRecord = Schemas["TestDataRecord"];
export type PeripheralTestDataRecordPage = Schemas["TestDataRecordPage"];
export type PeripheralCreateTestDataRecord = Schemas["CreateTestDataRecord"];
export type PeripheralUpdateTestDataRecord = Schemas["UpdateTestDataRecord"];
export type PeripheralDeletedResource = Schemas["DeletedResource"];
type PeripheralMutationResult = PeripheralScenarioState | PeripheralWorkOrder |
PeripheralTestDataRecord | PeripheralDeletedResource;
const API_PREFIX = "/api/sample/v1";
export class PeripheralSampleClient {
private readonly baseUrl = env.TJWATER_PERIPHERAL_SAMPLE_API_BASE_URL;
private projectIdPromise: Promise<string> | null = null;
private readonly pendingCommands = new Map<string, { requestId: string; payload: string }>();
private scenarioProjection: PeripheralScenarioState | null = null;
constructor(private readonly getAccessToken?: WorkbenchAccess) {}
get configured() {
return Boolean(this.baseUrl);
}
getContext(signal?: AbortSignal) { return this.get<PeripheralContext>("/context", signal); }
getScenario(signal?: AbortSignal) { return this.getScenarioProjected("/scenario", signal); }
listPoints(signal?: AbortSignal) { return this.get<PeripheralPointPage>("/monitoring/points?limit=500", signal); }
listAlerts(signal?: AbortSignal) { return this.get<PeripheralAlertPage>("/monitoring/alerts", signal); }
getDemoData(signal?: AbortSignal) { return this.get<PeripheralDemoDataset>("/demo-data", signal); }
listWorkOrders(signal?: AbortSignal) { return this.get<PeripheralWorkOrderPage>("/work-orders?limit=100", signal); }
listTestData(signal?: AbortSignal) { return this.get<PeripheralTestDataRecordPage>("/test-data?limit=100", signal); }
listObservations(pointId: string, start: string, end: string, signal?: AbortSignal) {
const query = new URLSearchParams({ point_id: pointId, start, end });
return this.get<PeripheralObservations>(`/monitoring/observations?${query}`, signal);
}
async getReceipt(requestId: string, signal?: AbortSignal) {
const receipt = await this.get<Schemas["Receipt"]>(
`/requests/${encodeURIComponent(requestId)}`, signal
);
const result = this.projectResult(receipt.result);
for (const [path, pending] of this.pendingCommands) {
if (pending.requestId === requestId) this.pendingCommands.delete(path);
}
return result;
}
setScenario(scenario: PeripheralScenario, signal?: AbortSignal) {
return this.postRecoverable<PeripheralScenarioState>("/scenario", {
request_id: crypto.randomUUID(), scenario
}, signal);
}
createWorkOrder(input: Omit<PeripheralCreateWorkOrder, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralWorkOrder>("/work-orders", {
...input, request_id: crypto.randomUUID()
}, signal);
}
updateWorkOrder(workOrderId: string, input: Omit<PeripheralUpdateWorkOrder, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralWorkOrder>(
`/work-orders/${encodeURIComponent(workOrderId)}`,
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
);
}
deleteWorkOrder(workOrderId: string, expectedRevision: number, signal?: AbortSignal) {
return this.postRecoverable<PeripheralDeletedResource>(
`/work-orders/${encodeURIComponent(workOrderId)}`,
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
);
}
createTestData(input: Omit<PeripheralCreateTestDataRecord, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTestDataRecord>("/test-data", {
...input, request_id: crypto.randomUUID()
}, signal);
}
updateTestData(recordId: string, input: Omit<PeripheralUpdateTestDataRecord, "request_id">, signal?: AbortSignal) {
return this.postRecoverable<PeripheralTestDataRecord>(
`/test-data/${encodeURIComponent(recordId)}`,
{ ...input, request_id: crypto.randomUUID() }, signal, "PATCH"
);
}
deleteTestData(recordId: string, expectedRevision: number, signal?: AbortSignal) {
return this.postRecoverable<PeripheralDeletedResource>(
`/test-data/${encodeURIComponent(recordId)}`,
{ expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE"
);
}
private async getScenarioProjected(path: string, signal?: AbortSignal) {
return this.projectScenario(await this.get<PeripheralScenarioState>(path, signal));
}
private async postRecoverable<T extends PeripheralMutationResult>(
path: string,
payload: { request_id: string } & Record<string, unknown>,
signal?: AbortSignal,
method: "POST" | "PATCH" | "DELETE" = "POST"
): Promise<T> {
const commandPath = `${method}:${path}`;
const serialized = JSON.stringify(payload);
const fingerprint = JSON.stringify(
Object.fromEntries(Object.entries(payload).filter(([key]) => key !== "request_id"))
);
const pending = this.pendingCommands.get(commandPath);
if (pending) {
if (pending.payload !== fingerprint) {
throw new PeripheralSampleError(
"PENDING_COMMAND_MISMATCH",
`此操作仍有未确认请求 ${pending.requestId},新内容未发送。请先查询原回执。`,
"unknown",
pending.requestId
);
}
return this.recoverReceipt<T>(commandPath, pending.requestId, signal);
}
const headers = await this.prepareHeaders(signal, true);
try {
const result = await this.requestWithHeaders<T>(path, {
method, body: serialized, signal, headers
}, true);
return this.projectResult(result) as T;
} catch (error) {
if (!(error instanceof PeripheralSampleError) || error.effect !== "unknown") throw error;
this.pendingCommands.set(commandPath, { requestId: payload.request_id, payload: fingerprint });
return this.recoverReceipt<T>(commandPath, payload.request_id, signal);
}
}
private async recoverReceipt<T extends PeripheralMutationResult>(
commandPath: string,
requestId: string,
signal?: AbortSignal
): Promise<T> {
try {
const receipt = await this.get<Schemas["Receipt"]>(
`/requests/${encodeURIComponent(requestId)}`, signal
);
this.pendingCommands.delete(commandPath);
return this.projectResult(receipt.result) as T;
} catch (receiptError) {
throw new PeripheralSampleError(
"UNKNOWN_OUTCOME",
`提交结果未知,未自动重试。再次操作只会查询原请求 ${requestId} 的回执。`,
"unknown",
requestId,
{ cause: receiptError }
);
}
}
private get<T>(path: string, signal?: AbortSignal) {
return this.request<T>(path, { signal });
}
private async request<T>(path: string, init: RequestInit): Promise<T> {
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
const headers = await this.prepareHeaders(init.signal ?? undefined, Boolean(init.body));
return this.requestWithHeaders<T>(path, { ...init, headers }, false);
}
private async requestWithHeaders<T>(path: string, init: RequestInit, write: boolean): Promise<T> {
let response: Response;
try {
response = await fetch(`${this.baseUrl}${API_PREFIX}${path}`, init);
} catch (error) {
if (!write) throw error;
throw new PeripheralSampleError("NETWORK_UNKNOWN", "连接中断,提交是否生效未知", "unknown", null, { cause: error });
}
let payload: unknown;
try {
payload = await response.json();
} catch (error) {
if (write) throw new PeripheralSampleError("INVALID_RESPONSE", "响应无法解析,提交是否生效未知", "unknown", null, { cause: error });
throw new PeripheralSampleError("INVALID_RESPONSE", "外围样板响应无法解析", "none", null, { cause: error });
}
if (!response.ok) {
const detail = asSampleError(payload);
throw new PeripheralSampleError(
detail?.code ?? `HTTP_${response.status}`,
detail?.message ?? `外围样板请求失败:HTTP ${response.status}`,
detail?.effect ?? (write && response.status >= 500 ? "unknown" : "none"),
detail?.request_id ?? null
);
}
const expectedProjectId = new Headers(init.headers).get("X-Project-Id");
if (expectedProjectId && !hasOnlyExpectedProjectIds(payload, expectedProjectId)) {
throw invalidResponseError(write, "外围样板响应属于其他项目");
}
if (!write && !isRecognizedReadPayload(path, payload)) {
throw invalidResponseError(false, "外围样板响应结构无效");
}
return payload as T;
}
private async prepareHeaders(signal: AbortSignal | undefined, json: boolean) {
if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null);
const projectId = await this.resolveProjectId(signal);
const accessToken = await getWorkbenchAccessToken(this.getAccessToken);
return createWorkbenchHeaders(accessToken, projectId, json);
}
private resolveProjectId(signal?: AbortSignal) {
this.projectIdPromise ??= findOwnedWorkbenchProject(this.getAccessToken, signal)
.then(({ project }) => project.project_id)
.catch((error) => { this.projectIdPromise = null; throw error; });
return this.projectIdPromise;
}
private projectResult(result: unknown): PeripheralMutationResult {
if (isScenarioState(result)) return this.projectScenario(result);
if (isManagedResult(result)) return result;
throw new PeripheralSampleError("INVALID_RESPONSE", "响应缺少可识别的样板资源,提交是否生效未知", "unknown", null);
}
private projectScenario(next: PeripheralScenarioState) {
if (this.scenarioProjection && next.revision < this.scenarioProjection.revision) {
return this.scenarioProjection;
}
this.scenarioProjection = next;
return next;
}
}
function isScenarioState(value: unknown): value is PeripheralScenarioState {
if (!value || typeof value !== "object") return false;
const item = value as Partial<PeripheralScenarioState>;
return typeof item.revision === "number" && typeof item.scenario === "string" &&
["normal", "missing_data", "unavailable", "response_lost", "duplicate_feedback", "conflicting_feedback", "late_state"].includes(item.scenario);
}
function isManagedResult(value: unknown): value is PeripheralWorkOrder | PeripheralTestDataRecord | PeripheralDeletedResource {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
if (item.deleted === true) return typeof item.resource_id === "string" &&
(item.resource_type === "work_order" || item.resource_type === "test_data_record");
if (typeof item.revision !== "number") return false;
if (typeof item.work_order_id === "string") return item.origin === "system_data";
return typeof item.record_id === "string" && item.origin === "synthetic";
}
export class PeripheralSampleError extends Error {
constructor(
readonly code: string,
message: string,
readonly effect: "none" | "unknown",
readonly requestId: string | null,
options?: ErrorOptions
) {
super(message, options);
this.name = "PeripheralSampleError";
}
}
function asSampleError(value: unknown): Schemas["SampleError"] | null {
if (!value || typeof value !== "object") return null;
const item = value as Partial<Schemas["SampleError"]>;
if (typeof item.code !== "string" || typeof item.message !== "string") return null;
if (item.effect !== "none" && item.effect !== "unknown") return null;
return { code: item.code, message: item.message, effect: item.effect, request_id: item.request_id ?? null };
}
function invalidResponseError(write: boolean, message: string) {
return new PeripheralSampleError(
"INVALID_RESPONSE",
write ? `${message},提交是否生效未知` : message,
write ? "unknown" : "none",
null
);
}
function hasOnlyExpectedProjectIds(value: unknown, expectedProjectId: string): boolean {
if (Array.isArray(value)) {
return value.every((item) => hasOnlyExpectedProjectIds(item, expectedProjectId));
}
if (!value || typeof value !== "object") return true;
const item = value as Record<string, unknown>;
if ("project_id" in item && item.project_id !== expectedProjectId) return false;
return Object.values(item).every((child) => hasOnlyExpectedProjectIds(child, expectedProjectId));
}
function isRecognizedReadPayload(path: string, value: unknown): boolean {
if (!value || typeof value !== "object") return false;
const item = value as Record<string, unknown>;
if (path === "/context") {
return typeof item.project_id === "string"
&& item.protocol_version === "tjwater-peripheral-sample@1";
}
if (path === "/scenario") return isScenarioState(value);
if (path.startsWith("/requests/")) {
return typeof item.request_id === "string" && "result" in item;
}
if (path.startsWith("/monitoring/points")) return isPageOf(value, isPoint);
if (path.startsWith("/monitoring/alerts")) return isPageOf(value, isAlert);
if (path.startsWith("/monitoring/observations")) {
return isPageOf(value, isObservation) && typeof item.truncated === "boolean";
}
if (path === "/demo-data") {
return item.origin === "synthetic"
&& typeof item.generator_version === "string"
&& Array.isArray(item.points)
&& Array.isArray(item.observations);
}
if (path.startsWith("/work-orders")) {
return isPageOf(value, (entry) => isManagedResult(entry) && "work_order_id" in entry);
}
if (path.startsWith("/test-data")) {
return isPageOf(value, (entry) => isManagedResult(entry) && "record_id" in entry);
}
return false;
}
function isPageOf(
value: unknown,
predicate: (item: Record<string, unknown>) => boolean
): boolean {
if (!value || typeof value !== "object") return false;
const items = (value as Record<string, unknown>).items;
return Array.isArray(items) && items.every(
(item) => Boolean(item && typeof item === "object" && predicate(item as Record<string, unknown>))
);
}
function isPoint(item: Record<string, unknown>) {
return typeof item.point_id === "string" && typeof item.label === "string";
}
function isAlert(item: Record<string, unknown>) {
return typeof item.alert_id === "string" && typeof item.title === "string";
}
function isObservation(item: Record<string, unknown>) {
return typeof item.record_id === "string" && typeof item.point_id === "string";
}