From 4c6215b2339f9e957df90042ae702b6286039225 Mon Sep 17 00:00:00 2001 From: jubnl Date: Sat, 11 Jul 2026 15:43:02 +0800 Subject: [PATCH] feat: add amap coordinate boundary --- shared/src/index.ts | 2 + shared/src/maps/amap.schema.spec.ts | 44 +++++++++++++++ shared/src/maps/amap.schema.ts | 73 +++++++++++++++++++++++++ shared/src/maps/coordinates.spec.ts | 26 +++++++++ shared/src/maps/coordinates.ts | 84 +++++++++++++++++++++++++++++ shared/src/maps/maps.schema.ts | 9 ++-- 6 files changed, 232 insertions(+), 6 deletions(-) create mode 100644 shared/src/maps/amap.schema.spec.ts create mode 100644 shared/src/maps/amap.schema.ts create mode 100644 shared/src/maps/coordinates.spec.ts create mode 100644 shared/src/maps/coordinates.ts diff --git a/shared/src/index.ts b/shared/src/index.ts index d3ed90de..f50f40c7 100644 --- a/shared/src/index.ts +++ b/shared/src/index.ts @@ -16,6 +16,8 @@ export * from './weather/weather.schema'; export * from './airport/airport.schema'; export * from './config/config.schema'; export * from './system-notice/system-notice.schema'; +export * from './maps/amap.schema'; +export * from './maps/coordinates'; export * from './maps/maps.schema'; export * from './category/category.schema'; export * from './tag/tag.schema'; diff --git a/shared/src/maps/amap.schema.spec.ts b/shared/src/maps/amap.schema.spec.ts new file mode 100644 index 00000000..e5c83aa3 --- /dev/null +++ b/shared/src/maps/amap.schema.spec.ts @@ -0,0 +1,44 @@ +import { describe, expect, it } from 'vitest'; + +import { amapPlaceSchema, routePlanRequestSchema, routePlanResultSchema } from './amap.schema'; + +describe('amap shared contracts', () => { + it('keeps places provider-neutral at the package boundary', () => { + const parsed = amapPlaceSchema.parse({ + id: 'amap:poi:B000A83M61', + name: 'Tiananmen', + lat: 39.908823, + lng: 116.39747, + address: 'Beijing', + categories: ['landmark'], + provider: 'amap', + }); + + expect(parsed).toMatchObject({ id: 'amap:poi:B000A83M61', provider: 'amap' }); + }); + + it('accepts WGS-84 route requests with at least two points', () => { + expect( + routePlanRequestSchema.safeParse({ + mode: 'driving', + points: [ + { lat: 39.908823, lng: 116.39747 }, + { lat: 39.999, lng: 116.39 }, + ], + }).success, + ).toBe(true); + expect(routePlanRequestSchema.safeParse({ mode: 'driving', points: [{ lat: 39.908823, lng: 116.39747 }] }).success).toBe( + false, + ); + }); + + it('requires explicit unsupported route states instead of fake geometry', () => { + expect( + routePlanResultSchema.safeParse({ + supported: false, + reason: 'AMAP_UNSUPPORTED_AREA', + }).success, + ).toBe(true); + expect(routePlanResultSchema.safeParse({ supported: false, coordinates: [] }).success).toBe(false); + }); +}); diff --git a/shared/src/maps/amap.schema.ts b/shared/src/maps/amap.schema.ts new file mode 100644 index 00000000..d7727d83 --- /dev/null +++ b/shared/src/maps/amap.schema.ts @@ -0,0 +1,73 @@ +import { z } from 'zod'; + +export const wgs84PointSchema = z + .object({ + lat: z.number().finite(), + lng: z.number().finite(), + }) + .strict(); + +export const amapPlaceSchema = z + .object({ + id: z.string().min(1), + name: z.string().min(1), + lat: z.number().finite(), + lng: z.number().finite(), + address: z.string().nullable().optional(), + categories: z.array(z.string()).default([]), + provider: z.literal('amap'), + }) + .strict(); +export type AmapPlace = z.infer; + +export const routeModeSchema = z.enum(['driving', 'walking', 'cycling', 'transit']); +export type RouteMode = z.infer; + +export const routePlanRequestSchema = z + .object({ + mode: routeModeSchema, + points: z.array(wgs84PointSchema).min(2), + }) + .strict(); +export type RoutePlanRequest = z.infer; + +export const routeLegSchema = z + .object({ + mode: routeModeSchema, + distanceMeters: z.number().nonnegative(), + durationSeconds: z.number().nonnegative().nullable().optional(), + coordinates: z.array(wgs84PointSchema).min(2), + instruction: z.string().nullable().optional(), + }) + .strict(); +export type RouteLeg = z.infer; + +export const routePlanSuccessSchema = z + .object({ + supported: z.literal(true), + mode: routeModeSchema, + distanceMeters: z.number().nonnegative(), + durationSeconds: z.number().nonnegative().nullable().optional(), + coordinates: z.array(wgs84PointSchema).min(2), + legs: z.array(routeLegSchema).default([]), + provider: z.literal('amap'), + }) + .strict(); + +export const routeUnsupportedReasonSchema = z.enum([ + 'AMAP_NOT_CONFIGURED', + 'AMAP_UNSUPPORTED_AREA', + 'AMAP_NO_ROUTE', + 'AMAP_PROVIDER_ERROR', +]); + +export const routePlanUnsupportedSchema = z + .object({ + supported: z.literal(false), + reason: routeUnsupportedReasonSchema, + message: z.string().optional(), + }) + .strict(); + +export const routePlanResultSchema = z.discriminatedUnion('supported', [routePlanSuccessSchema, routePlanUnsupportedSchema]); +export type RoutePlanResult = z.infer; diff --git a/shared/src/maps/coordinates.spec.ts b/shared/src/maps/coordinates.spec.ts new file mode 100644 index 00000000..ae048400 --- /dev/null +++ b/shared/src/maps/coordinates.spec.ts @@ -0,0 +1,26 @@ +import { describe, expect, it } from 'vitest'; + +import { gcj02ToWgs84, isInChinaMainland, wgs84ToGcj02 } from './coordinates'; + +describe('map coordinate transforms', () => { + it('converts Beijing WGS-84 into GCJ-02 and approximately reverses it', () => { + const wgs = { lat: 39.908823, lng: 116.39747 }; + + const gcj = wgs84ToGcj02(wgs); + + expect(gcj.lng).not.toBeCloseTo(wgs.lng, 4); + expect(gcj02ToWgs84(gcj)).toMatchObject({ + lat: expect.closeTo(wgs.lat, 5), + lng: expect.closeTo(wgs.lng, 5), + }); + }); + + it('does not transform London', () => { + expect(wgs84ToGcj02({ lat: 51.5072, lng: -0.1276 })).toEqual({ lat: 51.5072, lng: -0.1276 }); + }); + + it('treats Hong Kong and Taiwan as outside the mainland conversion area', () => { + expect(isInChinaMainland(22.3193, 114.1694)).toBe(false); + expect(isInChinaMainland(25.033, 121.5654)).toBe(false); + }); +}); diff --git a/shared/src/maps/coordinates.ts b/shared/src/maps/coordinates.ts new file mode 100644 index 00000000..b51fe4b7 --- /dev/null +++ b/shared/src/maps/coordinates.ts @@ -0,0 +1,84 @@ +export type Wgs84Point = Readonly<{ lat: number; lng: number }>; +export type Gcj02Point = Readonly<{ lat: number; lng: number }>; + +const PI = Math.PI; +const A = 6378245.0; +const EE = 0.00669342162296594323; + +const MAINLAND_BOUNDS = { + minLat: 18.0, + maxLat: 53.56, + minLng: 73.66, + maxLng: 135.05, +} as const; + +const EXCLUDED_REGIONS = [ + { minLat: 22.13, maxLat: 22.57, minLng: 113.76, maxLng: 114.51 }, // Hong Kong + { minLat: 22.06, maxLat: 22.25, minLng: 113.52, maxLng: 113.68 }, // Macau + { minLat: 21.8, maxLat: 25.4, minLng: 119.3, maxLng: 122.1 }, // Taiwan +] as const; + +export function isInChinaMainland(lat: number, lng: number): boolean { + if (!Number.isFinite(lat) || !Number.isFinite(lng)) return false; + if (lat < MAINLAND_BOUNDS.minLat || lat > MAINLAND_BOUNDS.maxLat) return false; + if (lng < MAINLAND_BOUNDS.minLng || lng > MAINLAND_BOUNDS.maxLng) return false; + + return !EXCLUDED_REGIONS.some( + region => lat >= region.minLat && lat <= region.maxLat && lng >= region.minLng && lng <= region.maxLng, + ); +} + +export function wgs84ToGcj02(point: Wgs84Point): Gcj02Point { + if (!isInChinaMainland(point.lat, point.lng)) return { lat: point.lat, lng: point.lng }; + + const { deltaLat, deltaLng } = transformDelta(point.lat, point.lng); + return { + lat: point.lat + deltaLat, + lng: point.lng + deltaLng, + }; +} + +export function gcj02ToWgs84(point: Gcj02Point): Wgs84Point { + if (!isInChinaMainland(point.lat, point.lng)) return { lat: point.lat, lng: point.lng }; + + let wgsLat = point.lat; + let wgsLng = point.lng; + + for (let i = 0; i < 3; i += 1) { + const gcj = wgs84ToGcj02({ lat: wgsLat, lng: wgsLng }); + wgsLat -= gcj.lat - point.lat; + wgsLng -= gcj.lng - point.lng; + } + + return { lat: wgsLat, lng: wgsLng }; +} + +function transformDelta(lat: number, lng: number): { deltaLat: number; deltaLng: number } { + let dLat = transformLat(lng - 105.0, lat - 35.0); + let dLng = transformLng(lng - 105.0, lat - 35.0); + const radLat = (lat / 180.0) * PI; + let magic = Math.sin(radLat); + magic = 1 - EE * magic * magic; + const sqrtMagic = Math.sqrt(magic); + + dLat = (dLat * 180.0) / (((A * (1 - EE)) / (magic * sqrtMagic)) * PI); + dLng = (dLng * 180.0) / ((A / sqrtMagic) * Math.cos(radLat) * PI); + + return { deltaLat: dLat, deltaLng: dLng }; +} + +function transformLat(x: number, y: number): number { + let ret = -100.0 + 2.0 * x + 3.0 * y + 0.2 * y * y + 0.1 * x * y + 0.2 * Math.sqrt(Math.abs(x)); + ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; + ret += ((20.0 * Math.sin(y * PI) + 40.0 * Math.sin((y / 3.0) * PI)) * 2.0) / 3.0; + ret += ((160.0 * Math.sin((y / 12.0) * PI) + 320 * Math.sin((y * PI) / 30.0)) * 2.0) / 3.0; + return ret; +} + +function transformLng(x: number, y: number): number { + let ret = 300.0 + x + 2.0 * y + 0.1 * x * x + 0.1 * x * y + 0.1 * Math.sqrt(Math.abs(x)); + ret += ((20.0 * Math.sin(6.0 * x * PI) + 20.0 * Math.sin(2.0 * x * PI)) * 2.0) / 3.0; + ret += ((20.0 * Math.sin(x * PI) + 40.0 * Math.sin((x / 3.0) * PI)) * 2.0) / 3.0; + ret += ((150.0 * Math.sin((x / 12.0) * PI) + 300.0 * Math.sin((x / 30.0) * PI)) * 2.0) / 3.0; + return ret; +} diff --git a/shared/src/maps/maps.schema.ts b/shared/src/maps/maps.schema.ts index 9e3318d7..849ec1f7 100644 --- a/shared/src/maps/maps.schema.ts +++ b/shared/src/maps/maps.schema.ts @@ -3,12 +3,9 @@ import { z } from 'zod'; /** * Maps / geo API contract — single source of truth for the /api/maps endpoints. * - * The legacy Express route (server/src/routes/maps.ts) is a thin layer over - * services/mapsService.ts, which talks to Nominatim/Overpass (and optionally - * Google Places when a key is configured) and applies the SSRF guard on every - * outbound URL. The place objects these return are provider-shaped and vary by - * source, so the response schemas keep them as open records — the contract pins - * down the request shapes and the stable envelope fields, not the provider blobs. + * During the AMap-only migration, endpoint envelopes stay stable while provider + * adapters move behind server-side contracts. The place objects remain open here + * until the legacy map endpoints finish migrating to typed AMap-neutral records. * * The bespoke 400 validation messages and the per-endpoint kill-switch responses * are reproduced in the controller, not derived from these schemas, so the bodies