299 lines
12 KiB
TypeScript
299 lines
12 KiB
TypeScript
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<typeof projectSchema>;
|
|
|
|
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<typeof placeSearchConfigurationSchema>;
|
|
|
|
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<WorkbenchAccess, {
|
|
promise: Promise<WorkbenchProject>;
|
|
expiresAt: number;
|
|
}>();
|
|
|
|
function awaitProject(promise: Promise<WorkbenchProject>, signal?: AbortSignal) {
|
|
if (!signal) return promise;
|
|
return new Promise<WorkbenchProject>((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<WorkbenchProjectPresentation> {
|
|
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<AccessTokenProvider, ResourceCache>();
|
|
|
|
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<WorkbenchProject[]>((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<typeof bboxSchema> | 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}`;
|
|
}
|