165 lines
6.0 KiB
TypeScript
165 lines
6.0 KiB
TypeScript
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<unknown>;
|
|
|
|
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<unknown>((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<string, unknown> {
|
|
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);
|
|
}
|
|
}
|