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:"],
|
imgSrc: ["'self'", "data:", "blob:", "https:"],
|
||||||
connectSrc: [
|
connectSrc: [
|
||||||
"'self'", "ws:", "wss:",
|
"'self'", "ws:", "wss:",
|
||||||
"https://nominatim.openstreetmap.org", "https://overpass-api.de",
|
"https://restapi.amap.com", "https://webapi.amap.com", "https://*.amap.com",
|
||||||
"https://places.googleapis.com", "https://api.openweathermap.org",
|
"https://api.openweathermap.org",
|
||||||
"https://en.wikipedia.org", "https://commons.wikimedia.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://unpkg.com", "https://open-meteo.com", "https://api.open-meteo.com",
|
||||||
"https://geocoding-api.open-meteo.com", "https://api.frankfurter.dev",
|
"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:"],
|
workerSrc: ["'self'", "blob:"],
|
||||||
childSrc: ["'self'", "blob:"],
|
childSrc: ["'self'", "blob:"],
|
||||||
|
|||||||
@ -9,24 +9,23 @@ import type {
|
|||||||
} from '@trek/shared';
|
} from '@trek/shared';
|
||||||
import { DatabaseService } from '../database/database.service';
|
import { DatabaseService } from '../database/database.service';
|
||||||
import {
|
import {
|
||||||
searchPlaces,
|
autocompleteAmapPlaces,
|
||||||
autocompletePlaces,
|
getAmapPlaceDetails,
|
||||||
getPlaceDetails,
|
getAmapPlaceDetailsExpanded,
|
||||||
getPlaceDetailsExpanded,
|
resolveAmapUri,
|
||||||
getPlacePhoto,
|
reverseAmapGeocode,
|
||||||
reverseGeocode,
|
searchAmapPlaces,
|
||||||
resolveGoogleMapsUrl,
|
searchAmapPois,
|
||||||
searchOverpassPois,
|
} from '../../services/amap/amap.places';
|
||||||
} from '../../services/mapsService';
|
import { AmapClient } from '../../services/amap/amap.client';
|
||||||
import { serveFilePath } from '../../services/placePhotoCache';
|
import { serveFilePath } from '../../services/placePhotoCache';
|
||||||
|
|
||||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin Nest wrapper around the existing maps service. All geocoding, the
|
* Thin Nest wrapper around the AMap-only adapter. Historical fields remain in
|
||||||
* provider fan-out (Nominatim/Overpass/Google) and — importantly — the SSRF
|
* response envelopes where clients still expect them, but runtime map calls go
|
||||||
* guard live in mapsService and are reused unchanged, so behaviour and the
|
* through the server-side AMap Web Service key only.
|
||||||
* outbound-URL protection are identical.
|
|
||||||
*
|
*
|
||||||
* The per-endpoint kill-switches are settings reads the legacy route does
|
* The per-endpoint kill-switches are settings reads the legacy route does
|
||||||
* inline; they're encapsulated here as `*Disabled()` helpers over the same
|
* 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()
|
@Injectable()
|
||||||
export class MapsService {
|
export class MapsService {
|
||||||
|
private readonly amap = new AmapClient();
|
||||||
|
|
||||||
constructor(private readonly database: DatabaseService) {}
|
constructor(private readonly database: DatabaseService) {}
|
||||||
|
|
||||||
private isSettingDisabled(key: string): boolean {
|
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> {
|
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> {
|
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> {
|
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> {
|
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> {
|
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 {
|
photoBytesPath(placeId: string): string | null {
|
||||||
@ -81,15 +91,14 @@ export class MapsService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
reverse(lat: string, lng: string, lang?: string): Promise<MapsReverseResult> {
|
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> {
|
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 }) {
|
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';
|
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||||
|
|
||||||
const { maps } = vi.hoisted(() => ({
|
const { amapPlaces } = vi.hoisted(() => ({
|
||||||
maps: {
|
amapPlaces: {
|
||||||
searchPlaces: vi.fn(),
|
searchAmapPlaces: vi.fn(),
|
||||||
autocompletePlaces: vi.fn(),
|
autocompleteAmapPlaces: vi.fn(),
|
||||||
getPlaceDetails: vi.fn(),
|
getAmapPlaceDetails: vi.fn(),
|
||||||
getPlaceDetailsExpanded: vi.fn(),
|
getAmapPlaceDetailsExpanded: vi.fn(),
|
||||||
getPlacePhoto: vi.fn(),
|
reverseAmapGeocode: vi.fn(),
|
||||||
reverseGeocode: vi.fn(),
|
searchAmapPois: vi.fn(),
|
||||||
resolveGoogleMapsUrl: vi.fn(),
|
resolveAmapUri: vi.fn(),
|
||||||
searchOverpassPois: vi.fn(),
|
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
vi.mock('../../../src/services/mapsService', () => maps);
|
vi.mock('../../../src/services/amap/amap.places', () => amapPlaces);
|
||||||
|
|
||||||
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
const { serveFilePath } = vi.hoisted(() => ({ serveFilePath: vi.fn() }));
|
||||||
vi.mock('../../../src/services/placePhotoCache', () => ({ serveFilePath }));
|
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', () => {
|
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 };
|
const bias = { lat: 1, lng: 2, radius: 5 };
|
||||||
svc().search(3, 'berlin', 'de', bias);
|
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', () => {
|
it('search works without optional args', () => {
|
||||||
svc().search(3, 'berlin');
|
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', () => {
|
it('autocomplete forwards through', () => {
|
||||||
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
const bias = { low: { lat: 1, lng: 2 }, high: { lat: 3, lng: 4 } };
|
||||||
svc().autocomplete(3, 'be', 'en', bias);
|
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', () => {
|
it('details forwards through', () => {
|
||||||
svc().details(3, 'p1', 'de');
|
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', () => {
|
it('detailsExpanded forwards refresh through', () => {
|
||||||
svc().detailsExpanded(3, 'p1', 'de', true);
|
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', () => {
|
it('photo returns an empty AMap-compatible result without calling legacy providers', async () => {
|
||||||
svc().photo(3, 'p1', 1.5, 2.5, 'Spot');
|
await expect(svc().photo(3, 'p1', 1.5, 2.5, 'Spot')).resolves.toEqual({ photoUrl: null });
|
||||||
expect(maps.getPlacePhoto).toHaveBeenCalledWith(3, 'p1', 1.5, 2.5, 'Spot');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reverse forwards through', () => {
|
it('reverse forwards through', () => {
|
||||||
svc().reverse('1', '2', 'de');
|
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', () => {
|
it('resolveUrl forwards through', () => {
|
||||||
svc().resolveUrl('https://maps.app.goo.gl/x');
|
svc().resolveUrl('https://uri.amap.com/marker?position=116.39747,39.908823');
|
||||||
expect(maps.resolveGoogleMapsUrl).toHaveBeenCalledWith('https://maps.app.goo.gl/x');
|
expect(amapPlaces.resolveAmapUri).toHaveBeenCalledWith('https://uri.amap.com/marker?position=116.39747,39.908823');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pois forwards category and bbox through', () => {
|
it('pois forwards category and bbox through', () => {
|
||||||
const bbox = { south: 1, west: 2, north: 3, east: 4 };
|
const bbox = { south: 1, west: 2, north: 3, east: 4 };
|
||||||
svc().pois('cafe', bbox);
|
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