feat: add amap coordinate boundary

This commit is contained in:
jubnl 2026-07-11 15:43:02 +08:00
parent e24394d3f3
commit 4c6215b233
6 changed files with 232 additions and 6 deletions

View File

@ -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';

View File

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

View File

@ -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<typeof amapPlaceSchema>;
export const routeModeSchema = z.enum(['driving', 'walking', 'cycling', 'transit']);
export type RouteMode = z.infer<typeof routeModeSchema>;
export const routePlanRequestSchema = z
.object({
mode: routeModeSchema,
points: z.array(wgs84PointSchema).min(2),
})
.strict();
export type RoutePlanRequest = z.infer<typeof routePlanRequestSchema>;
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<typeof routeLegSchema>;
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<typeof routePlanResultSchema>;

View File

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

View File

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

View File

@ -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