feat: proxy amap place services

This commit is contained in:
jubnl 2026-07-11 15:48:47 +08:00
parent 4c6215b233
commit faedf05ae5
7 changed files with 489 additions and 53 deletions

View File

@ -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:"],

View File

@ -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);
}
}

View 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();
}
}

View 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)));
}

View File

@ -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);
});
});

View 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');
}
});
});

View 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\./);
});
});