mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-18 21:16:47 +00:00
feat(maps): let Amap take the keyed provider slot beside the TREK index
The rebase kept maps.service.ts as dev has it: the TREK index and OpenStreetMap answer every search first, and a keyed provider only fills what they leave empty. Amap now sits in that slot next to Google instead of behind an extracted Google provider, which is dropped. places_provider (auto | google | amap | openstreetmap) decides which keyed provider holds the slot; auto keeps whatever the install already used. An amap: id opens against the Amap key regardless of the setting, takes the plain details lookup on expand, never reaches Google's photo call, and a pasted Amap link resolves through parseAmapUrl. Reverse geocoding asks Amap at the instance key before Nominatim. The migration carries users.amap_api_key, which the rebase had left at a stale array position, and places.amap_poi_id for the next commit.
This commit is contained in:
@@ -4588,6 +4588,33 @@ function runMigrations(db: Database.Database): void {
|
||||
const cols = db.prepare("SELECT name FROM pragma_table_info('budget_settlements')").all() as Array<{ name: string }>;
|
||||
if (!cols.some(c => c.name === 'settled_at')) {
|
||||
db.exec('ALTER TABLE budget_settlements ADD COLUMN settled_at TEXT');
|
||||
/**
|
||||
* Amap (高德) as a keyed places provider.
|
||||
*
|
||||
* users.amap_api_key has the same shape and role as maps_api_key: encrypted
|
||||
* with apiKeyCrypto, and the last step of the resolver after the operator
|
||||
* env var and the instance-wide app_settings row. Nullable with no default,
|
||||
* because "this install does not use Amap" is the correct state for almost
|
||||
* everybody. Nothing is backfilled: a Google key is not an Amap key, and
|
||||
* the two are chosen by the places_provider setting, not by which column
|
||||
* happens to be populated.
|
||||
*
|
||||
* places.amap_poi_id is the provider id a place was found by, beside
|
||||
* google_place_id and osm_id. Amap ids are bare strings shaped like Google
|
||||
* ones, so the column holds them with the `amap:` prefix the maps domain
|
||||
* uses everywhere, and a place keeps opening against Amap after the admin
|
||||
* switches provider.
|
||||
*
|
||||
* Appended LAST: the array is index-addressed against schema_version.
|
||||
*/
|
||||
() => {
|
||||
const userCols = db.prepare("SELECT name FROM pragma_table_info('users')").all() as Array<{ name: string }>;
|
||||
if (!userCols.some(c => c.name === 'amap_api_key')) {
|
||||
db.exec('ALTER TABLE users ADD COLUMN amap_api_key TEXT');
|
||||
}
|
||||
const placeCols = db.prepare("SELECT name FROM pragma_table_info('places')").all() as Array<{ name: string }>;
|
||||
if (!placeCols.some(c => c.name === 'amap_poi_id')) {
|
||||
db.exec('ALTER TABLE places ADD COLUMN amap_poi_id TEXT');
|
||||
}
|
||||
},
|
||||
];
|
||||
|
||||
@@ -70,9 +70,9 @@ export class MapsMcp {
|
||||
|
||||
@Tool({
|
||||
name: 'resolve_maps_url',
|
||||
description: 'Resolve a Google Maps share URL to coordinates and place name.',
|
||||
description: 'Resolve a Google Maps or Amap (高德地图) share URL to coordinates and place name.',
|
||||
inputSchema: {
|
||||
url: z.string().describe('Google Maps share URL'),
|
||||
url: z.string().describe('Google Maps or Amap share URL'),
|
||||
},
|
||||
annotations: TOOL_ANNOTATIONS_READONLY,
|
||||
access: { group: 'geo', mode: 'read' },
|
||||
|
||||
@@ -12,6 +12,14 @@ import { readEnv, getAppUrl } from '../../app-config';
|
||||
import { safeFetchFollow, SsrfBlockedError } from '../../utils/ssrfGuard';
|
||||
import { discardBody, exceedsDeclaredLength, readCappedText } from '../../utils/cappedFetch';
|
||||
import { resolveApiKey, type ApiKeySource } from '../settings/instance-api-keys';
|
||||
import { isPlacesProviderChoice, type PlacesProviderChoice } from './providers/places-provider';
|
||||
import {
|
||||
AMAP_SHORT_HOSTS,
|
||||
AmapPlacesProvider,
|
||||
isAmapHost,
|
||||
isAmapPlaceId,
|
||||
parseAmapUrl,
|
||||
} from './providers/amap.provider';
|
||||
// ── Photo cache (disk-backed) ────────────────────────────────────────────────
|
||||
import { PlacePhotoCacheService } from '../place-photos/place-photo-cache.service';
|
||||
import { DatabaseService } from '../database/database.service';
|
||||
@@ -633,6 +641,24 @@ async function overpassFetch(query: string): Promise<OverpassPoiElement[]> {
|
||||
|
||||
type LocationBias = { low: { lat: number; lng: number }; high: { lat: number; lng: number } };
|
||||
|
||||
/**
|
||||
* The app_settings row that names the keyed places provider.
|
||||
*
|
||||
* A setting rather than "whichever key is configured": an install can hold both
|
||||
* credentials (a team split between China and elsewhere), and then only an
|
||||
* admin can say which one should answer. Absent, which is every install that
|
||||
* predates Amap, means `auto`, which keeps Google.
|
||||
*/
|
||||
export const PLACES_PROVIDER_SETTING = 'places_provider';
|
||||
|
||||
/**
|
||||
* Whoever holds the keyed slot beside the index for one request: Google's
|
||||
* credential, an Amap provider, or nobody (the OpenStreetMap stack alone).
|
||||
*/
|
||||
type KeyedProvider =
|
||||
| { id: 'google'; key: string; source: ApiKeySource | null }
|
||||
| { id: 'amap'; provider: AmapPlacesProvider };
|
||||
|
||||
/**
|
||||
* /api/maps domain service — geocoding, the provider fan-out
|
||||
* (Nominatim/Overpass/Google), the place-details/photo caches and the SSRF
|
||||
@@ -946,6 +972,88 @@ export class MapsService {
|
||||
return this.resolveMapsKey(userId).key;
|
||||
}
|
||||
|
||||
/** The Amap credential, resolved through the identical three-step chain. */
|
||||
resolveAmapKey(userId: number): { key: string | null; source: ApiKeySource | null } {
|
||||
return resolveApiKey(this.database, 'amap_api_key', userId, readEnv().maps.amapApiKey);
|
||||
}
|
||||
|
||||
// ── Keyed provider selection ───────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Which keyed provider the admin picked, or `auto`.
|
||||
*
|
||||
* An unrecognised stored value degrades to `auto` rather than throwing: this
|
||||
* is read on the hot path of every search, and a hand-edited settings row must
|
||||
* not take place search down.
|
||||
*/
|
||||
placesProviderChoice(): PlacesProviderChoice {
|
||||
const row = this.database.get<{ value: string }>(
|
||||
'SELECT value FROM app_settings WHERE key = ?',
|
||||
PLACES_PROVIDER_SETTING,
|
||||
);
|
||||
return isPlacesProviderChoice(row?.value) ? row.value : 'auto';
|
||||
}
|
||||
|
||||
/**
|
||||
* Who holds the keyed slot for this request, or null for the OpenStreetMap
|
||||
* stack alone. The index and OpenStreetMap are asked either way; this only
|
||||
* decides what answers once they have nothing.
|
||||
*
|
||||
* `auto`, the default and what every install that predates Amap has, prefers
|
||||
* Google. That is deliberately the incumbent rather than "the newest provider
|
||||
* wins": an existing install must not silently start querying somewhere
|
||||
* else, with a different bill and different results, because a release added
|
||||
* a provider. An admin who wants Amap says so.
|
||||
*
|
||||
* Key resolution is ordered to match: under `auto` the Amap chain is only
|
||||
* walked when there is no Google key, so an install on Google issues exactly
|
||||
* the database reads it always did.
|
||||
*/
|
||||
keyedProvider(userId: number): KeyedProvider | null {
|
||||
const choice = this.placesProviderChoice();
|
||||
if (choice === 'openstreetmap') return null;
|
||||
|
||||
if (choice !== 'amap') {
|
||||
const google = this.resolveMapsKey(userId);
|
||||
if (google.key) return { id: 'google', key: google.key, source: google.source };
|
||||
// An explicit 'google' choice with no key is not a reason to query Amap
|
||||
// instead: this install is on Google and is misconfigured. OSM answers,
|
||||
// the way a keyless install has always been answered.
|
||||
if (choice === 'google') return null;
|
||||
}
|
||||
|
||||
const amap = this.resolveAmapKey(userId);
|
||||
return amap.key
|
||||
? { id: 'amap', provider: new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) }
|
||||
: null;
|
||||
}
|
||||
|
||||
/** The Amap provider, when Amap holds the keyed slot; null otherwise. */
|
||||
resolvePlacesProvider(userId: number): AmapPlacesProvider | null {
|
||||
const keyed = this.keyedProvider(userId);
|
||||
return keyed?.id === 'amap' ? keyed.provider : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The Amap provider for an `amap:` id, regardless of which provider is
|
||||
* currently selected.
|
||||
*
|
||||
* Places outlive the setting. An install that ran on Amap for a year and then
|
||||
* switches to Google still holds its `amap:` places, and every one of those
|
||||
* keeps opening against the Amap key that is still configured. Google ids do
|
||||
* not come through here at all: they take the inline Google path, which
|
||||
* resolves its own key the same way.
|
||||
*
|
||||
* Null means nobody can resolve it: a Google id, or an Amap place on an
|
||||
* install that has since dropped its Amap key. Callers treat that as a miss,
|
||||
* not an error.
|
||||
*/
|
||||
private providerForPlaceId(userId: number, placeId: string): AmapPlacesProvider | null {
|
||||
if (!isAmapPlaceId(placeId)) return null;
|
||||
const amap = this.resolveAmapKey(userId);
|
||||
return amap.key ? new AmapPlacesProvider({ key: amap.key, source: amap.source, userId }) : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A coordinate for a name that came out of an import, from whichever source
|
||||
* has it.
|
||||
@@ -1874,7 +1982,8 @@ export class MapsService {
|
||||
locationBias?: { lat: number; lng: number; radius?: number },
|
||||
opts: { googleIdentityOnly?: boolean } = {},
|
||||
): Promise<{ places: Record<string, unknown>[]; source: string }> {
|
||||
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
|
||||
const keyed = this.keyedProvider(userId);
|
||||
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
|
||||
|
||||
// The TREK index answers first, whether or not a Google key exists. It is
|
||||
// the only source here that may be stored, works offline as a country
|
||||
@@ -1943,6 +2052,13 @@ export class MapsService {
|
||||
}
|
||||
}
|
||||
|
||||
// Amap in the slot Google otherwise holds: asked only once the index and
|
||||
// OpenStreetMap came back empty, exactly like the Google call below.
|
||||
if (keyed?.id === 'amap') {
|
||||
const places = await keyed.provider.searchText(query, lang, locationBias);
|
||||
return { places, source: 'amap' };
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
// Reuse what OpenStreetMap already said rather than asking again. The
|
||||
// first call carried a viewbox with bounded=0, which orders results
|
||||
@@ -2022,7 +2138,8 @@ export class MapsService {
|
||||
locationBias?: { low: { lat: number; lng: number }; high: { lat: number; lng: number } },
|
||||
sessionToken?: string,
|
||||
): Promise<MapsAutocompleteResult> {
|
||||
const { key: apiKey, source: keySource } = this.resolveMapsKey(userId);
|
||||
const keyed = this.keyedProvider(userId);
|
||||
const { key: apiKey, source: keySource } = keyed?.id === 'google' ? keyed : { key: null, source: null };
|
||||
|
||||
// This is the path that mattered most. Nominatim's usage policy names
|
||||
// autocomplete as unacceptable use in its own words, regardless of rate,
|
||||
@@ -2100,6 +2217,11 @@ export class MapsService {
|
||||
}
|
||||
}
|
||||
|
||||
if (keyed?.id === 'amap') {
|
||||
const suggestions = await keyed.provider.autocomplete(input, lang, locationBias);
|
||||
return { suggestions, source: 'amap' };
|
||||
}
|
||||
|
||||
if (!apiKey) {
|
||||
return this.autocompleteNominatim(input, lang);
|
||||
}
|
||||
@@ -2252,6 +2374,11 @@ export class MapsService {
|
||||
};
|
||||
}
|
||||
|
||||
// An Amap id is `amap:<poiid>` and so carries a colon too. Before the OSM
|
||||
// branch, which would otherwise send "amap" to Overpass as an element type
|
||||
// and answer every Chinese place with an empty record.
|
||||
if (isAmapPlaceId(placeId)) return this.amapDetails(userId, placeId, lang);
|
||||
|
||||
// OSM details: placeId is "node:123456" or "way:123456" etc.
|
||||
if (placeId.includes(':')) {
|
||||
const [osmType, osmId] = placeId.split(':');
|
||||
@@ -2374,6 +2501,50 @@ export class MapsService {
|
||||
return { place };
|
||||
}
|
||||
|
||||
/**
|
||||
* The Amap half of getPlaceDetails, behind the same cache the Google half
|
||||
* uses. Keyed by place_id, and an Amap id carries its `amap:` prefix, so the
|
||||
* two providers' rows cannot collide.
|
||||
*
|
||||
* No key for the id is an empty result, not a client error, for the same
|
||||
* reason the Google half answers its keyless case that way: an Amap place
|
||||
* opened on an install that has since dropped its Amap key is a miss.
|
||||
*/
|
||||
private async amapDetails(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
lang?: string,
|
||||
): Promise<{ place: Record<string, unknown> | null }> {
|
||||
const provider = this.providerForPlaceId(userId, placeId);
|
||||
if (!provider) return { place: null };
|
||||
|
||||
const langKey = toApiLang(lang);
|
||||
const DETAILS_TTL = 7 * 24 * 60 * 60 * 1000;
|
||||
const cached = this.database.get<{ payload_json: string; fetched_at: number }>(
|
||||
'SELECT payload_json, fetched_at FROM place_details_cache WHERE place_id = ? AND lang = ? AND expanded = 0',
|
||||
placeId,
|
||||
langKey,
|
||||
);
|
||||
if (cached && Date.now() - cached.fetched_at < DETAILS_TTL) return { place: JSON.parse(cached.payload_json) };
|
||||
|
||||
const place = await provider.placeDetails(placeId, lang);
|
||||
if (!place) return { place: null };
|
||||
|
||||
try {
|
||||
this.database.run(
|
||||
'INSERT OR REPLACE INTO place_details_cache (place_id, lang, expanded, payload_json, fetched_at) VALUES (?, ?, 0, ?, ?)',
|
||||
placeId,
|
||||
langKey,
|
||||
JSON.stringify(place),
|
||||
Date.now(),
|
||||
);
|
||||
} catch (dbErr) {
|
||||
console.error('Failed to cache place details:', dbErr);
|
||||
}
|
||||
|
||||
return { place };
|
||||
}
|
||||
|
||||
async getPlaceDetailsExpanded(
|
||||
userId: number,
|
||||
placeId: string,
|
||||
@@ -2390,9 +2561,10 @@ export class MapsService {
|
||||
// whole record for them — name, address, contact, hours — and only the reviews
|
||||
// and the editorial summary are Google's to add. Answering `expand=1` with a
|
||||
// null while `expand=0` answers in full would make the richer request the
|
||||
// poorer one.
|
||||
// poorer one. An Amap id has no richer tier either, so it takes the plain
|
||||
// lookup as well.
|
||||
if (!isGooglePlaceId(placeId)) {
|
||||
return OSM_PLACE_ID.test(placeId) || placeId.startsWith('gers:')
|
||||
return OSM_PLACE_ID.test(placeId) || placeId.startsWith('gers:') || isAmapPlaceId(placeId)
|
||||
? this.getPlaceDetails(userId, placeId, lang)
|
||||
: { place: null };
|
||||
}
|
||||
@@ -2647,6 +2819,26 @@ export class MapsService {
|
||||
lang?: string,
|
||||
opts?: { lane?: GeoLane; timeoutMs?: number; locality?: boolean },
|
||||
): Promise<{ name: string | null; address: string | null }> {
|
||||
// Amap answers first when it holds the keyed slot. Resolved at userId 0,
|
||||
// because most callers here have no person behind them (a booking import,
|
||||
// an Atlas tile, a right-click on a shared map): the chain stops at the
|
||||
// operator env var and the instance-wide row, and nobody's personal key is
|
||||
// read on somebody else's behalf (#1939). Nominatim stays the fallback, so
|
||||
// an Amap outage does not take a right-click down with it.
|
||||
const amap = this.resolvePlacesProvider(0);
|
||||
if (amap) {
|
||||
const latNum = Number.parseFloat(lat);
|
||||
const lngNum = Number.parseFloat(lng);
|
||||
if (Number.isFinite(latNum) && Number.isFinite(lngNum)) {
|
||||
try {
|
||||
const answer = await amap.reverse(latNum, lngNum, lang);
|
||||
if (answer) return answer;
|
||||
} catch (err) {
|
||||
console.error('[Maps] amap reverse geocode failed, falling back to Nominatim:', (err as Error).message);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const params = new URLSearchParams({
|
||||
lat,
|
||||
lon: lng,
|
||||
@@ -2705,20 +2897,37 @@ export class MapsService {
|
||||
// usually carries the !3d!4d data param we can then parse. Redirects are
|
||||
// followed manually so every hop is SSRF-re-checked.
|
||||
const parsed = new URL(url);
|
||||
const isShort = GOOGLE_SHORT_HOSTS.includes(parsed.hostname);
|
||||
const isShort = GOOGLE_SHORT_HOSTS.includes(parsed.hostname) || AMAP_SHORT_HOSTS.includes(parsed.hostname);
|
||||
const isGoogleMaps = isGoogleMapsHost(parsed.hostname);
|
||||
if (isShort || (isGoogleMaps && !extractCoords(url))) {
|
||||
resolvedUrl = (await followRedirects(url)).url || resolvedUrl;
|
||||
}
|
||||
|
||||
let resolvedHost = '';
|
||||
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, both host branches are skipped */ }
|
||||
|
||||
// Amap links first, and on their own: they spell the coordinate `lng,lat`
|
||||
// in GCJ-02, which the Google patterns below would read as a WGS-84
|
||||
// `lat,lng` and put a Shanghai restaurant in the East China Sea.
|
||||
// parseAmapUrl owns both the ordering and the datum conversion.
|
||||
if (isAmapHost(resolvedHost)) {
|
||||
const amap = parseAmapUrl(resolvedUrl);
|
||||
// A POI page without a coordinate would need a keyed detail lookup, and
|
||||
// this method has no user to resolve a key for: the same answer a Google
|
||||
// page without coordinates gets.
|
||||
if (!amap || !Number.isFinite(amap.lat) || !Number.isFinite(amap.lng)) {
|
||||
throw Object.assign(new Error('Could not extract coordinates from URL'), { status: 400 });
|
||||
}
|
||||
const reverse = await this.reverseGeocode(String(amap.lat), String(amap.lng), undefined, { timeoutMs: 8000 });
|
||||
return { lat: amap.lat, lng: amap.lng, name: amap.name || reverse.name, address: reverse.address, google_ftid: null };
|
||||
}
|
||||
|
||||
let coords = extractCoords(resolvedUrl);
|
||||
|
||||
// Still nothing (e.g. a cid page whose final URL lacks coordinates): fetch the
|
||||
// page body once and parse the coordinates out of the embedded map data.
|
||||
// Only Google's own pages get read; the resolved host is what counts, so a
|
||||
// short link that lands on maps.google.com still qualifies.
|
||||
let resolvedHost = '';
|
||||
try { resolvedHost = new URL(resolvedUrl).hostname; } catch { /* keep the empty host, the branch is skipped */ }
|
||||
if (!coords && isGoogleMapsHost(resolvedHost)) {
|
||||
try {
|
||||
const pageRes = await followRedirects(resolvedUrl, {
|
||||
|
||||
@@ -34,7 +34,6 @@ import { readEnv } from '../../../app-config';
|
||||
import { safeFetchFollow } from '../../../utils/ssrfGuard';
|
||||
import { UA, parseOpeningHours } from '../maps.helpers';
|
||||
import type {
|
||||
DetailsOptions,
|
||||
PlacesProvider,
|
||||
ProviderCredential,
|
||||
ProviderPlace,
|
||||
@@ -163,10 +162,6 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
|
||||
constructor(private readonly credential: ProviderCredential) {}
|
||||
|
||||
ownsPlaceId(placeId: string): boolean {
|
||||
return isAmapPlaceId(placeId);
|
||||
}
|
||||
|
||||
// ── Outbound plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
@@ -188,10 +183,10 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
* Amap's optional 数字签名 (digital signature).
|
||||
*
|
||||
* A key can be created with a private secret, and such a key rejects every
|
||||
* unsigned request. The scheme is an MD5 of the request parameters sorted by
|
||||
* name, with the secret appended — MD5 because Amap specifies MD5, not because
|
||||
* anything here is choosing a hash. The key itself is deliberately NOT part of
|
||||
* the signed set on the query-string form of the API.
|
||||
* unsigned request. The scheme is an MD5 over every query parameter sorted by
|
||||
* name, the key and the output format included, with the secret appended.
|
||||
* MD5 because Amap specifies MD5, not because anything here is choosing a
|
||||
* hash.
|
||||
*
|
||||
* Unset, which is the common case, nothing is added.
|
||||
*/
|
||||
@@ -257,7 +252,7 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
* tell two identically named branches apart in the place picker. Province,
|
||||
* city and district come as separate fields for exactly this purpose.
|
||||
*/
|
||||
private toPlace(poi: AmapPoi): ProviderPlace | null {
|
||||
private toPlace(poi: AmapPoi): ProviderPlace {
|
||||
const coords = fromAmapLocation(poi.location);
|
||||
const business = poi.business ?? {};
|
||||
const bizExt = poi.biz_ext ?? {};
|
||||
@@ -325,19 +320,12 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
)
|
||||
: await this.call<AmapEnvelope & { pois?: AmapPoi[] }>('/v3/place/text', common, 'place/text');
|
||||
|
||||
return (data.pois ?? [])
|
||||
.map((poi) => this.toPlace(poi))
|
||||
.filter((p): p is ProviderPlace => p !== null);
|
||||
return (data.pois ?? []).map((poi) => this.toPlace(poi));
|
||||
}
|
||||
|
||||
async autocomplete(
|
||||
input: string,
|
||||
lang?: string,
|
||||
bias?: ViewportBias,
|
||||
// Amap does not bill per keystroke and has no session concept, so the token
|
||||
// the Google path uses to group a search has nothing to attach to here.
|
||||
_sessionToken?: string,
|
||||
): Promise<ProviderSuggestion[]> {
|
||||
// Amap does not bill per keystroke and has no session concept, so the token
|
||||
// the Google path uses to group a search has nothing to attach to here.
|
||||
async autocomplete(input: string, lang?: string, bias?: ViewportBias): Promise<ProviderSuggestion[]> {
|
||||
const params: Record<string, string> = {
|
||||
keywords: input,
|
||||
// Without this, inputtips also returns bus stops and road names, which
|
||||
@@ -371,7 +359,7 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
}));
|
||||
}
|
||||
|
||||
async placeDetails(placeId: string, opts: DetailsOptions = {}): Promise<ProviderPlace | null> {
|
||||
async placeDetails(placeId: string, lang?: string): Promise<ProviderPlace | null> {
|
||||
const poiId = amapPoiId(placeId);
|
||||
if (!poiId) return null;
|
||||
|
||||
@@ -384,15 +372,13 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
{
|
||||
id: poiId,
|
||||
show_fields: 'business,indoor',
|
||||
language: toAmapLang(opts.lang),
|
||||
language: toAmapLang(lang),
|
||||
},
|
||||
`place/detail(${poiId})`,
|
||||
);
|
||||
|
||||
const poi = data.pois?.[0];
|
||||
if (!poi) return null;
|
||||
const place = this.toPlace(poi);
|
||||
return place ? { ...place, cached_at: Date.now() } : null;
|
||||
return poi ? { ...this.toPlace(poi), cached_at: Date.now() } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -419,7 +405,8 @@ export class AmapPlacesProvider implements PlacesProvider {
|
||||
{
|
||||
location: toAmapLocation(lat, lng),
|
||||
extensions: 'all',
|
||||
// 18 is street-address detail, matching the zoom the Nominatim path asks for.
|
||||
// Metres around the point that count as "here", roughly the block the
|
||||
// Nominatim path's zoom 18 resolves to.
|
||||
radius: '200',
|
||||
language: toAmapLang(lang),
|
||||
},
|
||||
|
||||
@@ -1,34 +1,29 @@
|
||||
/**
|
||||
* The places-provider seam.
|
||||
* The keyed places-provider seam.
|
||||
*
|
||||
* Before this file, MapsService reached Google inline and every method carried
|
||||
* its own `if (!apiKey) → OpenStreetMap` branch. That worked for two sources and
|
||||
* stops working at three: adding Amap that way means a three-way conditional in
|
||||
* `searchPlaces`, another in `autocompletePlaces`, two more in the details pair,
|
||||
* and a fourth spelling of "which credential did we end up using" in each.
|
||||
*
|
||||
* So the *keyed* providers — the ones an admin configures and that bill somebody
|
||||
* — are implemented behind this interface, and MapsService keeps what is
|
||||
* genuinely its own: the caches, the kill switches, the photo orchestration and
|
||||
* the Wikimedia enrichment.
|
||||
* Search, autocomplete and details ask the TREK index and OpenStreetMap first;
|
||||
* neither needs a credential, and together they answer on every install.
|
||||
* Behind them sits one keyed provider, the one an admin configures and that
|
||||
* bills somebody: Google, which MapsService still calls inline, or Amap,
|
||||
* which comes through this interface. The `places_provider` row in
|
||||
* app_settings says which of the two holds that slot, and `auto` keeps
|
||||
* whatever the install already used.
|
||||
*
|
||||
* OpenStreetMap deliberately does NOT implement this interface. It is the
|
||||
* keyless floor every install falls back to, it has no credential to resolve,
|
||||
* and its details path is a Nominatim/Overpass merge that answers a different
|
||||
* question from a single provider lookup. Dressing it up as a provider would
|
||||
* mean either a fake key or an interface full of optionals. `null` from
|
||||
* `resolvePlacesProvider` means "use the OSM stack", which is also exactly what
|
||||
* the code did before.
|
||||
* mean either a fake key or an interface full of optionals.
|
||||
*
|
||||
* Every method returns the same normalised place shape regardless of provider —
|
||||
* `name/address/lat/lng` in WGS-84, plus whatever provider-specific ids the
|
||||
* client already persists (`google_place_id`, `amap_poi_id`, …). Coordinates are
|
||||
* WGS-84 by contract: a provider that speaks another datum converts at its own
|
||||
* boundary (see amap.provider.ts) so nothing downstream has to care.
|
||||
* Every method returns the same normalised place shape regardless of provider:
|
||||
* `name/address/lat/lng` in WGS-84, plus the provider's own id under the column
|
||||
* the client persists it in (`amap_poi_id`). Coordinates are WGS-84 by
|
||||
* contract: a provider that speaks another datum converts at its own boundary
|
||||
* (see amap.provider.ts) so nothing downstream has to care.
|
||||
*/
|
||||
import type { ApiKeySource } from '../../settings/instance-api-keys';
|
||||
|
||||
/** The keyed providers. OpenStreetMap is absent on purpose — see the file header. */
|
||||
/** The keyed providers. OpenStreetMap is absent on purpose, see the file header. */
|
||||
export type PlacesProviderId = 'google' | 'amap';
|
||||
|
||||
/**
|
||||
@@ -50,7 +45,7 @@ export function isPlacesProviderChoice(value: unknown): value is PlacesProviderC
|
||||
return typeof value === 'string' && (PLACES_PROVIDER_CHOICES as readonly string[]).includes(value);
|
||||
}
|
||||
|
||||
/** A place as the rest of TREK consumes it. Open by design — see maps.schema.ts. */
|
||||
/** A place as the rest of TREK consumes it. Open by design, see maps.schema.ts. */
|
||||
export type ProviderPlace = Record<string, unknown>;
|
||||
|
||||
export interface ProviderSuggestion {
|
||||
@@ -72,30 +67,10 @@ export interface ViewportBias {
|
||||
high: { lat: number; lng: number };
|
||||
}
|
||||
|
||||
export interface DetailsOptions {
|
||||
lang?: string;
|
||||
/**
|
||||
* Ask for the fields only the richer (and pricier) lookup returns — reviews
|
||||
* and the editorial summary at Google. A provider without a two-tier lookup
|
||||
* ignores it and answers the same either way.
|
||||
*/
|
||||
expanded?: boolean;
|
||||
/** Closes a billed autocomplete session, where the provider has the concept. */
|
||||
sessionToken?: string;
|
||||
}
|
||||
|
||||
/** One photo the provider holds, as a reference to fetch bytes for later. */
|
||||
export interface ProviderPhotoRef {
|
||||
/** Provider-opaque handle passed straight back to `photoBytes`. */
|
||||
name: string;
|
||||
/** The author as the provider names them — not the provider itself. */
|
||||
attribution: string | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The credential and its provenance, carried so an error can say WHICH of the
|
||||
* three configured places a key can come from was used without ever logging the
|
||||
* key. This is the #1939 breadcrumb, kept when the code moved out of the service.
|
||||
* three places a key can come from was used, without ever logging the key.
|
||||
* This is the #1939 breadcrumb, kept when the code moved out of the service.
|
||||
*/
|
||||
export interface ProviderCredential {
|
||||
key: string;
|
||||
@@ -107,42 +82,13 @@ export interface ProviderCredential {
|
||||
export interface PlacesProvider {
|
||||
readonly id: PlacesProviderId;
|
||||
|
||||
/**
|
||||
* True when this provider is the one that can resolve `placeId`.
|
||||
*
|
||||
* Sending an id to the wrong provider is not a harmless miss: Google answers
|
||||
* a foreign id with a *billable* 400 INVALID_ARGUMENT, which is the reason
|
||||
* `isGooglePlaceId` exists at all. Every details/photo path checks this first.
|
||||
*/
|
||||
ownsPlaceId(placeId: string): boolean;
|
||||
|
||||
searchText(query: string, lang?: string, bias?: SearchBias): Promise<ProviderPlace[]>;
|
||||
|
||||
autocomplete(
|
||||
input: string,
|
||||
lang?: string,
|
||||
bias?: ViewportBias,
|
||||
sessionToken?: string,
|
||||
): Promise<ProviderSuggestion[]>;
|
||||
autocomplete(input: string, lang?: string, bias?: ViewportBias): Promise<ProviderSuggestion[]>;
|
||||
|
||||
/** Null when the provider has no such place — a miss, not an error. */
|
||||
placeDetails(placeId: string, opts?: DetailsOptions): Promise<ProviderPlace | null>;
|
||||
/** Null when the provider has no such place: a miss, not an error. */
|
||||
placeDetails(placeId: string, lang?: string): Promise<ProviderPlace | null>;
|
||||
|
||||
/**
|
||||
* Reverse geocoding, where the provider offers it. Absent means "use
|
||||
* Nominatim", which is what every install did before and still does for the
|
||||
* OSM stack. Null means the provider had nothing for this point.
|
||||
*/
|
||||
reverse?(lat: number, lng: number, lang?: string): Promise<{ name: string | null; address: string | null } | null>;
|
||||
|
||||
/**
|
||||
* Photo candidates for the enrichment picker. Optional because a provider
|
||||
* whose image licensing we cannot state is better off not offering any — the
|
||||
* Wikimedia fallback then supplies the picture, with its licence attached.
|
||||
*/
|
||||
photoRefs?(placeId: string, cap: number): Promise<ProviderPhotoRef[]>;
|
||||
photoBytes?(photoName: string, maxHeightPx?: number): Promise<Buffer | null>;
|
||||
|
||||
/** A one-paragraph description of the place, where the provider has one. */
|
||||
editorialSummary?(placeId: string, lang?: string): Promise<string | null>;
|
||||
/** Reverse geocoding. Null means the provider had nothing for this point. */
|
||||
reverse(lat: number, lng: number, lang?: string): Promise<{ name: string | null; address: string | null } | null>;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,15 @@ vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
// The index answers search and autocomplete before any keyed provider, and
|
||||
// trekPlacesEnabled fails open, so a service-level case that reached it with
|
||||
// the Amap fetch stub in place would leave the runner for places.liketrek.com.
|
||||
// Stubbed to "nothing found", which is the state that hands the query on.
|
||||
vi.mock('../../../src/nest/maps/trek-places.client', async (importOriginal) => ({
|
||||
...(await importOriginal<typeof import('../../../src/nest/maps/trek-places.client')>()),
|
||||
trekPlacesSearch: vi.fn(async (): Promise<unknown[]> => []),
|
||||
}));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
@@ -61,7 +70,6 @@ import {
|
||||
isAmapPlaceId,
|
||||
parseAmapUrl,
|
||||
} from '../../../src/nest/maps/providers/amap.provider';
|
||||
import { GooglePlacesProvider } from '../../../src/nest/maps/providers/google.provider';
|
||||
import { isGooglePlaceId } from '../../../src/nest/maps/maps.helpers';
|
||||
import type { PlacePhotoCacheService } from '../../../src/nest/place-photos/place-photo-cache.service';
|
||||
|
||||
@@ -454,28 +462,31 @@ describe('parseAmapUrl', () => {
|
||||
|
||||
// ── Provider selection ───────────────────────────────────────────────────────
|
||||
|
||||
describe('MapsService.resolvePlacesProvider', () => {
|
||||
/** Make the Google key chain answer, the Amap chain answer, or neither. */
|
||||
function keys(opts: { google?: string; amap?: string }) {
|
||||
mockDbGet.mockImplementation((...args: unknown[]) => {
|
||||
// resolveApiKey reads the caller's own row per name; the SQL differs but
|
||||
// the stub only sees the bound userId, so answer both columns at once.
|
||||
return { maps_api_key: opts.google ?? null, amap_api_key: opts.amap ?? null } as any;
|
||||
});
|
||||
}
|
||||
/** Make the Google key chain answer, the Amap chain answer, or neither. */
|
||||
function keys(opts: { google?: string; amap?: string }) {
|
||||
mockDbGet.mockImplementation((..._args: unknown[]) => {
|
||||
// resolveApiKey reads the caller's own row per name; the SQL differs but
|
||||
// the stub only sees the bound userId, so answer both columns at once.
|
||||
return { maps_api_key: opts.google ?? null, amap_api_key: opts.amap ?? null } as any;
|
||||
});
|
||||
}
|
||||
|
||||
describe('MapsService.keyedProvider', () => {
|
||||
it('AMAP-070: auto keeps Google when a Google key is configured', () => {
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(GooglePlacesProvider);
|
||||
expect(svc.keyedProvider(1)).toMatchObject({ id: 'google', key: 'gkey', source: 'user-row' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-071: auto falls to Amap only when there is no Google key', () => {
|
||||
keys({ amap: 'akey' });
|
||||
expect(svc.keyedProvider(1)?.id).toBe('amap');
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
|
||||
});
|
||||
|
||||
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', () => {
|
||||
keys({});
|
||||
expect(svc.keyedProvider(1)).toBeNull();
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
@@ -489,20 +500,20 @@ describe('MapsService.resolvePlacesProvider', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'google' });
|
||||
keys({ amap: 'akey' });
|
||||
// Misconfigured means "answer with OSM", not "bill somebody else's provider".
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
expect(svc.keyedProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-075: openstreetmap ignores both keys', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'openstreetmap' });
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
expect(svc.keyedProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-076: a hand-edited nonsense value degrades to auto instead of failing', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'not-a-provider' });
|
||||
keys({ google: 'gkey' });
|
||||
expect(svc.placesProviderChoice()).toBe('auto');
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(GooglePlacesProvider);
|
||||
expect(svc.keyedProvider(1)?.id).toBe('google');
|
||||
});
|
||||
|
||||
it('AMAP-077: an Amap place stays with Amap even while Google is selected', async () => {
|
||||
@@ -556,10 +567,23 @@ describe('MapsService.resolvePlacesProvider', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapsService.searchPlaces with Amap selected', () => {
|
||||
it('AMAP-080: reports amap as the source so the client can credit it', async () => {
|
||||
describe('MapsService with Amap in the keyed slot', () => {
|
||||
// The index and OpenStreetMap are asked first either way; these cases are
|
||||
// about what answers once they have nothing, so the index is switched off
|
||||
// the way maps.service.test.ts does it for the Google cases.
|
||||
let indexSpy: { mockRestore: () => void } | null = null;
|
||||
afterEach(() => {
|
||||
indexSpy?.mockRestore();
|
||||
indexSpy = null;
|
||||
});
|
||||
function amapSelected() {
|
||||
indexSpy = vi.spyOn(svc, 'trekPlacesEnabled').mockReturnValue(false);
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
mockDbGet.mockReturnValue({ maps_api_key: null, amap_api_key: 'akey' } as any);
|
||||
keys({ amap: 'akey' });
|
||||
}
|
||||
|
||||
it('AMAP-080: search reports amap as the source so the client can credit it', async () => {
|
||||
amapSelected();
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B1', name: '外滩', location: TIANANMEN_LOCATION }] })),
|
||||
@@ -568,5 +592,60 @@ describe('MapsService.searchPlaces with Amap selected', () => {
|
||||
const result = await svc.searchPlaces(1, '外滩');
|
||||
expect(result.source).toBe('amap');
|
||||
expect(result.places).toHaveLength(1);
|
||||
expect(result.places[0].amap_poi_id).toBe('amap:B1');
|
||||
});
|
||||
|
||||
it('AMAP-081: autocomplete goes to inputtips instead of Nominatim', async () => {
|
||||
amapSelected();
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [{ id: 'T1', name: '外滩', district: '上海市黄浦区' }] })));
|
||||
|
||||
const result = await svc.autocompletePlaces(1, '外滩');
|
||||
expect(result.source).toBe('amap');
|
||||
expect(result.suggestions[0].placeId).toBe('amap:T1');
|
||||
expect(calledUrl()).toContain('/v3/assistant/inputtips');
|
||||
});
|
||||
|
||||
it('AMAP-082: reverse geocoding asks Amap at the instance key, and falls back to Nominatim when it fails', async () => {
|
||||
// No user behind a reverse lookup: the chain stops at the instance-wide row.
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
mockInstanceGet.mockReturnValue({ value: 'akey' });
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi
|
||||
.fn()
|
||||
.mockResolvedValueOnce(amapError('10003'))
|
||||
.mockResolvedValueOnce({ ok: true, json: async () => ({ name: '某处', display_name: '某地址' }) }),
|
||||
);
|
||||
|
||||
const answer = await svc.reverseGeocode('39.9', '116.4');
|
||||
expect(answer).toEqual({ name: '某处', address: '某地址' });
|
||||
const calls = (globalThis.fetch as any).mock.calls.map((c: unknown[]) => String(c[0]));
|
||||
expect(calls[0]).toContain('/v3/geocode/regeo');
|
||||
expect(calls[1]).toContain('nominatim');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('AMAP-083: a pasted Amap marker link resolves without touching the Google path', async () => {
|
||||
// Auto with no key at all: the address comes from Nominatim, the coordinate
|
||||
// and the name from the link itself, already converted to WGS-84.
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue({ ok: true, json: async () => ({ display_name: '北京市东城区' }) }));
|
||||
|
||||
const result = await svc.resolveGoogleMapsUrl(
|
||||
`https://uri.amap.com/marker?position=${TIANANMEN_LOCATION}&name=%E5%A4%A9%E5%AE%89%E9%97%A8`,
|
||||
);
|
||||
expect(result.lat).toBeCloseTo(39.90869, 4);
|
||||
expect(result.lng).toBeCloseTo(116.39124, 4);
|
||||
expect(result.name).toBe('天安门');
|
||||
expect(result.address).toBe('北京市东城区');
|
||||
expect(result.google_ftid).toBeNull();
|
||||
expect(calledUrl()).toContain('nominatim');
|
||||
});
|
||||
|
||||
it('AMAP-084: an Amap POI page with no coordinate is the same 400 a bare Google page gets', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
await expect(svc.resolveGoogleMapsUrl('https://www.amap.com/place/B000A83M61')).rejects.toMatchObject({ status: 400 });
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user