diff --git a/apps/api/src/amap-adapter.ts b/apps/api/src/amap-adapter.ts index 17ae82c..97ba7f1 100644 --- a/apps/api/src/amap-adapter.ts +++ b/apps/api/src/amap-adapter.ts @@ -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; + +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((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); + } +} diff --git a/apps/api/src/main.ts b/apps/api/src/main.ts index 0af86c9..f7034b4 100644 --- a/apps/api/src/main.ts +++ b/apps/api/src/main.ts @@ -17,7 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js"; import { StructuredJsonlLogger } from "./structured-log.js"; import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js"; import { ModelConfigurationService } from "./model-configuration.js"; -import { MockAmapAdapter } from "./amap-adapter.js"; +import { MockAmapAdapter, type AmapAdapter } from "./amap-adapter.js"; import { StickerReleaseService } from "./sticker-releases.js"; import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js"; @@ -30,10 +30,12 @@ let latestExports: LatestExportService | undefined; let models: ModelConfigurationService | undefined; let recentAssets: RecentAssetService | undefined; let stickers: StickerReleaseService | undefined; +let amap: AmapAdapter = new MockAmapAdapter(); const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath(); if (credentialChannelEnabled) { const clients = initializeApiCredentialClients(await receiveApiCredentials()); try { + amap = clients.amap; const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper) .update(`Dada/P0A/${purpose}/v1`, "utf8") .digest(); @@ -57,6 +59,8 @@ if (credentialChannelEnabled) { recentAssets = new RecentAssetService({ database: registration.database }); registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath)); } catch (error) { + amap.dispose?.(); + amap = new MockAmapAdapter(); stickers?.close(); stickers = undefined; latestExports?.close(); @@ -92,7 +96,7 @@ const adminDiagnostics = adminServicesStorage const app = await createApp({ ...(adminServicesStorage ? { adminServicesStorage } : {}), ...(adminDiagnostics ? { adminDiagnostics } : {}), - amap: new MockAmapAdapter(), + amap, ...(browserSupportRelease ? { browserSupportRelease } : {}), ...(credits ? { credits } : {}), ...(latestExports ? { latestExports } : {}), @@ -115,6 +119,7 @@ if (controlPipeIndex >= 0) { if (!controlPipe) throw new Error("Supervisor control pipe name is required."); const control = attachApiSupervisorControl(controlPipe, async () => { await app.close(); + amap.dispose?.(); latestExports?.close(); credits?.close(); projects?.close(); diff --git a/apps/api/src/supervisor-channel.ts b/apps/api/src/supervisor-channel.ts index 17b8cc8..c2339bb 100644 --- a/apps/api/src/supervisor-channel.ts +++ b/apps/api/src/supervisor-channel.ts @@ -1,5 +1,7 @@ import { createConnection } from "node:net"; +import { RealAmapAdapter } from "./amap-adapter.js"; + const API_CREDENTIALS = ["Dada/P0A/api/resend", "Dada/P0A/api/amap", "Dada/P0A/admin/pepper"] as const; export async function receiveApiCredentials(input: NodeJS.ReadableStream = process.stdin) { @@ -26,10 +28,15 @@ export async function receiveApiCredentials(input: NodeJS.ReadableStream = proce export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) { const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0); - const adminPepperValue = credentials["Dada/P0A/admin/pepper"]; - for (const name of API_CREDENTIALS) credentials[name] = ""; - if (!configured) throw new Error("API credential client initialization failed."); - return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") }; + try { + if (!configured) throw new Error("API credential client initialization failed."); + return { + adminAllowlistPepper: Buffer.from(credentials["Dada/P0A/admin/pepper"], "utf8"), + amap: new RealAmapAdapter(credentials["Dada/P0A/api/amap"]), + }; + } finally { + for (const name of API_CREDENTIALS) credentials[name] = ""; + } } export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise) { diff --git a/apps/web/src/generated/api/types.gen.ts b/apps/web/src/generated/api/types.gen.ts index a38445e..f38130c 100644 --- a/apps/web/src/generated/api/types.gen.ts +++ b/apps/web/src/generated/api/types.gen.ts @@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = { export type ReverseGeocodeResponse = { "formatted_value": string; - "service_mode": "mock"; + "service_mode": "mock" | "real"; "status": "resolved"; }; diff --git a/openapi/openapi.json b/openapi/openapi.json index a1e7e95..8464f1e 100644 --- a/openapi/openapi.json +++ b/openapi/openapi.json @@ -5621,10 +5621,20 @@ "type": "string" }, "service_mode": { - "enum": [ - "mock" - ], - "type": "string" + "anyOf": [ + { + "enum": [ + "mock" + ], + "type": "string" + }, + { + "enum": [ + "real" + ], + "type": "string" + } + ] }, "status": { "enum": [ diff --git a/packages/shared-contracts/src/location.ts b/packages/shared-contracts/src/location.ts index 6ec9b88..7ab010a 100644 --- a/packages/shared-contracts/src/location.ts +++ b/packages/shared-contracts/src/location.ts @@ -7,7 +7,7 @@ export const ReverseGeocodeRequestSchema = Type.Object({ export const ReverseGeocodeResponseSchema = Type.Object({ formatted_value: Type.String({ maxLength: 200, minLength: 1 }), - service_mode: Type.Literal("mock"), + service_mode: Type.Union([Type.Literal("mock"), Type.Literal("real")]), status: Type.Literal("resolved"), }, { additionalProperties: false, $id: "ReverseGeocodeResponse" }); diff --git a/tests/api/wp7-04-amap-production-adapter.test.ts b/tests/api/wp7-04-amap-production-adapter.test.ts new file mode 100644 index 0000000..bd8ee75 --- /dev/null +++ b/tests/api/wp7-04-amap-production-adapter.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it, vi } from "vitest"; + +import { RealAmapAdapter } from "../../apps/api/src/amap-adapter.js"; +import { initializeApiCredentialClients } from "../../apps/api/src/supervisor-channel.js"; + +describe("TDD-WP7-EXT-003 production Amap adapter", () => { + it("uses the fixed HTTPS provider boundary and returns only the formatted location", async () => { + const request = vi.fn(async () => ({ + regeocode: { formatted_address: "模拟省模拟市" }, + status: "1", + })); + const adapter = new RealAmapAdapter("fixture-amap-value", { request }); + + await expect(adapter.reverseGeocode({ latitude: 12.3456, longitude: 65.4321 })).resolves.toEqual({ + formattedValue: "模拟省模拟市", + serviceMode: "real", + }); + expect(request).toHaveBeenCalledOnce(); + expect(request.mock.calls[0]?.[0]).toMatchObject({ + allowRedirects: false, + hostname: "restapi.amap.com", + maxResponseBytes: 65_536, + method: "GET", + protocol: "https:", + rejectUnauthorized: true, + timeoutMs: 15_000, + }); + expect(request.mock.calls[0]?.[0].path).toContain("/v3/geocode/regeo?"); + expect(request.mock.calls[0]?.[0].path).toContain("location=65.4321%2C12.3456"); + adapter.dispose(); + await expect(adapter.reverseGeocode({ latitude: 0, longitude: 0 })).rejects.toThrow("amap_adapter_disposed"); + }); + + it("constructs the real client from the API credential channel and clears the source values", () => { + const credentials = { + "Dada/P0A/admin/pepper": "fixture-admin-value", + "Dada/P0A/api/amap": "fixture-amap-value", + "Dada/P0A/api/resend": "fixture-resend-value", + }; + + const clients = initializeApiCredentialClients(credentials); + expect(clients.amap).toBeInstanceOf(RealAmapAdapter); + expect(Object.values(credentials)).toEqual(["", "", ""]); + clients.amap.dispose(); + clients.adminAllowlistPepper.fill(0); + }); +});