feat: add amap coordinate boundary
This commit is contained in:
parent
e24394d3f3
commit
4c6215b233
@ -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';
|
||||
|
||||
44
shared/src/maps/amap.schema.spec.ts
Normal file
44
shared/src/maps/amap.schema.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
73
shared/src/maps/amap.schema.ts
Normal file
73
shared/src/maps/amap.schema.ts
Normal 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>;
|
||||
26
shared/src/maps/coordinates.spec.ts
Normal file
26
shared/src/maps/coordinates.spec.ts
Normal 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);
|
||||
});
|
||||
});
|
||||
84
shared/src/maps/coordinates.ts
Normal file
84
shared/src/maps/coordinates.ts
Normal 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;
|
||||
}
|
||||
@ -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
|
||||
|
||||
Loading…
Reference in New Issue
Block a user