fix: wire production Amap adapter
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m47s
Dada P0-A isolated Windows CI / validate-and-package (push) Failing after 1m47s
This commit is contained in:
@@ -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 {
|
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 {
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ import { readSecureConfigCandidate } from "./secure-config.js";
|
|||||||
import { StructuredJsonlLogger } from "./structured-log.js";
|
import { StructuredJsonlLogger } from "./structured-log.js";
|
||||||
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
import { attachApiSupervisorControl, initializeApiCredentialClients, receiveApiCredentials } from "./supervisor-channel.js";
|
||||||
import { ModelConfigurationService } from "./model-configuration.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 { StickerReleaseService } from "./sticker-releases.js";
|
||||||
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
import { createAdminDiagnosticsProvider, createAdminServicesStorageProvider } from "./admin-state.js";
|
||||||
|
|
||||||
@@ -30,10 +30,12 @@ let latestExports: LatestExportService | undefined;
|
|||||||
let models: ModelConfigurationService | undefined;
|
let models: ModelConfigurationService | undefined;
|
||||||
let recentAssets: RecentAssetService | undefined;
|
let recentAssets: RecentAssetService | undefined;
|
||||||
let stickers: StickerReleaseService | undefined;
|
let stickers: StickerReleaseService | undefined;
|
||||||
|
let amap: AmapAdapter = new MockAmapAdapter();
|
||||||
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
const instanceConfigPath = process.env.DADA_INSTANCE_CONFIG_PATH ?? defaultInstanceConfigPath();
|
||||||
if (credentialChannelEnabled) {
|
if (credentialChannelEnabled) {
|
||||||
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
const clients = initializeApiCredentialClients(await receiveApiCredentials());
|
||||||
try {
|
try {
|
||||||
|
amap = clients.amap;
|
||||||
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
const derivePepper = (purpose: string) => createHmac("sha256", clients.adminAllowlistPepper)
|
||||||
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
.update(`Dada/P0A/${purpose}/v1`, "utf8")
|
||||||
.digest();
|
.digest();
|
||||||
@@ -57,6 +59,8 @@ if (credentialChannelEnabled) {
|
|||||||
recentAssets = new RecentAssetService({ database: registration.database });
|
recentAssets = new RecentAssetService({ database: registration.database });
|
||||||
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
registration.applySecureConfig(readSecureConfigCandidate(instanceConfigPath));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
|
amap.dispose?.();
|
||||||
|
amap = new MockAmapAdapter();
|
||||||
stickers?.close();
|
stickers?.close();
|
||||||
stickers = undefined;
|
stickers = undefined;
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
@@ -92,7 +96,7 @@ const adminDiagnostics = adminServicesStorage
|
|||||||
const app = await createApp({
|
const app = await createApp({
|
||||||
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
...(adminServicesStorage ? { adminServicesStorage } : {}),
|
||||||
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
...(adminDiagnostics ? { adminDiagnostics } : {}),
|
||||||
amap: new MockAmapAdapter(),
|
amap,
|
||||||
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
...(browserSupportRelease ? { browserSupportRelease } : {}),
|
||||||
...(credits ? { credits } : {}),
|
...(credits ? { credits } : {}),
|
||||||
...(latestExports ? { latestExports } : {}),
|
...(latestExports ? { latestExports } : {}),
|
||||||
@@ -115,6 +119,7 @@ if (controlPipeIndex >= 0) {
|
|||||||
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
if (!controlPipe) throw new Error("Supervisor control pipe name is required.");
|
||||||
const control = attachApiSupervisorControl(controlPipe, async () => {
|
const control = attachApiSupervisorControl(controlPipe, async () => {
|
||||||
await app.close();
|
await app.close();
|
||||||
|
amap.dispose?.();
|
||||||
latestExports?.close();
|
latestExports?.close();
|
||||||
credits?.close();
|
credits?.close();
|
||||||
projects?.close();
|
projects?.close();
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
import { createConnection } from "node:net";
|
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;
|
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) {
|
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>) {
|
export function initializeApiCredentialClients(credentials: Record<(typeof API_CREDENTIALS)[number], string>) {
|
||||||
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
const configured = API_CREDENTIALS.every((name) => credentials[name].length > 0);
|
||||||
const adminPepperValue = credentials["Dada/P0A/admin/pepper"];
|
try {
|
||||||
for (const name of API_CREDENTIALS) credentials[name] = "";
|
if (!configured) throw new Error("API credential client initialization failed.");
|
||||||
if (!configured) throw new Error("API credential client initialization failed.");
|
return {
|
||||||
return { adminAllowlistPepper: Buffer.from(adminPepperValue, "utf8") };
|
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<void>) {
|
export function attachApiSupervisorControl(pipeName: string, shutdown: () => Promise<void>) {
|
||||||
|
|||||||
@@ -922,7 +922,7 @@ export type ReverseGeocodeRequest = {
|
|||||||
|
|
||||||
export type ReverseGeocodeResponse = {
|
export type ReverseGeocodeResponse = {
|
||||||
"formatted_value": string;
|
"formatted_value": string;
|
||||||
"service_mode": "mock";
|
"service_mode": "mock" | "real";
|
||||||
"status": "resolved";
|
"status": "resolved";
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
+14
-4
@@ -5621,10 +5621,20 @@
|
|||||||
"type": "string"
|
"type": "string"
|
||||||
},
|
},
|
||||||
"service_mode": {
|
"service_mode": {
|
||||||
"enum": [
|
"anyOf": [
|
||||||
"mock"
|
{
|
||||||
],
|
"enum": [
|
||||||
"type": "string"
|
"mock"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"enum": [
|
||||||
|
"real"
|
||||||
|
],
|
||||||
|
"type": "string"
|
||||||
|
}
|
||||||
|
]
|
||||||
},
|
},
|
||||||
"status": {
|
"status": {
|
||||||
"enum": [
|
"enum": [
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ export const ReverseGeocodeRequestSchema = Type.Object({
|
|||||||
|
|
||||||
export const ReverseGeocodeResponseSchema = Type.Object({
|
export const ReverseGeocodeResponseSchema = Type.Object({
|
||||||
formatted_value: Type.String({ maxLength: 200, minLength: 1 }),
|
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"),
|
status: Type.Literal("resolved"),
|
||||||
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
}, { additionalProperties: false, $id: "ReverseGeocodeResponse" });
|
||||||
|
|
||||||
|
|||||||
@@ -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);
|
||||||
|
});
|
||||||
|
});
|
||||||
Reference in New Issue
Block a user