import { ResourceCache } from "@/shared/cache/resource-cache"; import { z } from "zod"; import type { AccessTokenProvider } from "@/shared/auth/keycloak-auth"; import { env } from "@/shared/config/env"; import type { NetworkViewParams } from "../map/camera"; import { GEOSERVER_WORKSPACE } from "../map/geoserver-config"; import { getWorkbenchAccessToken, getWorkbenchProject, type WorkbenchAccess } from "./request-context"; import { PROJECT_REQUEST_TIMEOUT_MS, withProjectRequest } from "./project-request"; const projectSchema = z.object({ project_id: z.string().min(1), name: z.string().trim().min(1).optional(), code: z.string().trim().min(1).optional(), gs_workspace: z.string().min(1), status: z.string(), project_role: z.string().trim().min(1).optional() }); const projectPageSchema = z.object({ items: z.array(projectSchema), total: z.number().int().nonnegative(), limit: z.number().int().positive(), offset: z.number().int().nonnegative() }); export type WorkbenchProject = z.infer; const placeSearchConfigurationSchema = z.object({ enabled: z.boolean(), provider: z.literal("tianditu"), admin_code: z.string().regex(/^(?:156)?[0-9]{6}$/), region: z.string().trim().min(1) }); export type PlaceSearchConfiguration = z.infer; export function parsePlaceSearchConfiguration(mapConfig: unknown): PlaceSearchConfiguration | null { if (!mapConfig || typeof mapConfig !== "object" || !("place_search" in mapConfig)) return null; const parsed = placeSearchConfigurationSchema.safeParse(mapConfig.place_search); return parsed.success && parsed.data.enabled ? parsed.data : null; } const projectMetadataSchema = z.object({ project_id: z.string().min(1), name: z.string().trim().min(1), code: z.string().trim().min(1), gs_workspace: z.string().trim().min(1), map_extent: z.unknown().nullable().optional(), map_config: z.unknown().optional(), status: z.string(), project_role: z.string().trim().min(1) }); const accessContextSchema = z.object({ user_id: z.string().min(1), username: z.string().trim().min(1), system_role: z.string().trim().min(1), is_system_admin: z.boolean(), project_id: z.string().min(1).nullable().optional(), project_role: z.string().trim().min(1).nullable().optional(), permissions: z.array(z.string()) }); const bboxSchema = z.tuple([ z.number().finite(), z.number().finite(), z.number().finite(), z.number().finite() ]); const keyedExtentSchema = z.object({ xmin: z.number().finite(), ymin: z.number().finite(), xmax: z.number().finite(), ymax: z.number().finite() }); export type WorkbenchProjectPresentation = { projectId: string; projectName: string | null; username: string | null; projectRole: string | null; systemRole: string | null; isSystemAdmin: boolean; networkView: NetworkViewParams | null; placeSearch: PlaceSearchConfiguration | null; metadataAvailable: boolean; accessContextAvailable: boolean; }; const SERVER_API_BASE_URL = env.TJWATER_SERVER_API_BASE_URL.replace(/\/$/, ""); const PROJECT_CACHE_TTL_MS = 5 * 60_000; const projectContextPromises = new WeakMap; expiresAt: number; }>(); function awaitProject(promise: Promise, signal?: AbortSignal) { if (!signal) return promise; return new Promise((resolve, reject) => { const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")); if (signal.aborted) { abort(); return; } signal.addEventListener("abort", abort, { once: true }); promise.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); } export async function findOwnedWorkbenchProject( getAccessToken?: WorkbenchAccess, signal?: AbortSignal ): Promise<{ project: WorkbenchProject; accessToken: string | null | undefined }> { const accessToken = await getWorkbenchAccessToken(getAccessToken); signal?.throwIfAborted(); const scopedProject = getWorkbenchProject(getAccessToken); if (scopedProject) return { project: scopedProject, accessToken }; const existing = getAccessToken ? projectContextPromises.get(getAccessToken) : undefined; if (existing && existing.expiresAt > Date.now()) { return { project: await awaitProject(existing.promise, signal), accessToken }; } const loadProject = () => withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=0`, { headers: createWorkbenchHeaders(accessToken), // One caller cancelling must not cancel other consumers of this lookup. signal: requestSignal }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询")); const projects = projectPageSchema.parse(payload); const project = projects.items.find( (item) => item.status === "active" && item.gs_workspace === GEOSERVER_WORKSPACE ); if (!project) throw new Error(`未找到GeoServer工作空间 ${GEOSERVER_WORKSPACE} 对应的授权项目`); return project; }, getAccessToken ? undefined : signal); const pending = loadProject(); if (getAccessToken) { projectContextPromises.set(getAccessToken, { promise: pending, expiresAt: Date.now() + PROJECT_CACHE_TTL_MS }); void pending.catch(() => { if (projectContextPromises.get(getAccessToken)?.promise === pending) projectContextPromises.delete(getAccessToken); }); } return { project: await awaitProject(pending, signal), accessToken }; } export async function fetchWorkbenchProjectPresentation( getAccessToken?: WorkbenchAccess, signal?: AbortSignal ): Promise { const { project, accessToken } = await findOwnedWorkbenchProject(getAccessToken, signal); const headers = createWorkbenchHeaders(accessToken, project.project_id); const [metadataResult, accessResult] = await Promise.allSettled([ fetchProjectMetadata(project, headers, signal), fetchAccessContext(project, headers, signal) ]); const metadata = metadataResult.status === "fulfilled" ? metadataResult.value : null; const accessContext = accessResult.status === "fulfilled" ? accessResult.value : null; return { projectId: project.project_id, projectName: metadata?.name ?? project.name ?? null, username: accessContext?.username ?? null, projectRole: accessContext?.project_role ?? metadata?.project_role ?? project.project_role ?? null, systemRole: accessContext?.system_role ?? null, isSystemAdmin: accessContext?.is_system_admin ?? false, networkView: parseProjectMapExtent(metadata?.map_extent), placeSearch: parsePlaceSearchConfiguration(metadata?.map_config), metadataAvailable: metadata !== null, accessContextAvailable: accessContext !== null }; } const projectListCaches = new WeakMap(); export function clearWorkbenchProjectCache(provider?: AccessTokenProvider) { if (provider) { projectListCaches.delete(provider); projectContextPromises.delete(provider); } } export async function fetchWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal, force = false) { if (!getAccessToken) return loadWorkbenchProjects(getAccessToken, signal); let cache = projectListCaches.get(getAccessToken); if (!cache) { cache = new ResourceCache(); projectListCaches.set(getAccessToken, cache); } const pending = cache.load("projects", () => loadWorkbenchProjects(getAccessToken, AbortSignal.timeout(PROJECT_REQUEST_TIMEOUT_MS)), PROJECT_CACHE_TTL_MS, { force }); if (!signal) return pending; signal.throwIfAborted(); return new Promise((resolve, reject) => { const abort = () => reject(signal.reason ?? new DOMException("Aborted", "AbortError")); signal.addEventListener("abort", abort, { once: true }); pending.then(resolve, reject).finally(() => signal.removeEventListener("abort", abort)); }); } async function loadWorkbenchProjects(getAccessToken?: AccessTokenProvider, signal?: AbortSignal) { const token = await getAccessToken?.(); signal?.throwIfAborted(); const projects: WorkbenchProject[] = []; let offset = 0; while (true) { const page = await withProjectRequest(SERVER_API_BASE_URL, "读取授权项目", async (requestSignal) => { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects?limit=1000&offset=${offset}`, { headers: createWorkbenchHeaders(token), signal: requestSignal }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) throw new Error(getServerError(payload, response.status, "项目查询")); return projectPageSchema.parse(payload); }, signal); projects.push(...page.items.filter((project) => project.status === "active")); offset += page.items.length; if (offset >= page.total) break; if (!page.items.length) throw new Error("项目列表分页未返回后续数据"); } return projects; } export function parseProjectMapExtent(value: unknown): NetworkViewParams | null { let bbox: z.infer | null = null; if (value && typeof value === "object" && "bbox" in value) { const parsed = bboxSchema.safeParse(value.bbox); if (parsed.success) bbox = parsed.data; } else { const parsed = keyedExtentSchema.safeParse(value); if (parsed.success) { bbox = [parsed.data.xmin, parsed.data.ymin, parsed.data.xmax, parsed.data.ymax]; } } if (!bbox || bbox[0] >= bbox[2] || bbox[1] >= bbox[3]) return null; const webMercatorLimit = 20_037_508.35; if (bbox.some((coordinate) => Math.abs(coordinate) > webMercatorLimit)) return null; return { bbox3857: bbox }; } async function fetchProjectMetadata( project: WorkbenchProject, headers: Headers, signal?: AbortSignal ) { return withProjectRequest(SERVER_API_BASE_URL, "读取项目配置", async (requestSignal) => { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/projects/current/metadata`, { headers, signal: requestSignal }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) { throw new Error(getServerError(payload, response.status, "项目元数据查询")); } const metadata = projectMetadataSchema.parse(payload); if (metadata.project_id !== project.project_id) { throw new Error("项目元数据与授权项目不一致"); } return metadata; }, signal); } async function fetchAccessContext( project: WorkbenchProject, headers: Headers, signal?: AbortSignal ) { return withProjectRequest(SERVER_API_BASE_URL, "读取项目访问权限", async (requestSignal) => { const response = await fetch(`${SERVER_API_BASE_URL}/api/v1/access-context`, { headers, signal: requestSignal }); const payload: unknown = await response.json().catch(() => null); if (!response.ok) { throw new Error(getServerError(payload, response.status, "访问上下文查询")); } const accessContext = accessContextSchema.parse(payload); if (accessContext.project_id !== project.project_id) { throw new Error("访问上下文与授权项目不一致"); } return accessContext; }, signal); } export function createWorkbenchHeaders( accessToken: string | null | undefined, projectId?: string, json = false ) { const headers = new Headers({ Accept: "application/json" }); if (json) headers.set("Content-Type", "application/json"); if (accessToken) headers.set("Authorization", `Bearer ${accessToken}`); if (projectId) headers.set("X-Project-Id", projectId); return headers; } export function getServerError(payload: unknown, status: number, operation: string) { const detail = payload && typeof payload === "object" && "detail" in payload ? String(payload.detail) : `HTTP ${status}`; return `${operation}失败:${detail}`; }