feat: proxy amap place services
This commit is contained in:
parent
4c6215b233
commit
faedf05ae5
@ -107,15 +107,11 @@ export function applyGlobalMiddleware(
|
||||
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||
connectSrc: [
|
||||
"'self'", "ws:", "wss:",
|
||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
||||
"https://restapi.amap.com", "https://webapi.amap.com", "https://*.amap.com",
|
||||
"https://api.openweathermap.org",
|
||||
"https://en.wikipedia.org", "https://commons.wikimedia.org",
|
||||
"https://*.basemaps.cartocdn.com", "https://*.tile.openstreetmap.org",
|
||||
"https://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
|
||||
"https://router.project-osrm.org/route/v1/", "https://routing.openstreetmap.de/",
|
||||
"https://api.mapbox.com", "https://*.tiles.mapbox.com", "https://events.mapbox.com",
|
||||
"https://tiles.openfreemap.org"
|
||||
],
|
||||
workerSrc: ["'self'", "blob:"],
|
||||
childSrc: ["'self'", "blob:"],
|
||||
|
||||
@ -9,24 +9,23 @@ import type {
|
||||
} from '@trek/shared';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
import {
|
||||
searchPlaces,
|
||||
autocompletePlaces,
|
||||
getPlaceDetails,
|
||||
getPlaceDetailsExpanded,
|
||||
getPlacePhoto,
|
||||
reverseGeocode,
|
||||
resolveGoogleMapsUrl,
|
||||
searchOverpassPois,
|
||||
} from '../../services/mapsService';
|
||||
autocompleteAmapPlaces,
|
||||
getAmapPlaceDetails,
|
||||
getAmapPlaceDetailsExpanded,
|
||||
resolveAmapUri,
|
||||
reverseAmapGeocode,
|
||||
searchAmapPlaces,
|
||||
searchAmapPois,
|
||||
} from '../../services/amap/amap.places';
|
||||
import { AmapClient } from '../../services/amap/amap.client';
|
||||
import { serveFilePath } from '../../services/placePhotoCache';
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
/**
|
||||
* Thin Nest wrapper around the existing maps service. All geocoding, the
|
||||
* provider fan-out (Nominatim/Overpass/Google) and — importantly — the SSRF
|
||||
* guard live in mapsService and are reused unchanged, so behaviour and the
|
||||
* outbound-URL protection are identical.
|
||||
* Thin Nest wrapper around the AMap-only adapter. Historical fields remain in
|
||||
* response envelopes where clients still expect them, but runtime map calls go
|
||||
* through the server-side AMap Web Service key only.
|
||||
*
|
||||
* The per-endpoint kill-switches are settings reads the legacy route does
|
||||
* inline; they're encapsulated here as `*Disabled()` helpers over the same
|
||||
@ -34,6 +33,8 @@ type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; ln
|
||||
*/
|
||||
@Injectable()
|
||||
export class MapsService {
|
||||
private readonly amap = new AmapClient();
|
||||
|
||||
constructor(private readonly database: DatabaseService) {}
|
||||
|
||||
private isSettingDisabled(key: string): boolean {
|
||||
@ -57,23 +58,32 @@ export class MapsService {
|
||||
}
|
||||
|
||||
search(userId: number, query: string, lang?: string, locationBias?: { lat: number; lng: number; radius?: number }): Promise<MapsSearchResult> {
|
||||
return searchPlaces(userId, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
void userId;
|
||||
return searchAmapPlaces(this.amap, query, lang, locationBias) as Promise<MapsSearchResult>;
|
||||
}
|
||||
|
||||
autocomplete(userId: number, input: string, lang?: string, locationBias?: LocationBias): Promise<MapsAutocompleteResult> {
|
||||
return autocompletePlaces(userId, input, lang, locationBias) as Promise<MapsAutocompleteResult>;
|
||||
void userId;
|
||||
return autocompleteAmapPlaces(this.amap, input, lang, locationBias) as Promise<MapsAutocompleteResult>;
|
||||
}
|
||||
|
||||
details(userId: number, placeId: string, lang?: string): Promise<MapsPlaceDetailsResult> {
|
||||
return getPlaceDetails(userId, placeId, lang) as Promise<MapsPlaceDetailsResult>;
|
||||
void userId;
|
||||
return getAmapPlaceDetails(this.amap, placeId, lang) as Promise<MapsPlaceDetailsResult>;
|
||||
}
|
||||
|
||||
detailsExpanded(userId: number, placeId: string, lang: string | undefined, refresh: boolean): Promise<MapsPlaceDetailsResult> {
|
||||
return getPlaceDetailsExpanded(userId, placeId, lang, refresh) as Promise<MapsPlaceDetailsResult>;
|
||||
void userId;
|
||||
return getAmapPlaceDetailsExpanded(this.amap, placeId, lang, refresh) as Promise<MapsPlaceDetailsResult>;
|
||||
}
|
||||
|
||||
photo(userId: number, placeId: string, lat: number, lng: number, name?: string): Promise<MapsPlacePhotoResult> {
|
||||
return getPlacePhoto(userId, placeId, lat, lng, name) as Promise<MapsPlacePhotoResult>;
|
||||
void userId;
|
||||
void placeId;
|
||||
void lat;
|
||||
void lng;
|
||||
void name;
|
||||
return Promise.resolve({ photoUrl: null });
|
||||
}
|
||||
|
||||
photoBytesPath(placeId: string): string | null {
|
||||
@ -81,15 +91,14 @@ export class MapsService {
|
||||
}
|
||||
|
||||
reverse(lat: string, lng: string, lang?: string): Promise<MapsReverseResult> {
|
||||
return reverseGeocode(lat, lng, lang) as Promise<MapsReverseResult>;
|
||||
return reverseAmapGeocode(this.amap, lat, lng, lang) as Promise<MapsReverseResult>;
|
||||
}
|
||||
|
||||
resolveUrl(url: string): Promise<MapsResolveUrlResult> {
|
||||
return resolveGoogleMapsUrl(url) as Promise<MapsResolveUrlResult>;
|
||||
return Promise.resolve(resolveAmapUri(url) as MapsResolveUrlResult);
|
||||
}
|
||||
|
||||
// OSM-only POI search by category within a viewport bbox (never calls Google).
|
||||
pois(category: string, bbox: { south: number; west: number; north: number; east: number }) {
|
||||
return searchOverpassPois(category, bbox);
|
||||
return searchAmapPois(this.amap, category, bbox);
|
||||
}
|
||||
}
|
||||
|
||||
66
server/src/services/amap/amap.client.ts
Normal file
66
server/src/services/amap/amap.client.ts
Normal file
@ -0,0 +1,66 @@
|
||||
type AmapEnvelope = {
|
||||
status?: string | number;
|
||||
info?: string;
|
||||
infocode?: string;
|
||||
};
|
||||
|
||||
export type AmapErrorCode = 'AMAP_NOT_CONFIGURED' | 'AMAP_PROVIDER_ERROR' | 'AMAP_NETWORK_ERROR';
|
||||
|
||||
export class AmapServiceError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly code: AmapErrorCode,
|
||||
readonly status: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'AmapServiceError';
|
||||
}
|
||||
}
|
||||
|
||||
export class AmapClient {
|
||||
constructor(
|
||||
private readonly baseUrl = 'https://restapi.amap.com',
|
||||
private readonly timeoutMs = 8000,
|
||||
) {}
|
||||
|
||||
async get<T extends AmapEnvelope>(path: string, params: URLSearchParams = new URLSearchParams()): Promise<T> {
|
||||
const key = process.env.AMAP_WEB_SERVICE_KEY?.trim();
|
||||
if (!key) {
|
||||
throw new AmapServiceError('AMap Web Service key is not configured', 'AMAP_NOT_CONFIGURED', 503);
|
||||
}
|
||||
|
||||
const url = this.buildUrl(path, params, key);
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), this.timeoutMs);
|
||||
|
||||
try {
|
||||
const response = await fetch(url, { signal: controller.signal });
|
||||
const body = await response.json().catch(() => ({})) as T;
|
||||
|
||||
if (!response.ok || String(body.status ?? '1') === '0') {
|
||||
throw new AmapServiceError(
|
||||
`AMap provider error${body.infocode ? ` ${body.infocode}` : ''}${body.info ? `: ${body.info}` : ''}`,
|
||||
'AMAP_PROVIDER_ERROR',
|
||||
response.ok ? 502 : response.status,
|
||||
);
|
||||
}
|
||||
|
||||
return body;
|
||||
} catch (err) {
|
||||
if (err instanceof AmapServiceError) throw err;
|
||||
throw new AmapServiceError('AMap network error', 'AMAP_NETWORK_ERROR', 502);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
private buildUrl(path: string, params: URLSearchParams, key: string): string {
|
||||
const normalizedPath = path.startsWith('/') ? path : `/${path}`;
|
||||
const url = new URL(normalizedPath, this.baseUrl);
|
||||
for (const [paramKey, value] of params.entries()) {
|
||||
url.searchParams.set(paramKey, value);
|
||||
}
|
||||
url.searchParams.set('key', key);
|
||||
return url.toString();
|
||||
}
|
||||
}
|
||||
253
server/src/services/amap/amap.places.ts
Normal file
253
server/src/services/amap/amap.places.ts
Normal file
@ -0,0 +1,253 @@
|
||||
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared';
|
||||
|
||||
import { AmapClient } from './amap.client';
|
||||
|
||||
type AmapHttpClient = Pick<AmapClient, 'get'>;
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
type AmapPoi = {
|
||||
id?: string;
|
||||
name?: string;
|
||||
address?: string | unknown[];
|
||||
location?: string;
|
||||
tel?: string | unknown[];
|
||||
type?: string;
|
||||
typecode?: string;
|
||||
website?: string;
|
||||
};
|
||||
|
||||
type AmapPoiResponse = {
|
||||
status?: string;
|
||||
pois?: AmapPoi[];
|
||||
};
|
||||
|
||||
type AmapTip = {
|
||||
id?: string | unknown[];
|
||||
name?: string;
|
||||
district?: string;
|
||||
address?: string | unknown[];
|
||||
location?: string;
|
||||
};
|
||||
|
||||
type AmapTipsResponse = {
|
||||
status?: string;
|
||||
tips?: AmapTip[];
|
||||
};
|
||||
|
||||
type AmapRegeoResponse = {
|
||||
status?: string;
|
||||
regeocode?: {
|
||||
formatted_address?: string;
|
||||
addressComponent?: {
|
||||
province?: string;
|
||||
city?: string | unknown[];
|
||||
district?: string;
|
||||
township?: string;
|
||||
};
|
||||
};
|
||||
};
|
||||
|
||||
const defaultClient = new AmapClient();
|
||||
|
||||
export async function searchAmapPlaces(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
query: string,
|
||||
lang?: string,
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
): Promise<{ places: Record<string, unknown>[]; source: 'amap' }> {
|
||||
const params = new URLSearchParams({
|
||||
keywords: query,
|
||||
offset: '10',
|
||||
page: '1',
|
||||
extensions: 'all',
|
||||
});
|
||||
if (lang) params.set('language', toAmapLanguage(lang));
|
||||
if (locationBias) {
|
||||
const gcj = wgs84ToGcj02({ lat: locationBias.lat, lng: locationBias.lng });
|
||||
params.set('location', `${gcj.lng},${gcj.lat}`);
|
||||
params.set('radius', String(locationBias.radius ?? 50000));
|
||||
}
|
||||
|
||||
const data = await client.get<AmapPoiResponse>('/v3/place/text', params);
|
||||
return {
|
||||
places: (data.pois ?? []).map(mapPoi).filter((place): place is Record<string, unknown> => place !== null),
|
||||
source: 'amap',
|
||||
};
|
||||
}
|
||||
|
||||
export async function autocompleteAmapPlaces(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
input: string,
|
||||
lang?: string,
|
||||
locationBias?: LocationBias,
|
||||
): Promise<{ suggestions: { placeId: string; mainText: string; secondaryText: string }[]; source: 'amap' }> {
|
||||
const params = new URLSearchParams({ keywords: input });
|
||||
if (lang) params.set('language', toAmapLanguage(lang));
|
||||
if (locationBias) {
|
||||
const center = {
|
||||
lat: (locationBias.low.lat + locationBias.high.lat) / 2,
|
||||
lng: (locationBias.low.lng + locationBias.high.lng) / 2,
|
||||
};
|
||||
const gcj = wgs84ToGcj02(center);
|
||||
params.set('location', `${gcj.lng},${gcj.lat}`);
|
||||
}
|
||||
|
||||
const data = await client.get<AmapTipsResponse>('/v3/assistant/inputtips', params);
|
||||
const suggestions = (data.tips ?? [])
|
||||
.map(tip => {
|
||||
const id = firstString(tip.id);
|
||||
if (!id || !tip.name) return null;
|
||||
return {
|
||||
placeId: id,
|
||||
mainText: tip.name,
|
||||
secondaryText: [firstString(tip.address), tip.district].filter(Boolean).join(', '),
|
||||
};
|
||||
})
|
||||
.filter((suggestion): suggestion is { placeId: string; mainText: string; secondaryText: string } => suggestion !== null)
|
||||
.slice(0, 5);
|
||||
|
||||
return { suggestions, source: 'amap' };
|
||||
}
|
||||
|
||||
export async function getAmapPlaceDetails(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
placeId: string,
|
||||
lang?: string,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const params = new URLSearchParams({
|
||||
id: placeId,
|
||||
extensions: 'all',
|
||||
});
|
||||
if (lang) params.set('language', toAmapLanguage(lang));
|
||||
|
||||
const data = await client.get<AmapPoiResponse>('/v3/place/detail', params);
|
||||
return { place: mapPoi(data.pois?.[0]) };
|
||||
}
|
||||
|
||||
export function getAmapPlaceDetailsExpanded(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
placeId: string,
|
||||
lang?: string,
|
||||
_refresh = false,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
return getAmapPlaceDetails(client, placeId, lang);
|
||||
}
|
||||
|
||||
export async function reverseAmapGeocode(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
lat: string,
|
||||
lng: string,
|
||||
lang?: string,
|
||||
): Promise<{ name: string | null; address: string | null }> {
|
||||
const wgsLat = Number(lat);
|
||||
const wgsLng = Number(lng);
|
||||
if (!Number.isFinite(wgsLat) || !Number.isFinite(wgsLng)) return { name: null, address: null };
|
||||
|
||||
const gcj = wgs84ToGcj02({ lat: wgsLat, lng: wgsLng });
|
||||
const params = new URLSearchParams({
|
||||
location: `${gcj.lng},${gcj.lat}`,
|
||||
extensions: 'base',
|
||||
});
|
||||
if (lang) params.set('language', toAmapLanguage(lang));
|
||||
|
||||
const data = await client.get<AmapRegeoResponse>('/v3/geocode/regeo', params);
|
||||
const component = data.regeocode?.addressComponent;
|
||||
const name = [component?.province, firstString(component?.city), component?.district, component?.township].filter(Boolean).join(' ') || null;
|
||||
return {
|
||||
name,
|
||||
address: data.regeocode?.formatted_address ?? null,
|
||||
};
|
||||
}
|
||||
|
||||
export async function searchAmapPois(
|
||||
client: AmapHttpClient = defaultClient,
|
||||
category: string,
|
||||
bbox: { south: number; west: number; north: number; east: number },
|
||||
): Promise<{ pois: Record<string, unknown>[]; source: 'amap'; truncated: boolean; clamped: boolean }> {
|
||||
const center = {
|
||||
lat: (bbox.south + bbox.north) / 2,
|
||||
lng: (bbox.west + bbox.east) / 2,
|
||||
};
|
||||
const gcj = wgs84ToGcj02(center);
|
||||
const params = new URLSearchParams({
|
||||
keywords: category,
|
||||
location: `${gcj.lng},${gcj.lat}`,
|
||||
radius: String(radiusMetersForBbox(bbox)),
|
||||
offset: '60',
|
||||
page: '1',
|
||||
extensions: 'all',
|
||||
});
|
||||
|
||||
const data = await client.get<AmapPoiResponse>('/v3/place/around', params);
|
||||
return {
|
||||
pois: (data.pois ?? []).map(mapPoi).filter((place): place is Record<string, unknown> => place !== null),
|
||||
source: 'amap',
|
||||
truncated: false,
|
||||
clamped: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function resolveAmapUri(url: string): { lat: number; lng: number; name: string | null; address: string | null } {
|
||||
const parsed = new URL(url);
|
||||
if (!/(^|\.)amap\.com$/i.test(parsed.hostname)) {
|
||||
throw Object.assign(new Error('Only AMap links are supported'), { status: 422 });
|
||||
}
|
||||
|
||||
const position = parsed.searchParams.get('position') ?? parsed.searchParams.get('to') ?? parsed.searchParams.get('from');
|
||||
const point = parseAmapLocation(position ?? undefined);
|
||||
if (!point) throw Object.assign(new Error('AMap link does not contain coordinates'), { status: 422 });
|
||||
|
||||
return {
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
name: parsed.searchParams.get('name'),
|
||||
address: null,
|
||||
};
|
||||
}
|
||||
|
||||
function mapPoi(poi: AmapPoi | undefined): Record<string, unknown> | null {
|
||||
if (!poi) return null;
|
||||
const point = parseAmapLocation(poi.location);
|
||||
if (!poi.id || !poi.name || !point) return null;
|
||||
|
||||
return {
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
osm_id: null,
|
||||
amap_place_id: poi.id,
|
||||
name: poi.name,
|
||||
address: firstString(poi.address) ?? '',
|
||||
lat: point.lat,
|
||||
lng: point.lng,
|
||||
rating: null,
|
||||
website: poi.website ?? null,
|
||||
phone: firstString(poi.tel) ?? null,
|
||||
types: [poi.type, poi.typecode].filter(Boolean),
|
||||
source: 'amap',
|
||||
};
|
||||
}
|
||||
|
||||
function parseAmapLocation(location: string | undefined): { lat: number; lng: number } | null {
|
||||
if (!location) return null;
|
||||
const [lngRaw, latRaw] = location.split(',');
|
||||
const lng = Number(lngRaw);
|
||||
const lat = Number(latRaw);
|
||||
if (!Number.isFinite(lat) || !Number.isFinite(lng)) return null;
|
||||
return gcj02ToWgs84({ lat, lng });
|
||||
}
|
||||
|
||||
function firstString(value: string | unknown[] | undefined): string | undefined {
|
||||
if (typeof value === 'string') return value;
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function toAmapLanguage(lang: string): string {
|
||||
return lang.toLowerCase().startsWith('zh') ? 'zh_cn' : 'en';
|
||||
}
|
||||
|
||||
function radiusMetersForBbox(bbox: { south: number; west: number; north: number; east: number }): number {
|
||||
const latMeters = Math.abs(bbox.north - bbox.south) * 111_000;
|
||||
const lngMeters = Math.abs(bbox.east - bbox.west) * 111_000;
|
||||
return Math.max(200, Math.min(50_000, Math.ceil(Math.max(latMeters, lngMeters) / 2)));
|
||||
}
|
||||
@ -1,18 +1,17 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
|
||||
const { maps } = vi.hoisted(() => ({
|
||||
maps: {
|
||||
searchPlaces: vi.fn(),
|
||||
autocompletePlaces: vi.fn(),
|
||||
getPlaceDetails: vi.fn(),
|
||||
getPlaceDetailsExpanded: vi.fn(),
|
||||
getPlacePhoto: vi.fn(),
|
||||
reverseGeocode: vi.fn(),
|
||||
resolveGoogleMapsUrl: vi.fn(),
|
||||
searchOverpassPois: vi.fn(),
|
||||
const { amapPlaces } = vi.hoisted(() => ({
|
||||
amapPlaces: {
|
||||
searchAmapPlaces: vi.fn(),
|
||||
autocompleteAmapPlaces: vi.fn(),
|
||||
getAmapPlaceDetails: vi.fn(),
|
||||
getAmapPlaceDetailsExpanded: vi.fn(),
|
||||
reverseAmapGeocode: vi.fn(),
|
||||
searchAmapPois: vi.fn(),
|
||||
resolveAmapUri: vi.fn(),
|
||||
},
|
||||
}));
|
||||
vi.mock('../../../src/services/mapsService', () => maps);
|
||||
vi.mock('../../../src/services/amap/amap.places', () => amapPlaces);
|
||||
|
||||
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
||||
vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath }));
|
||||
@ -65,54 +64,53 @@ describe('MapsService', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('delegation to the legacy maps service', () => {
|
||||
describe('delegation to the AMap adapter', () => {
|
||||
it('search forwards userId, query, lang and bias', () => {
|
||||
maps.searchPlaces.mockResolvedValue({ places: [], source: 'osm' });
|
||||
amapPlaces.searchAmapPlaces.mockResolvedValue({ places: [], source: 'amap' });
|
||||
const bias = { lat: 1, lng: 2, radius: 5 };
|
||||
svc().search(3, 'berlin', 'de', bias);
|
||||
expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', 'de', bias);
|
||||
expect(amapPlaces.searchAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'berlin', 'de', bias);
|
||||
});
|
||||
|
||||
it('search works without optional args', () => {
|
||||
svc().search(3, 'berlin');
|
||||
expect(maps.searchPlaces).toHaveBeenCalledWith(3, 'berlin', undefined, undefined);
|
||||
expect(amapPlaces.searchAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'berlin', undefined, undefined);
|
||||
});
|
||||
|
||||
it('autocomplete forwards through', () => {
|
||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||
svc().autocomplete(3, 'be', 'en', bias);
|
||||
expect(maps.autocompletePlaces).toHaveBeenCalledWith(3, 'be', 'en', bias);
|
||||
expect(amapPlaces.autocompleteAmapPlaces).toHaveBeenCalledWith(expect.anything(), 'be', 'en', bias);
|
||||
});
|
||||
|
||||
it('details forwards through', () => {
|
||||
svc().details(3, 'p1', 'de');
|
||||
expect(maps.getPlaceDetails).toHaveBeenCalledWith(3, 'p1', 'de');
|
||||
expect(amapPlaces.getAmapPlaceDetails).toHaveBeenCalledWith(expect.anything(), 'p1', 'de');
|
||||
});
|
||||
|
||||
it('detailsExpanded forwards refresh through', () => {
|
||||
svc().detailsExpanded(3, 'p1', 'de', true);
|
||||
expect(maps.getPlaceDetailsExpanded).toHaveBeenCalledWith(3, 'p1', 'de', true);
|
||||
expect(amapPlaces.getAmapPlaceDetailsExpanded).toHaveBeenCalledWith(expect.anything(), 'p1', 'de', true);
|
||||
});
|
||||
|
||||
it('photo forwards coords and name through', () => {
|
||||
svc().photo(3, 'p1', 1.5, 2.5, 'Spot');
|
||||
expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot');
|
||||
it('photo returns an empty AMap-compatible result without calling legacy providers', async () => {
|
||||
await expect(svc().photo(3, 'p1', 1.5, 2.5, 'Spot')).resolves.toEqual({ photoUrl: null });
|
||||
});
|
||||
|
||||
it('reverse forwards through', () => {
|
||||
svc().reverse('1', '2', 'de');
|
||||
expect(maps.reverseGeocode).toHaveBeenCalledWith('1', '2', 'de');
|
||||
expect(amapPlaces.reverseAmapGeocode).toHaveBeenCalledWith(expect.anything(), '1', '2', 'de');
|
||||
});
|
||||
|
||||
it('resolveUrl forwards through', () => {
|
||||
svc().resolveUrl('https://maps.app.goo.gl/x');
|
||||
expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x');
|
||||
svc().resolveUrl('https://uri.amap.com/marker?position=116.39747,39.908823');
|
||||
expect(amapPlaces.resolveAmapUri).toHaveBeenCalledWith('https://uri.amap.com/marker?position=116.39747,39.908823');
|
||||
});
|
||||
|
||||
it('pois forwards category and bbox through', () => {
|
||||
const bbox = { south: 1, west: 2, north: 3, east: 4 };
|
||||
svc().pois('cafe', bbox);
|
||||
expect(maps.searchOverpassPois).toHaveBeenCalledWith('cafe', bbox);
|
||||
expect(amapPlaces.searchAmapPois).toHaveBeenCalledWith(expect.anything(), 'cafe', bbox);
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
64
server/tests/unit/services/amap/amap.client.test.ts
Normal file
64
server/tests/unit/services/amap/amap.client.test.ts
Normal file
@ -0,0 +1,64 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { AmapClient } from '../../../../src/services/amap/amap.client';
|
||||
|
||||
describe('AmapClient', () => {
|
||||
const originalKey = process.env.AMAP_WEB_SERVICE_KEY;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal('fetch', vi.fn());
|
||||
delete process.env.AMAP_WEB_SERVICE_KEY;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
if (originalKey === undefined) delete process.env.AMAP_WEB_SERVICE_KEY;
|
||||
else process.env.AMAP_WEB_SERVICE_KEY = originalKey;
|
||||
});
|
||||
|
||||
it('returns a configuration error without AMAP_WEB_SERVICE_KEY', async () => {
|
||||
const client = new AmapClient();
|
||||
|
||||
await expect(client.get('/v3/place/text', new URLSearchParams())).rejects.toMatchObject({
|
||||
code: 'AMAP_NOT_CONFIGURED',
|
||||
status: 503,
|
||||
});
|
||||
expect(fetch).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('uses the fixed AMap host and appends the server-only key', async () => {
|
||||
process.env.AMAP_WEB_SERVICE_KEY = 'secret-web-key';
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: '1', pois: [] }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
await new AmapClient().get('/v3/place/text', new URLSearchParams({ keywords: 'beijing' }));
|
||||
|
||||
const url = new URL(String(vi.mocked(fetch).mock.calls[0]?.[0]));
|
||||
expect(url.origin).toBe('https://restapi.amap.com');
|
||||
expect(url.pathname).toBe('/v3/place/text');
|
||||
expect(url.searchParams.get('keywords')).toBe('beijing');
|
||||
expect(url.searchParams.get('key')).toBe('secret-web-key');
|
||||
});
|
||||
|
||||
it('redacts the key from provider failures', async () => {
|
||||
process.env.AMAP_WEB_SERVICE_KEY = 'secret-web-key';
|
||||
vi.mocked(fetch).mockResolvedValue(
|
||||
new Response(JSON.stringify({ status: '0', info: 'INVALID_USER_KEY', infocode: '10001' }), {
|
||||
status: 200,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
}),
|
||||
);
|
||||
|
||||
try {
|
||||
await new AmapClient().get('/v3/place/text', new URLSearchParams());
|
||||
throw new Error('expected request to fail');
|
||||
} catch (err) {
|
||||
expect(err).toMatchObject({ code: 'AMAP_PROVIDER_ERROR', status: 502 });
|
||||
expect(String((err as Error).message)).not.toContain('secret-web-key');
|
||||
}
|
||||
});
|
||||
});
|
||||
50
server/tests/unit/services/amap/amap.places.test.ts
Normal file
50
server/tests/unit/services/amap/amap.places.test.ts
Normal file
@ -0,0 +1,50 @@
|
||||
import { describe, expect, it, vi } from 'vitest';
|
||||
|
||||
import { autocompleteAmapPlaces, searchAmapPlaces } from '../../../../src/services/amap/amap.places';
|
||||
|
||||
describe('AMap places adapter', () => {
|
||||
it('converts AMap GCJ POI coordinates back to WGS-84', async () => {
|
||||
const client = {
|
||||
get: vi.fn().mockResolvedValue({
|
||||
status: '1',
|
||||
pois: [
|
||||
{
|
||||
id: 'B000A83M61',
|
||||
name: 'Tiananmen',
|
||||
address: 'Beijing',
|
||||
location: '116.403714,39.910627',
|
||||
type: 'scenic',
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
await expect(searchAmapPlaces(client, '天安门')).resolves.toMatchObject({
|
||||
source: 'amap',
|
||||
places: [
|
||||
{
|
||||
google_place_id: null,
|
||||
google_ftid: null,
|
||||
amap_place_id: 'B000A83M61',
|
||||
name: 'Tiananmen',
|
||||
lat: expect.closeTo(39.908823, 3),
|
||||
lng: expect.closeTo(116.39747, 3),
|
||||
source: 'amap',
|
||||
},
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('converts a WGS location bias to AMap longitude-latitude order for input tips', async () => {
|
||||
const client = { get: vi.fn().mockResolvedValue({ status: '1', tips: [] }) };
|
||||
|
||||
await autocompleteAmapPlaces(client, '天安门', 'zh', {
|
||||
low: { lat: 39.9, lng: 116.3 },
|
||||
high: { lat: 40.0, lng: 116.5 },
|
||||
});
|
||||
|
||||
const params = client.get.mock.calls[0]?.[1] as URLSearchParams;
|
||||
expect(client.get).toHaveBeenCalledWith('/v3/assistant/inputtips', expect.any(URLSearchParams));
|
||||
expect(params.get('location')).toMatch(/^116\./);
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user