357 lines
15 KiB
TypeScript
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";
|
|
}
|