fix: wire production Amap adapter
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m47s

This commit is contained in:
suyx
2026-08-04 17:09:04 +08:00
parent a7772a7e92
commit 8c349fb56c
7 changed files with 231 additions and 13 deletions
+150 -1
View File
@@ -1,5 +1,31 @@
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 {
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" }>;
dispose?(): void;
reverseGeocode(coordinates: { latitude: number; longitude: number }): Promise<{ formattedValue: string; serviceMode: "mock" | "real" }>;
}
export class MockAmapAdapter implements AmapAdapter {
@@ -13,3 +39,126 @@ export class MockAmapAdapter implements AmapAdapter {
};
}
}
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);
}
}