mirror of
https://github.com/mauriceboe/TREK.git
synced 2026-10-19 05:26:47 +00:00
feat(map): amap as map api provider and display template
This commit is contained in:
@@ -290,6 +290,7 @@ async function main() {
|
||||
'admin_ntfy_token',
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
'amap_api_key',
|
||||
]) {
|
||||
const row = db.prepare('SELECT value FROM app_settings WHERE key = ?').get(key) as { value: string } | undefined;
|
||||
if (!row?.value) continue;
|
||||
@@ -312,6 +313,9 @@ async function main() {
|
||||
const apiKeyColumns = [
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
// Added with the Amap provider; filtered against the real table below, so a
|
||||
// database that has not run that migration yet is not a rotation failure.
|
||||
'amap_api_key',
|
||||
'openweather_api_key',
|
||||
'immich_api_key',
|
||||
'synology_password',
|
||||
|
||||
@@ -77,7 +77,7 @@ below.)
|
||||
|
||||
**Runtime-toggled** (read live on every access via `readEnv()` /
|
||||
`RuntimeEnvService`; tests mutate these mid-lifetime):
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_MANAGED, PLACES_API_BASE, PLACES_API_KEY, AMAP_API_BASE, AMAP_API_KEY, AMAP_API_SECRET, MAPBOX_ACCESS_TOKEN, CARTO_API_KEY, DEMO_MODE, NODE_ENV, APP_VERSION, APP_URL, TREK_API_DOCS_ENABLED,
|
||||
TREK_PLUGINS_ENABLED / _DEV_LINK / _IGNORE_TREK_RANGE / _DIR / _DATA_DIR / TREK_PLUGIN_PERMISSIONS,
|
||||
OIDC_*, SMTP_*, FORCE_HTTPS, COOKIE_SECURE, HSTS_INCLUDE_SUBDOMAINS,
|
||||
ALLOWED_ORIGINS, UNSPLASH_ACCESS_KEY, WEBAUTHN_*, TZ, ADMIN_EMAIL,
|
||||
|
||||
@@ -153,6 +153,15 @@ export function deriveMaps(raw: RawEnv) {
|
||||
*/
|
||||
trekPlacesEnabled: raw.TREK_PLACES_ENABLED !== 'false',
|
||||
placesApiKey: raw.PLACES_API_KEY || undefined,
|
||||
/** The same two knobs for Amap (高德), which an install in China uses instead. */
|
||||
amapApiBase: raw.AMAP_API_BASE || undefined,
|
||||
amapApiKey: raw.AMAP_API_KEY || undefined,
|
||||
/**
|
||||
* Amap's optional 数字签名 secret. A key created with one rejects every
|
||||
* unsigned request, so this is not a hardening option — it is required
|
||||
* whenever the operator's key was issued that way.
|
||||
*/
|
||||
amapApiSecret: raw.AMAP_API_SECRET || undefined,
|
||||
/** Public pk.* token shipped with a managed instance; reaches the browser by design. */
|
||||
mapboxToken: raw.MAPBOX_ACCESS_TOKEN || undefined,
|
||||
/** CARTO basemap key; without one the tiles come back watermarked (#2054). Public too. */
|
||||
|
||||
@@ -146,6 +146,9 @@ export const envSchema = z.object({
|
||||
TREK_PLACES_URL: url,
|
||||
TREK_PLACES_ENABLED: boolStr,
|
||||
PLACES_API_KEY: anyString,
|
||||
AMAP_API_BASE: url,
|
||||
AMAP_API_KEY: anyString,
|
||||
AMAP_API_SECRET: anyString,
|
||||
MAPBOX_ACCESS_TOKEN: anyString,
|
||||
CARTO_API_KEY: anyString,
|
||||
DEMO_MODE: boolStr,
|
||||
|
||||
@@ -10,6 +10,7 @@ function createTables(db: Database.Database): void {
|
||||
role TEXT NOT NULL DEFAULT 'user',
|
||||
maps_api_key TEXT,
|
||||
unsplash_api_key TEXT,
|
||||
amap_api_key TEXT,
|
||||
openweather_api_key TEXT,
|
||||
avatar TEXT,
|
||||
oidc_sub TEXT,
|
||||
|
||||
@@ -242,6 +242,11 @@ export function applyGlobalMiddleware(
|
||||
// refused here while nothing in the app could see it: the switch flipped, the
|
||||
// layer went on, and no tile ever arrived (#2307).
|
||||
"https://server.arcgisonline.com",
|
||||
// Amap's raster tiles, for an install whose users are in China, for the
|
||||
// same reason as the hosts above (#2180). Road (webrd01..04) and
|
||||
// satellite (webst01..04) are numbered shards of one domain, so the
|
||||
// wildcard is the whole list.
|
||||
"https://*.is.autonavi.com",
|
||||
"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/",
|
||||
|
||||
@@ -36,6 +36,12 @@ export const ADMIN_SETTINGS_KEYS = [
|
||||
'notify_trip_reminder',
|
||||
'password_login', 'password_registration', 'oidc_login', 'oidc_registration',
|
||||
'passkey_login', 'webauthn_rp_id', 'webauthn_origins',
|
||||
// Which places provider answers search/autocomplete/details: 'auto' (the
|
||||
// default and what every install had before Amap existed), 'google', 'amap' or
|
||||
// 'openstreetmap'. Validated in updateAppSettings — an unknown value is
|
||||
// refused rather than stored, because the reader degrades a bad row to 'auto'
|
||||
// and the admin would be left looking at a setting that does nothing.
|
||||
'places_provider',
|
||||
];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -53,6 +59,7 @@ export function stripUserForClient(user: User): Record<string, unknown> {
|
||||
maps_api_key: _m,
|
||||
openweather_api_key: _o,
|
||||
unsplash_api_key: _u,
|
||||
amap_api_key: _a,
|
||||
mfa_secret: _mf,
|
||||
mfa_backup_codes: _mbc,
|
||||
...rest
|
||||
|
||||
@@ -15,6 +15,9 @@ import { validatePassword } from '../common/passwordPolicy';
|
||||
import { encryptMfaSecret, decryptMfaSecret } from '../common/crypto/mfaCrypto';
|
||||
import { decrypt_api_key, maybe_encrypt_api_key, encrypt_api_key } from '../common/crypto/apiKeyCrypto';
|
||||
import { resolveApiKey } from '../settings/instance-api-keys';
|
||||
// Type-and-guard only: the app-config read reports the provider choice, it does
|
||||
// not construct one, so this does not pull the maps domain into auth.
|
||||
import { isPlacesProviderChoice } from '../maps/providers/places-provider';
|
||||
import { EphemeralTokenService } from './ephemeral-token.service';
|
||||
// Import from sessionManager directly, NOT the ../../mcp barrel: the barrel pulls
|
||||
// the whole tools fan-out (and via the domain bridges, the Nest services) into
|
||||
@@ -263,6 +266,12 @@ export class AuthService {
|
||||
// question is only about the instance, which is the first two steps of the
|
||||
// chain; id 0 matches no row.
|
||||
const hasGoogleKey = !!resolveApiKey(this.db, 'maps_api_key', authenticatedUser?.id ?? 0, readEnv().maps.placesApiKey).key;
|
||||
// The same question for Amap, asked the same way. The client needs both to
|
||||
// tell "search is unavailable" from "search runs on OpenStreetMap", and to
|
||||
// know whether the provider the admin selected actually has a credential.
|
||||
const hasAmapKey = !!resolveApiKey(this.db, 'amap_api_key', authenticatedUser?.id ?? 0, readEnv().maps.amapApiKey).key;
|
||||
const placesProviderRow = this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'places_provider'")?.value;
|
||||
const placesProvider = isPlacesProviderChoice(placesProviderRow) ? placesProviderRow : 'auto';
|
||||
const oidcDisplayName = readEnv().oidc.displayName ||
|
||||
this.db.get<{ value: string }>("SELECT value FROM app_settings WHERE key = 'oidc_display_name'")?.value || null;
|
||||
const oidcConfigured = !!(
|
||||
@@ -313,6 +322,8 @@ export class AuthService {
|
||||
version,
|
||||
is_prerelease: version.includes('-pre.'),
|
||||
has_maps_key: hasGoogleKey,
|
||||
has_amap_key: hasAmapKey,
|
||||
places_provider: placesProvider,
|
||||
oidc_configured: oidcConfigured,
|
||||
oidc_display_name: oidcConfigured ? (oidcDisplayName || 'SSO') : undefined,
|
||||
require_mfa: requireMfaRow?.value === 'true',
|
||||
@@ -695,6 +706,10 @@ export class AuthService {
|
||||
if (key === 'require_mfa') {
|
||||
val = body[key] === true || val === 'true' ? 'true' : 'false';
|
||||
}
|
||||
// An unknown provider name is dropped, not stored: the maps service
|
||||
// degrades an unrecognised row to 'auto', so writing one would show the
|
||||
// admin a saved setting that quietly does nothing.
|
||||
if (key === 'places_provider' && !isPlacesProviderChoice(val)) continue;
|
||||
if (key === 'smtp_pass' && val === '••••••••') continue;
|
||||
if (key === 'smtp_pass') val = encrypt_api_key(val);
|
||||
if (key === 'admin_webhook_url' && val === '••••••••') continue;
|
||||
|
||||
@@ -53,8 +53,8 @@ export class UserProfileService {
|
||||
|
||||
/** The three key columns plus the role that decides where a save lands. */
|
||||
private currentKeys(userId: number) {
|
||||
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
|
||||
return this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
}
|
||||
@@ -67,8 +67,8 @@ export class UserProfileService {
|
||||
* their own column only speaks when no instance value has been set yet.
|
||||
*/
|
||||
private storedKeyPlaintext(
|
||||
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key',
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
|
||||
name: 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key',
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
|
||||
isAdmin: boolean,
|
||||
): string {
|
||||
if (isAdmin && (INSTANCE_API_KEY_NAMES as readonly string[]).includes(name)) {
|
||||
@@ -91,12 +91,12 @@ export class UserProfileService {
|
||||
*/
|
||||
private changedKeyNames(
|
||||
body: Record<string, unknown>,
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'> | undefined,
|
||||
current: Pick<User, 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'> | undefined,
|
||||
isAdmin: boolean,
|
||||
skipped: string[] = [],
|
||||
): string[] {
|
||||
const norm = (v: unknown) => String(v ?? '').trim();
|
||||
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key'] as const).filter(
|
||||
return (['maps_api_key', 'openweather_api_key', 'unsplash_api_key', 'amap_api_key'] as const).filter(
|
||||
(name) => body[name] !== undefined && !skipped.includes(name) && norm(body[name]) !== norm(this.storedKeyPlaintext(name, current, isAdmin))
|
||||
);
|
||||
}
|
||||
@@ -135,7 +135,7 @@ export class UserProfileService {
|
||||
}
|
||||
|
||||
updateApiKeys(userId: number, rawBody: unknown) {
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string };
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string };
|
||||
const { blocked } = splitManagedKeys(body, this.managed);
|
||||
for (const key of blocked) delete body[key as keyof typeof body];
|
||||
const current = this.currentKeys(userId);
|
||||
@@ -146,17 +146,18 @@ export class UserProfileService {
|
||||
// `?? null` instead of the former non-null assertions: a user row deleted
|
||||
// mid-request must degrade to a 0-row UPDATE, not a TypeError/500.
|
||||
this.db.run(
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
'UPDATE users SET maps_api_key = ?, openweather_api_key = ?, unsplash_api_key = ?, amap_api_key = ?, updated_at = CURRENT_TIMESTAMP WHERE id = ?',
|
||||
body.maps_api_key !== undefined ? maybe_encrypt_api_key(body.maps_api_key) : current?.maps_api_key ?? null,
|
||||
body.openweather_api_key !== undefined ? maybe_encrypt_api_key(body.openweather_api_key) : current?.openweather_api_key ?? null,
|
||||
body.unsplash_api_key !== undefined ? maybe_encrypt_api_key(body.unsplash_api_key) : current?.unsplash_api_key ?? null,
|
||||
body.amap_api_key !== undefined ? maybe_encrypt_api_key(body.amap_api_key) : current?.amap_api_key ?? null,
|
||||
userId
|
||||
);
|
||||
this.mirrorInstanceKeys(body, isAdmin);
|
||||
});
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -164,7 +165,7 @@ export class UserProfileService {
|
||||
return {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), amap_api_key: mask_stored_api_key(u?.amap_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
changedKeys,
|
||||
};
|
||||
}
|
||||
@@ -173,8 +174,8 @@ export class UserProfileService {
|
||||
userId: number,
|
||||
rawBody: unknown
|
||||
): { error?: string; status?: number; success?: boolean; user?: Record<string, unknown>; changedKeys?: string[] } {
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; username?: string; email?: string };
|
||||
const { maps_api_key, openweather_api_key, unsplash_api_key, username, email } = body;
|
||||
const body = rawBody as { maps_api_key?: string; openweather_api_key?: string; unsplash_api_key?: string; amap_api_key?: string; username?: string; email?: string };
|
||||
const { maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, username, email } = body;
|
||||
|
||||
if (username !== undefined) {
|
||||
const trimmed = username.trim();
|
||||
@@ -208,6 +209,7 @@ export class UserProfileService {
|
||||
if (maps_api_key !== undefined && !keyLocked) { updates.push('maps_api_key = ?'); params.push(maybe_encrypt_api_key(maps_api_key)); }
|
||||
if (openweather_api_key !== undefined && !keyLocked) { updates.push('openweather_api_key = ?'); params.push(maybe_encrypt_api_key(openweather_api_key)); }
|
||||
if (unsplash_api_key !== undefined && !keyLocked) { updates.push('unsplash_api_key = ?'); params.push(maybe_encrypt_api_key(unsplash_api_key)); }
|
||||
if (amap_api_key !== undefined && !keyLocked) { updates.push('amap_api_key = ?'); params.push(maybe_encrypt_api_key(amap_api_key)); }
|
||||
if (username !== undefined) { updates.push('username = ?'); params.push(username.trim()); }
|
||||
if (email !== undefined) { updates.push('email = ?'); params.push(email.trim()); }
|
||||
|
||||
@@ -226,8 +228,8 @@ export class UserProfileService {
|
||||
});
|
||||
}
|
||||
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
const updated = this.db.get<Pick<User, 'id' | 'username' | 'email' | 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key' | 'avatar' | 'mfa_enabled'>>(
|
||||
'SELECT id, username, email, role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key, avatar, mfa_enabled FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
|
||||
@@ -235,14 +237,14 @@ export class UserProfileService {
|
||||
return {
|
||||
success: true,
|
||||
...(blocked.length ? { managed_keys: blocked } : {}),
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
user: { ...u, maps_api_key: mask_stored_api_key(u?.maps_api_key), openweather_api_key: mask_stored_api_key(u?.openweather_api_key), unsplash_api_key: mask_stored_api_key(u?.unsplash_api_key), amap_api_key: mask_stored_api_key(u?.amap_api_key), avatar_url: avatarUrl(updated || {}) },
|
||||
changedKeys,
|
||||
};
|
||||
}
|
||||
|
||||
getSettings(userId: number): { error?: string; status?: number; settings?: Record<string, unknown> } {
|
||||
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key FROM users WHERE id = ?',
|
||||
const user = this.db.get<Pick<User, 'role' | 'maps_api_key' | 'openweather_api_key' | 'unsplash_api_key' | 'amap_api_key'>>(
|
||||
'SELECT role, maps_api_key, openweather_api_key, unsplash_api_key, amap_api_key FROM users WHERE id = ?',
|
||||
userId
|
||||
);
|
||||
if (user?.role !== 'admin') return { error: 'Admin access required', status: 403 };
|
||||
@@ -258,6 +260,7 @@ export class UserProfileService {
|
||||
maps_api_key: null,
|
||||
openweather_api_key: null,
|
||||
unsplash_api_key: null,
|
||||
amap_api_key: null,
|
||||
managed_keys: [...MANAGED_LOCKED_PROFILE_KEYS],
|
||||
},
|
||||
};
|
||||
@@ -272,6 +275,7 @@ export class UserProfileService {
|
||||
maps_api_key: readInstanceApiKey(this.db, 'maps_api_key') ?? decrypt_api_key(user.maps_api_key),
|
||||
openweather_api_key: decrypt_api_key(user.openweather_api_key),
|
||||
unsplash_api_key: readInstanceApiKey(this.db, 'unsplash_api_key') ?? decrypt_api_key(user.unsplash_api_key),
|
||||
amap_api_key: readInstanceApiKey(this.db, 'amap_api_key') ?? decrypt_api_key(user.amap_api_key),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -77,6 +77,9 @@ export function isManagedBlocked(env: RuntimeEnvService): boolean {
|
||||
* the tiles it fetches.
|
||||
*/
|
||||
export const MANAGED_LOCKED_SETTING_KEYS = [
|
||||
// The operator's for the same reason maps_api_key is: Amap bills the account
|
||||
// the key belongs to, and on a managed install that account is theirs.
|
||||
'amap_api_key',
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
'llm_base_url',
|
||||
@@ -110,6 +113,7 @@ export const MANAGED_LOCKED_PROFILE_KEYS = [
|
||||
'maps_api_key',
|
||||
'openweather_api_key',
|
||||
'unsplash_api_key',
|
||||
'amap_api_key',
|
||||
] as const;
|
||||
|
||||
/**
|
||||
@@ -142,6 +146,10 @@ export const MANAGED_CUSTOMER_KEYS = [
|
||||
'passkey_login',
|
||||
'password_login',
|
||||
'password_registration',
|
||||
// WHICH provider answers place search stays the admin's call even when the
|
||||
// operator owns both credentials: it decides what their users see, not what
|
||||
// the operator is billed for — the keys above cover that.
|
||||
'places_provider',
|
||||
'require_mfa',
|
||||
'temperature_unit',
|
||||
'time_format',
|
||||
|
||||
@@ -726,18 +726,19 @@ export function parseWikipediaTag(tag: string | undefined | null): { lang: strin
|
||||
// Ids that can never resolve against the Google Places API: coordinate pseudo-ids
|
||||
// (right-click places, in both the coords: and the bare "lat,lng" spelling the
|
||||
// collection views send), OSM ids the client sends when a place has no
|
||||
// google_place_id, GERS ids from the TREK index, the raw photo URLs legacy rows
|
||||
// keep in image_url, and photo cache keys of the form "<placeId>~p3" that
|
||||
// enrichment mints for the picker.
|
||||
// google_place_id, GERS ids from the TREK index, Amap POI ids (`amap:<poiid>`,
|
||||
// which are otherwise shaped exactly like a Google id), the raw photo URLs
|
||||
// legacy rows keep in image_url, and photo cache keys of the form "<placeId>~p3"
|
||||
// that enrichment mints for the picker.
|
||||
// Google answers those with a billable 400 INVALID_ARGUMENT, so every lookup
|
||||
// sorts them out before the call and uses the OSM/Wikimedia path instead.
|
||||
// sorts them out before the call and uses the right provider instead.
|
||||
//
|
||||
// `gers:` belongs here for the same reason as `node:`, and it matters more: the
|
||||
// index answers first for search and autocomplete, so a GERS id is what an
|
||||
// ordinary new place now carries. Leaving it out billed three invalid lookups
|
||||
// per place — photo refs, editorial summary, and the photo route.
|
||||
const NON_GOOGLE_PLACE_ID =
|
||||
/^(?:coords|gers|node|way|relation):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
|
||||
/^(?:coords|gers|node|way|relation|amap):|^https?:\/\/|^-?\d+(?:\.\d+)?,\s*-?\d+(?:\.\d+)?$|~p\d+$/i;
|
||||
// The subset that still has a provider behind it — Overpass for details,
|
||||
// Wikimedia for photos.
|
||||
export const OSM_PLACE_ID = /^(?:node|way|relation):/i;
|
||||
|
||||
@@ -0,0 +1,506 @@
|
||||
/**
|
||||
* Amap (高德地图) Web Service API as a PlacesProvider.
|
||||
*
|
||||
* Why this exists: Google Places and Nominatim are both poor inside mainland
|
||||
* China. Google is unreachable from most Chinese networks, and OpenStreetMap's
|
||||
* coverage of Chinese POIs — restaurants, shops, the things a trip is actually
|
||||
* made of — is thin and rarely in Chinese. Amap is what a Chinese user expects
|
||||
* their map search to behave like.
|
||||
*
|
||||
* Two things make this provider different from the Google one:
|
||||
*
|
||||
* 1. **Datum.** Amap speaks GCJ-02, TREK speaks WGS-84 everywhere else. This
|
||||
* file is one of the two places in the codebase allowed to hold a GCJ-02
|
||||
* coordinate, and it never lets one escape: every outbound coordinate is
|
||||
* converted on the way out, every inbound one on the way back. Nothing
|
||||
* downstream — the database, the map, a GPX export — ever sees GCJ-02.
|
||||
*
|
||||
* 2. **Errors.** Amap answers HTTP 200 for everything and puts the verdict in
|
||||
* the body (`status: "0"` plus an `infocode`). A quota-exceeded response and
|
||||
* a successful one are the same status line, so the body is checked and
|
||||
* translated into the same `Error & { status }` the Google path throws —
|
||||
* otherwise the controller's error mapping would report every failure as an
|
||||
* empty result.
|
||||
*
|
||||
* Deliberately not implemented: `photoRefs`/`photoBytes`. Amap's POI photos come
|
||||
* with no licence statement we could show a user, and the existing Wikimedia
|
||||
* fallback already produces a picture *with* its attribution. An unattributable
|
||||
* image is worse than no image, so the photo path falls through to Wikimedia for
|
||||
* Amap places.
|
||||
*/
|
||||
import { createHash } from 'node:crypto';
|
||||
import { fromAmapLocation, gcj02ToWgs84, toAmapLocation } from '@trek/shared';
|
||||
import { readEnv } from '../../../app-config';
|
||||
import { safeFetchFollow } from '../../../utils/ssrfGuard';
|
||||
import { UA, parseOpeningHours } from '../maps.helpers';
|
||||
import type {
|
||||
DetailsOptions,
|
||||
PlacesProvider,
|
||||
ProviderCredential,
|
||||
ProviderPlace,
|
||||
ProviderSuggestion,
|
||||
SearchBias,
|
||||
ViewportBias,
|
||||
} from './places-provider';
|
||||
|
||||
/** The upstream every Web Service call is written against. */
|
||||
const AMAP_UPSTREAM = 'https://restapi.amap.com';
|
||||
|
||||
/**
|
||||
* The prefix that makes an Amap POI id recognisable anywhere in TREK.
|
||||
*
|
||||
* Amap ids are bare hex-ish strings (`B0FFFAB6J2`), indistinguishable from a
|
||||
* Google place id by shape. Without a namespace, an Amap place opened later
|
||||
* would be sent to Google, which answers a foreign id with a *billable* 400.
|
||||
* The separator is a colon to match the existing `node:`/`way:`/`coords:`
|
||||
* convention — see maps.helpers.ts, where this prefix is part of
|
||||
* NON_GOOGLE_PLACE_ID for exactly that reason.
|
||||
*/
|
||||
export const AMAP_PLACE_ID_PREFIX = 'amap:';
|
||||
export const AMAP_PLACE_ID = /^amap:(.+)$/i;
|
||||
|
||||
export function isAmapPlaceId(placeId: string): boolean {
|
||||
return AMAP_PLACE_ID.test(placeId);
|
||||
}
|
||||
|
||||
/** The bare provider id, or null when this is not an Amap place. */
|
||||
export function amapPoiId(placeId: string): string | null {
|
||||
const m = AMAP_PLACE_ID.exec(placeId);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
|
||||
let amapApiCallCount = 0;
|
||||
|
||||
/**
|
||||
* Amap accepts either `lang=zh_cn` or `lang=en`, and nothing else. Anything we
|
||||
* cannot serve is answered in Chinese rather than rejected, which is the right
|
||||
* default for a China-only provider.
|
||||
*/
|
||||
function toAmapLang(lang?: string): string {
|
||||
return lang && /^en/i.test(lang) ? 'en' : 'zh_cn';
|
||||
}
|
||||
|
||||
/**
|
||||
* Amap's own error text for the codes an operator can actually act on.
|
||||
*
|
||||
* The raw `info` string is returned for everything else; these three are
|
||||
* singled out because they are the ones a misconfigured install hits first, and
|
||||
* because Amap's own wording for them ("INVALID_USER_SCODE") does not tell an
|
||||
* admin what to change.
|
||||
*/
|
||||
const AMAP_INFOCODE_HINTS: Record<string, string> = {
|
||||
'10001': 'Amap API key is invalid — check that it is a "Web 服务" (web service) key, not a JS API key',
|
||||
'10003': 'Amap daily request quota exhausted',
|
||||
'10009': 'Amap key rejected the request: the key is restricted to a different domain or IP',
|
||||
};
|
||||
|
||||
/**
|
||||
* The HTTP status TREK should answer with for an Amap failure.
|
||||
*
|
||||
* Amap's own status line is always 200, and the controller maps a thrown
|
||||
* `.status` straight through to the client, so a credential problem has to
|
||||
* arrive as 403 and a quota problem as 429 for the client to say anything
|
||||
* useful about it.
|
||||
*/
|
||||
function statusForInfocode(infocode: string): number {
|
||||
if (infocode === '10001' || infocode === '10009') return 403;
|
||||
if (infocode === '10003' || infocode === '10004' || infocode === '10019' || infocode === '10020') return 429;
|
||||
return 502;
|
||||
}
|
||||
|
||||
interface AmapEnvelope {
|
||||
status?: string;
|
||||
info?: string;
|
||||
infocode?: string;
|
||||
}
|
||||
|
||||
interface AmapTip {
|
||||
id?: string;
|
||||
name?: string;
|
||||
district?: string;
|
||||
address?: unknown;
|
||||
location?: unknown;
|
||||
}
|
||||
|
||||
interface AmapPoi {
|
||||
id?: string;
|
||||
name?: string;
|
||||
address?: unknown;
|
||||
location?: unknown;
|
||||
pname?: string;
|
||||
cityname?: string;
|
||||
adname?: string;
|
||||
type?: string;
|
||||
tel?: unknown;
|
||||
website?: unknown;
|
||||
biz_ext?: { rating?: unknown; open_time?: unknown };
|
||||
business?: { rating?: unknown; tel?: unknown; opentime_week?: unknown; opentime_today?: unknown };
|
||||
}
|
||||
|
||||
/**
|
||||
* Amap returns an empty *array* where a string field has no value — `address:
|
||||
* []`, `tel: []` — so every optional text field has to be coerced rather than
|
||||
* read. A bare `poi.address || ''` yields `[]` in a template and `"[]"` in the
|
||||
* database.
|
||||
*/
|
||||
function amapText(value: unknown): string {
|
||||
if (typeof value === 'string') return value.trim();
|
||||
if (typeof value === 'number') return String(value);
|
||||
// Some fields arrive as a one-element array of the value.
|
||||
if (Array.isArray(value) && value.length === 1) return amapText(value[0]);
|
||||
return '';
|
||||
}
|
||||
|
||||
function amapNumber(value: unknown): number | null {
|
||||
const text = amapText(value);
|
||||
if (!text) return null;
|
||||
const n = Number.parseFloat(text);
|
||||
return Number.isFinite(n) ? n : null;
|
||||
}
|
||||
|
||||
export class AmapPlacesProvider implements PlacesProvider {
|
||||
readonly id = 'amap' as const;
|
||||
|
||||
constructor(private readonly credential: ProviderCredential) {}
|
||||
|
||||
ownsPlaceId(placeId: string): boolean {
|
||||
return isAmapPlaceId(placeId);
|
||||
}
|
||||
|
||||
// ── Outbound plumbing ──────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Build the full URL for one call, key and optional signature attached.
|
||||
*
|
||||
* `AMAP_API_BASE` mirrors `PLACES_API_BASE`: an install that routes its
|
||||
* outbound calls through a proxy or a gateway holding the credential says so
|
||||
* once, here.
|
||||
*/
|
||||
private url(path: string, params: Record<string, string>): string {
|
||||
const query = new URLSearchParams({ ...params, key: this.credential.key, output: 'JSON' });
|
||||
const sig = this.signature(params);
|
||||
if (sig) query.set('sig', sig);
|
||||
const base = (readEnv().maps.amapApiBase || AMAP_UPSTREAM).replace(/([^/]|^)\/+$/, '$1');
|
||||
return `${base}${path}?${query.toString()}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*
|
||||
* Unset, which is the common case, nothing is added.
|
||||
*/
|
||||
private signature(params: Record<string, string>): string | null {
|
||||
const secret = readEnv().maps.amapApiSecret;
|
||||
if (!secret) return null;
|
||||
const signed = { ...params, key: this.credential.key, output: 'JSON' };
|
||||
const canonical = Object.keys(signed)
|
||||
.sort()
|
||||
.map((k) => `${k}=${signed[k]}`)
|
||||
.join('&');
|
||||
return createHash('md5').update(`${canonical}${secret}`).digest('hex');
|
||||
}
|
||||
|
||||
/**
|
||||
* One call, with the body-carried verdict turned into a real error.
|
||||
*
|
||||
* Through safeFetchFollow like every other outbound URL in the maps domain, so
|
||||
* a proxied base cannot be pointed at something internal.
|
||||
*/
|
||||
private async call<T extends AmapEnvelope>(path: string, params: Record<string, string>, label: string): Promise<T> {
|
||||
const url = this.url(path, params);
|
||||
amapApiCallCount++;
|
||||
// The key rides in the query string, so the label is logged without the URL —
|
||||
// unlike the Google path, where the credential sits in a header.
|
||||
console.debug(`[Amap API] #${amapApiCallCount} ${label} → ${path}`);
|
||||
|
||||
const response = await safeFetchFollow(
|
||||
url,
|
||||
{ headers: { 'User-Agent': UA }, signal: AbortSignal.timeout(8000) },
|
||||
{ bypassInternalIpAllowed: true },
|
||||
);
|
||||
|
||||
if (!response.ok) {
|
||||
// A transport-level failure, i.e. the proxy in front of Amap rather than
|
||||
// Amap itself: Amap's own answers are always 200.
|
||||
this.fail(label, response.status, `Amap ${label} failed with HTTP ${response.status}`);
|
||||
}
|
||||
|
||||
const data = (await response.json()) as T;
|
||||
if (data.status !== '1') {
|
||||
const infocode = data.infocode ?? '';
|
||||
const hint = AMAP_INFOCODE_HINTS[infocode] || data.info || 'Amap API error';
|
||||
this.fail(label, statusForInfocode(infocode), `${hint} (infocode ${infocode || 'none'})`);
|
||||
}
|
||||
return data;
|
||||
}
|
||||
|
||||
private fail(label: string, status: number, message: string): never {
|
||||
console.error(`[Maps] amap/${label} failed with ${status} userId=${this.credential.userId} keySource=${this.credential.source}`);
|
||||
const err = new Error(message) as Error & { status: number };
|
||||
err.status = status;
|
||||
throw err;
|
||||
}
|
||||
|
||||
// ── Normalisation ──────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* One POI in the shape the rest of TREK consumes, coordinates back in WGS-84.
|
||||
*
|
||||
* `address` is assembled rather than taken as-is: Amap's `address` field is
|
||||
* the street line alone ("三里屯路19号"), which on its own is not enough to
|
||||
* 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 {
|
||||
const coords = fromAmapLocation(poi.location);
|
||||
const business = poi.business ?? {};
|
||||
const bizExt = poi.biz_ext ?? {};
|
||||
const street = amapText(poi.address);
|
||||
const region = [amapText(poi.pname), amapText(poi.cityname), amapText(poi.adname)]
|
||||
// A municipality repeats itself — pname and cityname are both "北京市".
|
||||
.filter((part, i, all) => part && all.indexOf(part) === i);
|
||||
const address = [...region, street].filter(Boolean).join('');
|
||||
const openText = amapText(business.opentime_week) || amapText(bizExt.open_time);
|
||||
|
||||
return {
|
||||
amap_poi_id: poi.id ? `${AMAP_PLACE_ID_PREFIX}${poi.id}` : null,
|
||||
name: amapText(poi.name),
|
||||
address,
|
||||
// Null rather than dropped: a POI with no geometry still answers "which
|
||||
// one did I mean" in the picker, and the caller decides whether it is
|
||||
// usable. `?? null` because 0 is a real coordinate.
|
||||
lat: coords?.lat ?? null,
|
||||
lng: coords?.lng ?? null,
|
||||
rating: amapNumber(business.rating ?? bizExt.rating),
|
||||
rating_count: null,
|
||||
website: amapText(poi.website) || null,
|
||||
phone: amapText(business.tel ?? poi.tel) || null,
|
||||
// Amap's type is a slash-separated taxonomy ("餐饮服务;中餐厅;川菜"); split so
|
||||
// it reads like the string array every other provider returns.
|
||||
types: amapText(poi.type).split(/[;|]/).map((t) => t.trim()).filter(Boolean),
|
||||
// Amap gives opening hours as free text in the same dialect OSM uses for
|
||||
// simple cases, so the existing parser gets a chance at it; unparseable
|
||||
// text is still worth showing verbatim.
|
||||
opening_hours: openText ? parseOpeningHours(openText).weekdayDescriptions : null,
|
||||
open_now: openText ? parseOpeningHours(openText).openNow : null,
|
||||
opening_periods: null,
|
||||
opening_special_days: null,
|
||||
summary: null,
|
||||
reviews: [],
|
||||
source: 'amap' as const,
|
||||
};
|
||||
}
|
||||
|
||||
// ── PlacesProvider ─────────────────────────────────────────────────────────
|
||||
|
||||
async searchText(query: string, lang?: string, bias?: SearchBias): Promise<ProviderPlace[]> {
|
||||
const common = {
|
||||
keywords: query,
|
||||
offset: '10',
|
||||
page: '1',
|
||||
extensions: 'all',
|
||||
language: toAmapLang(lang),
|
||||
};
|
||||
|
||||
// `place/around` rather than `place/text` when we know where the user is
|
||||
// looking: Amap's text search has no bias parameter at all, so without this
|
||||
// a search for "咖啡" from a Shanghai viewport returns Beijing.
|
||||
const data = bias
|
||||
? await this.call<AmapEnvelope & { pois?: AmapPoi[] }>(
|
||||
'/v3/place/around',
|
||||
{
|
||||
...common,
|
||||
location: toAmapLocation(bias.lat, bias.lng),
|
||||
// Amap caps the radius at 50 km, which is also the default the
|
||||
// Google path uses for an unspecified bias.
|
||||
radius: String(Math.min(Math.round(bias.radius ?? 50000), 50000)),
|
||||
},
|
||||
'place/around',
|
||||
)
|
||||
: 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);
|
||||
}
|
||||
|
||||
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[]> {
|
||||
const params: Record<string, string> = {
|
||||
keywords: input,
|
||||
// Without this, inputtips also returns bus stops and road names, which
|
||||
// cannot be turned into a place with coordinates.
|
||||
datatype: 'poi',
|
||||
language: toAmapLang(lang),
|
||||
};
|
||||
// inputtips biases around a point, not a rectangle: the viewport centre is
|
||||
// the closest thing we can give it.
|
||||
if (bias) {
|
||||
const lat = (bias.low.lat + bias.high.lat) / 2;
|
||||
const lng = (bias.low.lng + bias.high.lng) / 2;
|
||||
params.location = toAmapLocation(lat, lng);
|
||||
}
|
||||
|
||||
const data = await this.call<AmapEnvelope & { tips?: AmapTip[] }>(
|
||||
'/v3/assistant/inputtips',
|
||||
params,
|
||||
'inputtips',
|
||||
);
|
||||
|
||||
return (data.tips ?? [])
|
||||
// A tip without an id cannot be looked up afterwards, and inputtips does
|
||||
// return those (a district name, a road).
|
||||
.filter((tip) => amapText(tip.id))
|
||||
.slice(0, 5)
|
||||
.map((tip) => ({
|
||||
placeId: `${AMAP_PLACE_ID_PREFIX}${amapText(tip.id)}`,
|
||||
mainText: amapText(tip.name),
|
||||
secondaryText: [amapText(tip.district), amapText(tip.address)].filter(Boolean).join(' '),
|
||||
}));
|
||||
}
|
||||
|
||||
async placeDetails(placeId: string, opts: DetailsOptions = {}): Promise<ProviderPlace | null> {
|
||||
const poiId = amapPoiId(placeId);
|
||||
if (!poiId) return null;
|
||||
|
||||
// v5 rather than v3: v3's place/detail is deprecated and does not return the
|
||||
// business block (rating, phone, opening hours) that makes a details lookup
|
||||
// worth making. `show_fields` is v5's field mask — asking for everything
|
||||
// costs no more, but photos are left out on purpose (see the file header).
|
||||
const data = await this.call<AmapEnvelope & { pois?: AmapPoi[] }>(
|
||||
'/v5/place/detail',
|
||||
{
|
||||
id: poiId,
|
||||
show_fields: 'business,indoor',
|
||||
language: toAmapLang(opts.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;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reverse geocoding via `geocode/regeo`.
|
||||
*
|
||||
* Worth having beyond the datum: the OSM path funnels through a single
|
||||
* process-wide Nominatim throttle of one request every 1.1 s (Nominatim's
|
||||
* usage policy), so a booking import that geocodes thirty stops takes half a
|
||||
* minute of wall clock. Amap has no such rule, so an install on Amap gets its
|
||||
* imports back at network speed.
|
||||
*/
|
||||
async reverse(lat: number, lng: number, lang?: string): Promise<{ name: string | null; address: string | null } | null> {
|
||||
const data = await this.call<
|
||||
AmapEnvelope & {
|
||||
regeocode?: {
|
||||
formatted_address?: unknown;
|
||||
addressComponent?: { building?: { name?: unknown }; neighborhood?: { name?: unknown } };
|
||||
pois?: { name?: unknown }[];
|
||||
aois?: { name?: unknown }[];
|
||||
};
|
||||
}
|
||||
>(
|
||||
'/v3/geocode/regeo',
|
||||
{
|
||||
location: toAmapLocation(lat, lng),
|
||||
extensions: 'all',
|
||||
// 18 is street-address detail, matching the zoom the Nominatim path asks for.
|
||||
radius: '200',
|
||||
language: toAmapLang(lang),
|
||||
},
|
||||
'geocode/regeo',
|
||||
);
|
||||
|
||||
const regeocode = data.regeocode;
|
||||
if (!regeocode) return null;
|
||||
|
||||
// Same precedence as the Nominatim path: the most specific name Amap knows,
|
||||
// falling back through the containing area. An AOI ("故宫博物院") beats a POI
|
||||
// here because the click landed inside it rather than on it.
|
||||
const name =
|
||||
amapText(regeocode.aois?.[0]?.name) ||
|
||||
amapText(regeocode.pois?.[0]?.name) ||
|
||||
amapText(regeocode.addressComponent?.building?.name) ||
|
||||
amapText(regeocode.addressComponent?.neighborhood?.name) ||
|
||||
null;
|
||||
|
||||
return { name, address: amapText(regeocode.formatted_address) || null };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Coordinates and a name out of a shared Amap link.
|
||||
*
|
||||
* The formats people paste, in the order they are tried:
|
||||
* - `uri.amap.com/marker?position=<lng>,<lat>&name=…` — what our own "open in
|
||||
* Amap" links look like, and what the mobile app's share sheet produces
|
||||
* - `amap.com/place/<id>` or `…/detail?id=<id>` — a POI page, whose id we can
|
||||
* look up when a key is configured
|
||||
* - `?p=…` / `#…` viewport fragments carrying a bare `lng,lat`
|
||||
*
|
||||
* Returned coordinates are WGS-84; the link carries GCJ-02.
|
||||
*/
|
||||
export function parseAmapUrl(rawUrl: string): { lat: number; lng: number; name: string | null; poiId: string | null } | null {
|
||||
let parsed: URL;
|
||||
try {
|
||||
parsed = new URL(rawUrl);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (!isAmapHost(parsed.hostname)) return null;
|
||||
|
||||
const name = parsed.searchParams.get('name')?.trim() || null;
|
||||
const poiId = parsed.searchParams.get('id')?.trim() || parsed.pathname.match(/\/place\/([A-Za-z0-9]+)/)?.[1] || null;
|
||||
|
||||
// `position=lng,lat` (uri.amap.com) and `p=…,lng,lat,…` (the web viewport) both
|
||||
// spell longitude first, like every other Amap coordinate.
|
||||
const position = parsed.searchParams.get('position') || parsed.searchParams.get('location');
|
||||
const fromPosition = position ? fromAmapLocation(position) : null;
|
||||
if (fromPosition) return { ...fromPosition, name, poiId };
|
||||
|
||||
const anywhere = /(\d{2,3}\.\d+),(\d{1,2}\.\d+)/.exec(`${parsed.search}${parsed.hash}`);
|
||||
if (anywhere) {
|
||||
const converted = gcj02ToWgs84(Number.parseFloat(anywhere[2]), Number.parseFloat(anywhere[1]));
|
||||
return { ...converted, name, poiId };
|
||||
}
|
||||
|
||||
return poiId ? { lat: Number.NaN, lng: Number.NaN, name, poiId } : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Amap's own hosts, including the short-link one.
|
||||
*
|
||||
* Matched by exact suffix rather than by shape: unlike Google, Amap has no
|
||||
* ccTLD family, so a pattern would only widen what counts as trusted.
|
||||
*/
|
||||
const AMAP_HOSTS = new Set([
|
||||
'amap.com',
|
||||
'www.amap.com',
|
||||
'uri.amap.com',
|
||||
'wb.amap.com',
|
||||
'surl.amap.com',
|
||||
'gaode.com',
|
||||
'www.gaode.com',
|
||||
]);
|
||||
|
||||
export function isAmapHost(hostname: string): boolean {
|
||||
return AMAP_HOSTS.has(hostname.toLowerCase());
|
||||
}
|
||||
|
||||
/** The short links that have to be followed before anything can be parsed out. */
|
||||
export const AMAP_SHORT_HOSTS = ['surl.amap.com'];
|
||||
@@ -0,0 +1,148 @@
|
||||
/**
|
||||
* The 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.
|
||||
*
|
||||
* 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.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
import type { ApiKeySource } from '../../settings/instance-api-keys';
|
||||
|
||||
/** The keyed providers. OpenStreetMap is absent on purpose — see the file header. */
|
||||
export type PlacesProviderId = 'google' | 'amap';
|
||||
|
||||
/**
|
||||
* What an admin can choose. `auto` keeps whatever the install already used:
|
||||
* Google when a Google key is configured, Amap when only an Amap key is, and
|
||||
* the OSM stack when neither is. Existing installs must not change provider
|
||||
* because a new one became available, so `auto` prefers the incumbent.
|
||||
*/
|
||||
export type PlacesProviderChoice = 'auto' | PlacesProviderId | 'openstreetmap';
|
||||
|
||||
export const PLACES_PROVIDER_CHOICES: readonly PlacesProviderChoice[] = [
|
||||
'auto',
|
||||
'google',
|
||||
'amap',
|
||||
'openstreetmap',
|
||||
];
|
||||
|
||||
export function isPlacesProviderChoice(value: unknown): value is PlacesProviderChoice {
|
||||
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. */
|
||||
export type ProviderPlace = Record<string, unknown>;
|
||||
|
||||
export interface ProviderSuggestion {
|
||||
placeId: string;
|
||||
mainText: string;
|
||||
secondaryText: string;
|
||||
}
|
||||
|
||||
/** Bias a text search toward a point. Radius in metres; WGS-84. */
|
||||
export interface SearchBias {
|
||||
lat: number;
|
||||
lng: number;
|
||||
radius?: number;
|
||||
}
|
||||
|
||||
/** Bias autocomplete toward a viewport rectangle. WGS-84. */
|
||||
export interface ViewportBias {
|
||||
low: { lat: number; lng: number };
|
||||
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.
|
||||
*/
|
||||
export interface ProviderCredential {
|
||||
key: string;
|
||||
source: ApiKeySource | null;
|
||||
/** Whose request this is; 0 for an unauthenticated read. */
|
||||
userId: number;
|
||||
}
|
||||
|
||||
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[]>;
|
||||
|
||||
/** Null when the provider has no such place — a miss, not an error. */
|
||||
placeDetails(placeId: string, opts?: DetailsOptions): 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>;
|
||||
}
|
||||
@@ -18,10 +18,14 @@ import type { ApiKeySource } from '@trek/shared';
|
||||
* apiKeyCrypto, so the format matches what the users columns already hold and
|
||||
* a legacy plaintext value still reads back.
|
||||
*/
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key';
|
||||
export type InstanceApiKeyName = 'maps_api_key' | 'unsplash_api_key' | 'amap_api_key';
|
||||
|
||||
/** Instance names whose per-user column is still honoured as a last resort. */
|
||||
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = ['maps_api_key', 'unsplash_api_key'];
|
||||
export const INSTANCE_API_KEY_NAMES: readonly InstanceApiKeyName[] = [
|
||||
'maps_api_key',
|
||||
'unsplash_api_key',
|
||||
'amap_api_key',
|
||||
];
|
||||
|
||||
/**
|
||||
* Where a resolved key came from. Logged beside a provider error so "works for
|
||||
@@ -39,6 +43,7 @@ export type { ApiKeySource };
|
||||
const USER_ROW_SQL: Record<InstanceApiKeyName, string> = {
|
||||
maps_api_key: 'SELECT maps_api_key FROM users WHERE id = ?',
|
||||
unsplash_api_key: 'SELECT unsplash_api_key FROM users WHERE id = ?',
|
||||
amap_api_key: 'SELECT amap_api_key FROM users WHERE id = ?',
|
||||
};
|
||||
|
||||
/** The instance-wide value in cleartext, or null when unset/cleared. */
|
||||
|
||||
@@ -8,6 +8,8 @@ export interface User {
|
||||
password_hash?: string;
|
||||
maps_api_key?: string | null;
|
||||
unsplash_api_key?: string | null;
|
||||
/** Amap (高德) web-service key — the per-user fallback for the Amap provider. */
|
||||
amap_api_key?: string | null;
|
||||
openweather_api_key?: string | null;
|
||||
avatar?: string | null;
|
||||
oidc_sub?: string | null;
|
||||
|
||||
@@ -51,6 +51,7 @@ describe('managed key assignment', () => {
|
||||
|
||||
it('MANAGED-KEYS-005: the locked list is pinned verbatim', () => {
|
||||
expect([...MANAGED_LOCKED_SETTING_KEYS]).toEqual([
|
||||
'amap_api_key',
|
||||
'carto_api_key',
|
||||
'llm_api_key',
|
||||
'llm_base_url',
|
||||
|
||||
@@ -0,0 +1,572 @@
|
||||
/**
|
||||
* Unit tests for the Amap (高德) places provider and the provider selection it
|
||||
* plugs into — AMAP-001 onwards.
|
||||
*
|
||||
* What these are really about is the two things that make Amap different from
|
||||
* Google and that no amount of care at the call site can fix: the datum (GCJ-02
|
||||
* in, WGS-84 out, in BOTH directions) and the fact that Amap reports failure in
|
||||
* a 200 response body. Everything else is field mapping, which is checked
|
||||
* against the shapes Amap actually sends — including `address: []` for a missing
|
||||
* string, which is the one that bites.
|
||||
*
|
||||
* fetch is stubbed; the SSRF guard and the database are mocked, the same way
|
||||
* maps.service.test.ts does it.
|
||||
*/
|
||||
import { describe, it, expect, vi, afterEach } from 'vitest';
|
||||
import { gcj02ToWgs84, wgs84ToGcj02 } from '@trek/shared';
|
||||
|
||||
const { mockDbGet, mockDbRun, mockInstanceGet, mockProviderGet } = vi.hoisted(() => ({
|
||||
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
mockDbRun: vi.fn(),
|
||||
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/db/database', () => ({
|
||||
db: {
|
||||
prepare: (sql: string) => ({
|
||||
get: (...args: unknown[]) => {
|
||||
if (!sql.includes('app_settings')) return mockDbGet(...args);
|
||||
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
|
||||
},
|
||||
all: vi.fn(() => []),
|
||||
run: mockDbRun,
|
||||
}),
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/utils/ssrfGuard', () => {
|
||||
class SsrfBlockedError extends Error {}
|
||||
return {
|
||||
SsrfBlockedError,
|
||||
checkSsrf: vi.fn(async () => ({ allowed: true })),
|
||||
safeFetchFollow: vi.fn(async (url: string, init?: any) => (globalThis.fetch as any)(url, init)),
|
||||
};
|
||||
});
|
||||
|
||||
vi.mock('../../../src/nest/common/crypto/apiKeyCrypto', () => ({
|
||||
decrypt_api_key: (v: string | null) => v,
|
||||
maybe_encrypt_api_key: (v: string | null) => v,
|
||||
}));
|
||||
|
||||
vi.mock('../../../src/config', () => ({ JWT_SECRET: 'test-secret', ENCRYPTION_KEY: '0'.repeat(64) }));
|
||||
|
||||
import { db } from '../../../src/db/database';
|
||||
import { DatabaseService } from '../../../src/nest/database/database.service';
|
||||
import { MapsService } from '../../../src/nest/maps/maps.service';
|
||||
import {
|
||||
AmapPlacesProvider,
|
||||
amapPoiId,
|
||||
isAmapHost,
|
||||
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';
|
||||
|
||||
const photoCacheStub = {
|
||||
get: vi.fn(() => null),
|
||||
getErrored: vi.fn(() => false),
|
||||
put: vi.fn(),
|
||||
markError: vi.fn(),
|
||||
getInFlight: vi.fn(() => undefined),
|
||||
setInFlight: vi.fn(),
|
||||
serveKey: vi.fn(() => null),
|
||||
} as unknown as PlacePhotoCacheService;
|
||||
|
||||
const svc = new MapsService(new DatabaseService(db as never), photoCacheStub);
|
||||
|
||||
/** A provider over a fixed key, which is all these cases need. */
|
||||
function provider(): AmapPlacesProvider {
|
||||
return new AmapPlacesProvider({ key: 'amap-test-key', source: 'instance', userId: 3 });
|
||||
}
|
||||
|
||||
/** Amap's success envelope. */
|
||||
function ok(body: Record<string, unknown>) {
|
||||
return { ok: true, status: 200, json: async () => ({ status: '1', info: 'OK', infocode: '10000', ...body }) };
|
||||
}
|
||||
|
||||
/** Amap's failure envelope — note the HTTP 200. */
|
||||
function amapError(infocode: string, info = 'SOMETHING_WRONG') {
|
||||
return { ok: true, status: 200, json: async () => ({ status: '0', info, infocode }) };
|
||||
}
|
||||
|
||||
/** The URL the single stubbed call was made with. */
|
||||
function calledUrl(): string {
|
||||
return String((globalThis.fetch as any).mock.calls[0][0]);
|
||||
}
|
||||
|
||||
/** One POI in Beijing, at a GCJ-02 coordinate. */
|
||||
const TIANANMEN_GCJ = wgs84ToGcj02(39.90869, 116.39124);
|
||||
const TIANANMEN_LOCATION = `${TIANANMEN_GCJ.lng.toFixed(6)},${TIANANMEN_GCJ.lat.toFixed(6)}`;
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
mockDbGet.mockReset();
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockDbRun.mockReset();
|
||||
mockInstanceGet.mockReset();
|
||||
mockInstanceGet.mockReturnValue(undefined);
|
||||
mockProviderGet.mockReset();
|
||||
mockProviderGet.mockReturnValue(undefined);
|
||||
});
|
||||
|
||||
// ── Place id namespace ───────────────────────────────────────────────────────
|
||||
|
||||
describe('Amap place ids', () => {
|
||||
it('AMAP-001: namespaces an Amap id so it can never be sent to Google', () => {
|
||||
expect(isAmapPlaceId('amap:B000A83M61')).toBe(true);
|
||||
expect(amapPoiId('amap:B000A83M61')).toBe('B000A83M61');
|
||||
// The whole point: Google's own id check must reject it, because Google
|
||||
// answers a foreign id with a BILLABLE 400 INVALID_ARGUMENT.
|
||||
expect(isGooglePlaceId('amap:B000A83M61')).toBe(false);
|
||||
// And a bare Amap id, which is what we would have had without the prefix,
|
||||
// is indistinguishable from a Google one — hence the prefix.
|
||||
expect(isGooglePlaceId('B000A83M61')).toBe(true);
|
||||
});
|
||||
|
||||
it('AMAP-002: is not confused with an OSM id or a coordinate pseudo-id', () => {
|
||||
expect(isAmapPlaceId('node:240109189')).toBe(false);
|
||||
expect(isAmapPlaceId('coords:39.9,116.4')).toBe(false);
|
||||
expect(isAmapPlaceId('ChIJ_____')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── searchText ───────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AmapPlacesProvider.searchText', () => {
|
||||
it('AMAP-010: converts the returned GCJ-02 coordinate back to WGS-84', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({
|
||||
pois: [
|
||||
{
|
||||
id: 'B000A83M61',
|
||||
name: '天安门',
|
||||
address: '东长安街',
|
||||
location: TIANANMEN_LOCATION,
|
||||
pname: '北京市',
|
||||
cityname: '北京市',
|
||||
adname: '东城区',
|
||||
type: '风景名胜;风景名胜相关;旅游景点',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const [place] = await provider().searchText('天安门');
|
||||
|
||||
// Back within a metre of where it started, i.e. the datum round-tripped.
|
||||
expect(place.lat as number).toBeCloseTo(39.90869, 4);
|
||||
expect(place.lng as number).toBeCloseTo(116.39124, 4);
|
||||
expect(place.amap_poi_id).toBe('amap:B000A83M61');
|
||||
expect(place.source).toBe('amap');
|
||||
expect(place.types).toEqual(['风景名胜', '风景名胜相关', '旅游景点']);
|
||||
});
|
||||
|
||||
it('AMAP-011: assembles a full address without repeating a municipality', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({
|
||||
pois: [
|
||||
{
|
||||
id: 'B1',
|
||||
name: '故宫',
|
||||
address: '景山前街4号',
|
||||
location: TIANANMEN_LOCATION,
|
||||
pname: '北京市',
|
||||
cityname: '北京市',
|
||||
adname: '东城区',
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [place] = await provider().searchText('故宫');
|
||||
expect(place.address).toBe('北京市东城区景山前街4号');
|
||||
});
|
||||
|
||||
it('AMAP-012: survives the empty arrays Amap sends for absent string fields', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({
|
||||
pois: [
|
||||
{
|
||||
id: 'B2',
|
||||
name: '某小店',
|
||||
// These are what Amap really returns when the field has no value —
|
||||
// not undefined, and not '': an empty ARRAY.
|
||||
address: [],
|
||||
tel: [],
|
||||
website: [],
|
||||
location: TIANANMEN_LOCATION,
|
||||
pname: [],
|
||||
cityname: [],
|
||||
adname: [],
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
const [place] = await provider().searchText('某小店');
|
||||
// Not "[]" and not "[object Object]" — the failure this coercion prevents.
|
||||
expect(place.address).toBe('');
|
||||
expect(place.phone).toBeNull();
|
||||
expect(place.website).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-013: uses place/around with a bias, because text search cannot be biased', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
||||
await provider().searchText('咖啡', 'zh', { lat: 31.2304, lng: 121.4737, radius: 3000 });
|
||||
|
||||
const url = calledUrl();
|
||||
expect(url).toContain('/v3/place/around');
|
||||
expect(url).toContain('radius=3000');
|
||||
// The outbound coordinate is GCJ-02, i.e. NOT the WGS-84 value we passed in.
|
||||
const location = new URL(url).searchParams.get('location')!;
|
||||
const [lng, lat] = location.split(',').map(Number);
|
||||
const back = gcj02ToWgs84(lat, lng);
|
||||
expect(back.lat).toBeCloseTo(31.2304, 4);
|
||||
expect(back.lng).toBeCloseTo(121.4737, 4);
|
||||
expect(lat).not.toBeCloseTo(31.2304, 4);
|
||||
});
|
||||
|
||||
it('AMAP-014: clamps the radius to the 50 km Amap accepts', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
||||
await provider().searchText('x', undefined, { lat: 31.2304, lng: 121.4737, radius: 900000 });
|
||||
expect(calledUrl()).toContain('radius=50000');
|
||||
});
|
||||
|
||||
it('AMAP-015: uses place/text without a bias', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
||||
await provider().searchText('外滩');
|
||||
expect(calledUrl()).toContain('/v3/place/text');
|
||||
});
|
||||
|
||||
it('AMAP-016: keeps a POI that has no geometry rather than dropping it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B3', name: '无坐标', location: [] }] })));
|
||||
const [place] = await provider().searchText('无坐标');
|
||||
expect(place.lat).toBeNull();
|
||||
expect(place.lng).toBeNull();
|
||||
expect(place.name).toBe('无坐标');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Error translation ────────────────────────────────────────────────────────
|
||||
|
||||
describe('Amap error handling', () => {
|
||||
it('AMAP-020: turns an invalid-key body into a 403, despite the HTTP 200', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10001', 'INVALID_USER_KEY')));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
|
||||
// The hint has to name what an admin can change; Amap's own wording does not.
|
||||
await expect(provider().searchText('x')).rejects.toThrow(/Web 服务/);
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('AMAP-021: turns quota exhaustion into a 429', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10003')));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 429 });
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('AMAP-022: reports the key source and the user, never the key', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('10009')));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 403 });
|
||||
const logged = errorSpy.mock.calls.map((c) => String(c[0])).join('\n');
|
||||
expect(logged).toContain('keySource=instance');
|
||||
expect(logged).toContain('userId=3');
|
||||
expect(logged).not.toContain('amap-test-key');
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('AMAP-023: an unknown infocode is a 502 and keeps Amap\u2019s own wording', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(amapError('20800', 'OUT_OF_SERVICE')));
|
||||
const errorSpy = vi.spyOn(console, 'error').mockImplementation(() => {});
|
||||
await expect(provider().searchText('x')).rejects.toMatchObject({ status: 502, message: /OUT_OF_SERVICE/ });
|
||||
errorSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
|
||||
// ── autocomplete ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AmapPlacesProvider.autocomplete', () => {
|
||||
it('AMAP-030: namespaces every suggestion id and caps the list at five', async () => {
|
||||
const tips = Array.from({ length: 8 }, (_, i) => ({
|
||||
id: `T${i}`,
|
||||
name: `候选${i}`,
|
||||
district: '北京市东城区',
|
||||
address: '某路',
|
||||
}));
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips })));
|
||||
|
||||
const suggestions = await provider().autocomplete('候选');
|
||||
expect(suggestions).toHaveLength(5);
|
||||
expect(suggestions[0].placeId).toBe('amap:T0');
|
||||
expect(suggestions[0].mainText).toBe('候选0');
|
||||
expect(suggestions[0].secondaryText).toBe('北京市东城区 某路');
|
||||
expect(calledUrl()).toContain('/v3/assistant/inputtips');
|
||||
});
|
||||
|
||||
it('AMAP-031: drops a tip with no id, which cannot be looked up afterwards', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ok({ tips: [{ name: '一条路', id: [] }, { id: 'T9', name: '有效的' }] })),
|
||||
);
|
||||
const suggestions = await provider().autocomplete('路');
|
||||
expect(suggestions).toHaveLength(1);
|
||||
expect(suggestions[0].placeId).toBe('amap:T9');
|
||||
});
|
||||
|
||||
it('AMAP-032: biases around the centre of the viewport, in GCJ-02', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ tips: [] })));
|
||||
await provider().autocomplete('咖啡', 'zh', {
|
||||
low: { lat: 31.2, lng: 121.4 },
|
||||
high: { lat: 31.3, lng: 121.5 },
|
||||
});
|
||||
const location = new URL(calledUrl()).searchParams.get('location')!;
|
||||
const [lng, lat] = location.split(',').map(Number);
|
||||
const back = gcj02ToWgs84(lat, lng);
|
||||
expect(back.lat).toBeCloseTo(31.25, 3);
|
||||
expect(back.lng).toBeCloseTo(121.45, 3);
|
||||
});
|
||||
});
|
||||
|
||||
// ── placeDetails ─────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AmapPlacesProvider.placeDetails', () => {
|
||||
it('AMAP-040: looks up the bare poi id via v5 and returns WGS-84', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({
|
||||
pois: [
|
||||
{
|
||||
id: 'B000A83M61',
|
||||
name: '天安门',
|
||||
address: '东长安街',
|
||||
location: TIANANMEN_LOCATION,
|
||||
business: { rating: '4.8', tel: '010-12345678' },
|
||||
},
|
||||
],
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const place = await provider().placeDetails('amap:B000A83M61');
|
||||
const url = calledUrl();
|
||||
expect(url).toContain('/v5/place/detail');
|
||||
// The prefix is ours, not Amap's — it must not reach the API.
|
||||
expect(new URL(url).searchParams.get('id')).toBe('B000A83M61');
|
||||
expect(place!.rating).toBe(4.8);
|
||||
expect(place!.phone).toBe('010-12345678');
|
||||
expect(place!.lat as number).toBeCloseTo(39.90869, 4);
|
||||
});
|
||||
|
||||
it('AMAP-041: returns null for an id that is not an Amap id, without calling out', async () => {
|
||||
const fetchSpy = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchSpy);
|
||||
expect(await provider().placeDetails('ChIJsomething')).toBeNull();
|
||||
expect(fetchSpy).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('AMAP-042: returns null when Amap knows the id but has no POI for it', async () => {
|
||||
vi.stubGlobal('fetch', vi.fn().mockResolvedValue(ok({ pois: [] })));
|
||||
expect(await provider().placeDetails('amap:B000A83M61')).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
// ── reverse ──────────────────────────────────────────────────────────────────
|
||||
|
||||
describe('AmapPlacesProvider.reverse', () => {
|
||||
it('AMAP-050: sends GCJ-02 and prefers the AOI the click landed inside', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({
|
||||
regeocode: {
|
||||
formatted_address: '北京市东城区天安门广场',
|
||||
aois: [{ name: '天安门广场' }],
|
||||
pois: [{ name: '国旗杆' }],
|
||||
addressComponent: { building: { name: '某楼' } },
|
||||
},
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
const answer = await provider().reverse(39.90869, 116.39124);
|
||||
expect(answer).toEqual({ name: '天安门广场', address: '北京市东城区天安门广场' });
|
||||
const location = new URL(calledUrl()).searchParams.get('location')!;
|
||||
const [lng, lat] = location.split(',').map(Number);
|
||||
expect(gcj02ToWgs84(lat, lng).lat).toBeCloseTo(39.90869, 4);
|
||||
});
|
||||
|
||||
it('AMAP-051: falls through the name candidates when there is no AOI', async () => {
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(
|
||||
ok({ regeocode: { formatted_address: '某地址', aois: [], pois: [], addressComponent: { neighborhood: { name: '某社区' } } } }),
|
||||
),
|
||||
);
|
||||
expect((await provider().reverse(39.9, 116.4))!.name).toBe('某社区');
|
||||
});
|
||||
});
|
||||
|
||||
// ── Shared-link parsing ──────────────────────────────────────────────────────
|
||||
|
||||
describe('parseAmapUrl', () => {
|
||||
it('AMAP-060: reads uri.amap.com marker links as lng,lat in GCJ-02', () => {
|
||||
const parsed = parseAmapUrl(`https://uri.amap.com/marker?position=${TIANANMEN_LOCATION}&name=%E5%A4%A9%E5%AE%89%E9%97%A8`);
|
||||
expect(parsed).not.toBeNull();
|
||||
// Longitude first AND a datum shift: reading this link the Google way would
|
||||
// put a Beijing landmark in the Indian Ocean.
|
||||
expect(parsed!.lat).toBeCloseTo(39.90869, 4);
|
||||
expect(parsed!.lng).toBeCloseTo(116.39124, 4);
|
||||
expect(parsed!.name).toBe('天安门');
|
||||
});
|
||||
|
||||
it('AMAP-061: picks the poi id out of a place page', () => {
|
||||
const parsed = parseAmapUrl('https://www.amap.com/place/B000A83M61');
|
||||
expect(parsed!.poiId).toBe('B000A83M61');
|
||||
expect(Number.isNaN(parsed!.lat)).toBe(true);
|
||||
});
|
||||
|
||||
it('AMAP-062: refuses a host that is not Amap', () => {
|
||||
expect(parseAmapUrl('https://amap.com.evil.example/marker?position=116,39')).toBeNull();
|
||||
expect(parseAmapUrl('not a url')).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-063: recognises only Amap\u2019s own hosts', () => {
|
||||
expect(isAmapHost('uri.amap.com')).toBe(true);
|
||||
expect(isAmapHost('www.amap.com')).toBe(true);
|
||||
expect(isAmapHost('surl.amap.com')).toBe(true);
|
||||
expect(isAmapHost('amap.com.attacker.net')).toBe(false);
|
||||
expect(isAmapHost('notamap.com')).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
// ── 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;
|
||||
});
|
||||
}
|
||||
|
||||
it('AMAP-070: auto keeps Google when a Google key is configured', () => {
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(GooglePlacesProvider);
|
||||
});
|
||||
|
||||
it('AMAP-071: auto falls to Amap only when there is no Google key', () => {
|
||||
keys({ amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
|
||||
});
|
||||
|
||||
it('AMAP-072: auto with no key at all means the OpenStreetMap stack', () => {
|
||||
keys({});
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-073: an explicit amap choice wins over a configured Google key', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(1)).toBeInstanceOf(AmapPlacesProvider);
|
||||
});
|
||||
|
||||
it('AMAP-074: an explicit google choice never silently uses Amap instead', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'google' });
|
||||
keys({ amap: 'akey' });
|
||||
// Misconfigured means "answer with OSM", not "bill somebody else's provider".
|
||||
expect(svc.resolvePlacesProvider(1)).toBeNull();
|
||||
});
|
||||
|
||||
it('AMAP-075: openstreetmap ignores both keys', () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'openstreetmap' });
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
expect(svc.resolvePlacesProvider(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);
|
||||
});
|
||||
|
||||
it('AMAP-077: an Amap place stays with Amap even while Google is selected', async () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'google' });
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '旧地点', location: TIANANMEN_LOCATION }] })),
|
||||
);
|
||||
|
||||
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
|
||||
// Reached Amap's endpoint, not Google's — an id outlives the setting.
|
||||
expect(calledUrl()).toContain('restapi.amap.com');
|
||||
expect(place!.name).toBe('旧地点');
|
||||
});
|
||||
|
||||
it('AMAP-077b: a Google place still opens after the admin switches to Amap', async () => {
|
||||
// The mirror of AMAP-077, and the one that matters on a real migration: an
|
||||
// install with a year of google_place_id rows switches provider, and every
|
||||
// one of those places has to keep opening against the Google key that is
|
||||
// still configured.
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
keys({ google: 'gkey', amap: 'akey' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({ id: 'ChIJ123', displayName: { text: 'Old Google place' }, location: { latitude: 48.8, longitude: 2.3 } }),
|
||||
}),
|
||||
);
|
||||
|
||||
const { place } = await svc.getPlaceDetails(1, 'ChIJ123');
|
||||
expect(calledUrl()).toContain('places.googleapis.com');
|
||||
expect(place!.name).toBe('Old Google place');
|
||||
});
|
||||
|
||||
it('AMAP-078: an Amap id is never mistaken for an OSM type:id pair', async () => {
|
||||
// `amap:B9` contains a colon, like `node:123`. Read as OSM it would go to
|
||||
// Overpass as element type "amap" and answer every Chinese place with a
|
||||
// blank record — so the ordering of those two branches is load-bearing.
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
keys({ amap: 'akey' });
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B9', name: '天安门', location: TIANANMEN_LOCATION }] })),
|
||||
);
|
||||
const { place } = await svc.getPlaceDetails(1, 'amap:B9');
|
||||
expect(place!.source).toBe('amap');
|
||||
expect(calledUrl()).not.toContain('overpass');
|
||||
});
|
||||
});
|
||||
|
||||
describe('MapsService.searchPlaces with Amap selected', () => {
|
||||
it('AMAP-080: reports amap as the source so the client can credit it', async () => {
|
||||
mockProviderGet.mockReturnValue({ value: 'amap' });
|
||||
mockDbGet.mockReturnValue({ maps_api_key: null, amap_api_key: 'akey' } as any);
|
||||
vi.stubGlobal(
|
||||
'fetch',
|
||||
vi.fn().mockResolvedValue(ok({ pois: [{ id: 'B1', name: '外滩', location: TIANANMEN_LOCATION }] })),
|
||||
);
|
||||
|
||||
const result = await svc.searchPlaces(1, '外滩');
|
||||
expect(result.source).toBe('amap');
|
||||
expect(result.places).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
@@ -51,6 +51,7 @@ const {
|
||||
mockCacheGetInFlight,
|
||||
mockCacheSetInFlight,
|
||||
mockServeFilePath,
|
||||
mockProviderGet,
|
||||
} = vi.hoisted(() => ({
|
||||
mockDbGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
mockDbRun: vi.fn(),
|
||||
@@ -59,6 +60,12 @@ const {
|
||||
// row holds a key", and would otherwise have its stub eaten by the
|
||||
// app_settings lookup.
|
||||
mockInstanceGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
// The places_provider row is read before the key chain, so it needs a seam of
|
||||
// its own: routed through mockInstanceGet it would eat the mockReturnValueOnce
|
||||
// that a case meant for the instance-wide KEY, and the service would resolve a
|
||||
// key value as a provider name. Default undefined = the 'auto' choice, which is
|
||||
// what every install that predates Amap has.
|
||||
mockProviderGet: vi.fn((..._args: unknown[]) => undefined as any),
|
||||
preparedSql: [] as string[],
|
||||
mockCheckSsrf: vi.fn(async (_url: string, _bypassInternalIpAllowed?: boolean): Promise<SsrfCheckStub> => ({
|
||||
allowed: true,
|
||||
@@ -83,7 +90,12 @@ vi.mock('../../../src/db/database', () => ({
|
||||
prepare: (sql: string) => {
|
||||
preparedSql.push(sql);
|
||||
return {
|
||||
get: (...args: unknown[]) => (sql.includes('app_settings') ? mockInstanceGet(...args) : mockDbGet(...args)),
|
||||
get: (...args: unknown[]) => {
|
||||
if (!sql.includes('app_settings')) return mockDbGet(...args);
|
||||
// Keyed by the bound parameter, not just by the table: app_settings
|
||||
// holds both the instance-wide API keys and the provider choice.
|
||||
return args[0] === 'places_provider' ? mockProviderGet(...args) : mockInstanceGet(...args);
|
||||
},
|
||||
all: vi.fn(() => []),
|
||||
run: mockDbRun,
|
||||
};
|
||||
@@ -195,6 +207,8 @@ afterEach(() => {
|
||||
mockDbGet.mockReturnValue(undefined);
|
||||
mockInstanceGet.mockReset();
|
||||
mockInstanceGet.mockReturnValue(undefined);
|
||||
mockProviderGet.mockReset();
|
||||
mockProviderGet.mockReturnValue(undefined);
|
||||
preparedSql.length = 0;
|
||||
mockDbRun.mockReset();
|
||||
mockCheckSsrf.mockReset();
|
||||
|
||||
Reference in New Issue
Block a user