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 | null = null; private readonly pendingCommands = new Map(); private scenarioProjection: PeripheralScenarioState | null = null; constructor(private readonly getAccessToken?: WorkbenchAccess) {} get configured() { return Boolean(this.baseUrl); } getContext(signal?: AbortSignal) { return this.get("/context", signal); } getScenario(signal?: AbortSignal) { return this.getScenarioProjected("/scenario", signal); } listPoints(signal?: AbortSignal) { return this.get("/monitoring/points?limit=500", signal); } listAlerts(signal?: AbortSignal) { return this.get("/monitoring/alerts", signal); } getDemoData(signal?: AbortSignal) { return this.get("/demo-data", signal); } listWorkOrders(signal?: AbortSignal) { return this.get("/work-orders?limit=100", signal); } listTestData(signal?: AbortSignal) { return this.get("/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(`/monitoring/observations?${query}`, signal); } async getReceipt(requestId: string, signal?: AbortSignal) { const receipt = await this.get( `/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("/scenario", { request_id: crypto.randomUUID(), scenario }, signal); } createWorkOrder(input: Omit, signal?: AbortSignal) { return this.postRecoverable("/work-orders", { ...input, request_id: crypto.randomUUID() }, signal); } updateWorkOrder(workOrderId: string, input: Omit, signal?: AbortSignal) { return this.postRecoverable( `/work-orders/${encodeURIComponent(workOrderId)}`, { ...input, request_id: crypto.randomUUID() }, signal, "PATCH" ); } deleteWorkOrder(workOrderId: string, expectedRevision: number, signal?: AbortSignal) { return this.postRecoverable( `/work-orders/${encodeURIComponent(workOrderId)}`, { expected_revision: expectedRevision, request_id: crypto.randomUUID() }, signal, "DELETE" ); } createTestData(input: Omit, signal?: AbortSignal) { return this.postRecoverable("/test-data", { ...input, request_id: crypto.randomUUID() }, signal); } updateTestData(recordId: string, input: Omit, signal?: AbortSignal) { return this.postRecoverable( `/test-data/${encodeURIComponent(recordId)}`, { ...input, request_id: crypto.randomUUID() }, signal, "PATCH" ); } deleteTestData(recordId: string, expectedRevision: number, signal?: AbortSignal) { return this.postRecoverable( `/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(path, signal)); } private async postRecoverable( path: string, payload: { request_id: string } & Record, signal?: AbortSignal, method: "POST" | "PATCH" | "DELETE" = "POST" ): Promise { 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(commandPath, pending.requestId, signal); } const headers = await this.prepareHeaders(signal, true); try { const result = await this.requestWithHeaders(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(commandPath, payload.request_id, signal); } } private async recoverReceipt( commandPath: string, requestId: string, signal?: AbortSignal ): Promise { try { const receipt = await this.get( `/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(path: string, signal?: AbortSignal) { return this.request(path, { signal }); } private async request(path: string, init: RequestInit): Promise { if (!this.baseUrl) throw new PeripheralSampleError("NOT_CONFIGURED", "外围样板服务未配置", "none", null); const headers = await this.prepareHeaders(init.signal ?? undefined, Boolean(init.body)); return this.requestWithHeaders(path, { ...init, headers }, false); } private async requestWithHeaders(path: string, init: RequestInit, write: boolean): Promise { 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; 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; 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; 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; 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; 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) => boolean ): boolean { if (!value || typeof value !== "object") return false; const items = (value as Record).items; return Array.isArray(items) && items.every( (item) => Boolean(item && typeof item === "object" && predicate(item as Record)) ); } function isPoint(item: Record) { return typeof item.point_id === "string" && typeof item.label === "string"; } function isAlert(item: Record) { return typeof item.alert_id === "string" && typeof item.title === "string"; } function isObservation(item: Record) { return typeof item.record_id === "string" && typeof item.point_id === "string"; }