import { request as httpsRequest } from "node:https"; const amapHostname = "restapi.amap.com" as const; const amapMaxResponseBytes = 65_536; const amapTimeoutMs = 15_000; export interface AmapHttpRequest { allowRedirects: false; hostname: typeof amapHostname; maxResponseBytes: number; method: "GET"; path: string; protocol: "https:"; rejectUnauthorized: true; timeoutMs: number; } type AmapRequester = (request: AmapHttpRequest) => Promise; export class AmapAdapterError extends Error { constructor(readonly code: "amap_adapter_disposed" | "amap_invalid_request" | "amap_invalid_response" | "amap_provider_rejected" | "amap_provider_unavailable" | "amap_redirect_rejected" | "amap_request_timeout" | "amap_response_too_large") { super(code); } } export interface AmapAdapter { dispose?(): void; reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>; } export class MockAmapAdapter implements AmapAdapter { readonly calls: Array<{ latitude: number; longitude: number }> = []; async reverseGeocode(coordinates: { latitude: number; longitude: number }) { this.calls.push({ ...coordinates }); return { formattedValue: `模拟地点 ${coordinates.latitude.toFixed(4)}, ${coordinates.longitude.toFixed(4)}`, serviceMode: "mock" as const, }; } } function requestAmapJson(input: AmapHttpRequest) { return new Promise((resolve, reject) => { if (input.protocol !== "https:" || input.hostname !== amapHostname || input.allowRedirects || !input.rejectUnauthorized) { reject(new AmapAdapterError("amap_invalid_request")); return; } let settled = false; const finish = (callback: () => void) => { if (settled) return; settled = true; callback(); }; const request = httpsRequest({ headers: { Accept: "application/json" }, hostname: input.hostname, method: input.method, path: input.path, port: 443, protocol: input.protocol, rejectUnauthorized: input.rejectUnauthorized, servername: input.hostname, }, (response) => { const statusCode = response.statusCode ?? 0; if (statusCode >= 300 && statusCode < 400) { response.resume(); finish(() => reject(new AmapAdapterError("amap_redirect_rejected"))); return; } if (statusCode !== 200) { response.resume(); finish(() => reject(new AmapAdapterError("amap_provider_unavailable"))); return; } const declaredLength = Number(response.headers["content-length"] ?? 0); if (Number.isFinite(declaredLength) && declaredLength > input.maxResponseBytes) { response.destroy(); finish(() => reject(new AmapAdapterError("amap_response_too_large"))); return; } const chunks: Buffer[] = []; let receivedBytes = 0; response.on("data", (chunk: Buffer | string) => { const bytes = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk); receivedBytes += bytes.length; if (receivedBytes > input.maxResponseBytes) { response.destroy(); finish(() => reject(new AmapAdapterError("amap_response_too_large"))); return; } chunks.push(bytes); }); response.on("end", () => { finish(() => { try { resolve(JSON.parse(Buffer.concat(chunks).toString("utf8"))); } catch { reject(new AmapAdapterError("amap_invalid_response")); } finally { for (const chunk of chunks) chunk.fill(0); chunks.length = 0; } }); }); }); request.setTimeout(input.timeoutMs, () => request.destroy(new AmapAdapterError("amap_request_timeout"))); request.on("error", (error) => finish(() => reject(error instanceof AmapAdapterError ? error : new AmapAdapterError("amap_provider_unavailable")))); request.end(); }); } function isRecord(value: unknown): value is Record { return Boolean(value) && typeof value === "object" && !Array.isArray(value); } export class RealAmapAdapter implements AmapAdapter { private readonly credential: Buffer; private readonly requester: AmapRequester; private disposed = false; constructor(value: string, options: { request?: AmapRequester } = {}) { if (!value.trim()) throw new AmapAdapterError("amap_invalid_request"); this.credential = Buffer.from(value, "utf8"); this.requester = options.request ?? requestAmapJson; } async reverseGeocode(coordinates: { latitude: number; longitude: number }) { if (this.disposed) throw new AmapAdapterError("amap_adapter_disposed"); if (!Number.isFinite(coordinates.latitude) || coordinates.latitude < -90 || coordinates.latitude > 90 || !Number.isFinite(coordinates.longitude) || coordinates.longitude < -180 || coordinates.longitude > 180) { throw new AmapAdapterError("amap_invalid_request"); } const query = new URLSearchParams({ extensions: "base", key: this.credential.toString("utf8"), location: `${coordinates.longitude},${coordinates.latitude}`, }); const response = await this.requester({ allowRedirects: false, hostname: amapHostname, maxResponseBytes: amapMaxResponseBytes, method: "GET", path: `/v3/geocode/regeo?${query.toString()}`, protocol: "https:", rejectUnauthorized: true, timeoutMs: amapTimeoutMs, }); if (!isRecord(response) || response.status !== "1" || !isRecord(response.regeocode)) { throw new AmapAdapterError("amap_provider_rejected"); } const formattedValue = typeof response.regeocode.formatted_address === "string" ? response.regeocode.formatted_address.trim() : ""; if (!formattedValue || formattedValue.length > 200) throw new AmapAdapterError("amap_invalid_response"); return { formattedValue, serviceMode: "real" as const }; } dispose() { if (this.disposed) return; this.disposed = true; this.credential.fill(0); } }