19 KiB
高德地图单供应商迁移 Implementation Plan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Goal: 用高德 JS API 与高德 Web 服务替换 TREK 的全部运行时地图供应商,并移除旧供应商的代码和依赖。
Architecture: 数据与业务层保留 WGS-84;一个共享坐标模块在高德 JS、Web 服务和 URI 边界转换 GCJ-02。服务端以受鉴权的高德适配器提供通用搜索、POI、路线和公交契约;客户端只消费这些内部契约与单一高德地图组件。
Tech Stack: TypeScript、React、NestJS、Vitest、AMap JS API 2.0、AMap Web Service API。
Global Constraints
- 服务端 Key 使用
AMAP_WEB_SERVICE_KEY;不得写入数据库、前端状态或日志。 - JS Key 使用
VITE_AMAP_JS_API_KEY;AMAP_JS_SECURITY_CODE仅留在服务端并经serviceHost反代,禁止使用VITE_AMAP_JS_SECURITY_CODE。 - 业务/数据库坐标始终是 WGS-84;高德入参、出参与地图叠加层是 GCJ-02;中国大陆外不转换。
- 旧数据字段只停止使用,不物理删除:
google_place_id、google_ftid、OSM 标识、旧 URL 和既有照片缓存保留只读兼容。 - 不保留旧供应商回退,也不把直线显示为高德路线结果。
- 代码中不得遗留 Leaflet、Mapbox、MapLibre、Google Maps、Nominatim、Overpass、OSRM、Transitous/MOTIS、Naver 或旧瓦片供应商的运行时调用。
File Structure
| 文件/目录 | 责任 |
|---|---|
shared/src/maps/coordinates.ts |
WGS-84/GCJ-02 类型、转换和中国大陆边界判断 |
shared/src/maps/amap.schema.ts |
客户端/服务端共享的高德中性地图与路线契约 |
server/src/services/amap/* |
高德 HTTP 客户端、搜索/POI、路线、公交和 URI 适配器 |
server/src/nest/maps/* |
对现有地图 API 的受鉴权 Nest 暴露层 |
server/src/nest/routes/* |
新增道路路线 API,替代浏览器 OSRM |
client/src/components/Map/amap/* |
加载器、单一地图组件、marker/route/poi/定位叠加层 |
client/src/api/client.ts |
地图与路线 API 调用 |
client/src/components/Map/* |
移除旧渲染器与 OSRM 计算器,接入高德组件/接口 |
client/src/pages/atlas/* |
用高德能力重建 Atlas,或对不支持的世界级 GeoJSON 交互显示受控状态 |
client/src/sync/*, client/vite.config.js |
移除未经授权的 XYZ 离线瓦片预取和缓存 |
Implementation Order
Task 1: 建立共享坐标边界与契约
Files:
- Create:
shared/src/maps/coordinates.ts - Create:
shared/src/maps/coordinates.spec.ts - Create:
shared/src/maps/amap.schema.ts - Modify:
shared/src/maps/maps.schema.ts - Modify:
shared/src/index.ts
Interfaces:
-
Produces:
isInChinaMainland(lat, lng),wgs84ToGcj02(point),gcj02ToWgs84(point),Wgs84Point,Gcj02Point. -
Produces: shared internal route/POI response schemas; no AMap raw response crosses package boundaries.
-
Step 1: Write failing coordinate tests
it('converts Beijing WGS-84 into GCJ-02 and approximately reverses it', () => {
const gcj = wgs84ToGcj02({ lat: 39.908823, lng: 116.39747 })
expect(gcj.lng).not.toBeCloseTo(116.39747, 4)
expect(gcj02ToWgs84(gcj)).toMatchObject({ lat: expect.closeTo(39.908823, 5), lng: expect.closeTo(116.39747, 5) })
})
it('does not transform London', () => expect(wgs84ToGcj02({ lat: 51.5072, lng: -0.1276 })).toEqual({ lat: 51.5072, lng: -0.1276 }))
- Step 2: Run the failing test
Run: npm run test --workspace=shared -- coordinates.spec.ts
Expected: FAIL because the coordinate module does not exist.
- Step 3: Implement branded coordinate types and conversions
export type Wgs84Point = Readonly<{ lat: number; lng: number }>
export type Gcj02Point = Readonly<{ lat: number; lng: number }>
export function wgs84ToGcj02(point: Wgs84Point): Gcj02Point { /* mainland guard then transform */ }
export function gcj02ToWgs84(point: Gcj02Point): Wgs84Point { /* iterative inverse */ }
- Step 4: Export the contracts and run shared tests
Run: npm run test --workspace=shared
Expected: PASS.
- Step 5: Commit
git add shared/src/maps shared/src/index.ts
git commit -m "feat: add amap coordinate boundary"
Task 2: 实现受保护的服务端高德 HTTP 与地点适配器
Files:
- Create:
server/src/services/amap/amap.client.ts - Create:
server/src/services/amap/amap.places.ts - Create:
server/tests/unit/services/amap/amap.client.test.ts - Create:
server/tests/unit/services/amap/amap.places.test.ts - Modify:
server/src/nest/maps/maps.service.ts - Modify:
server/src/nest/maps/maps.controller.ts - Modify:
server/src/middleware/globalMiddleware.ts - Delete: Google/Nominatim/Overpass branches from
server/src/services/mapsService.ts
Interfaces:
-
Consumes: Task 1 coordinate/response contracts and
AMAP_WEB_SERVICE_KEY. -
Produces:
search,autocomplete,details,reverse,poisbacked only by AMap. -
Step 1: Add failing adapter contract tests
it('returns a configuration error without AMAP_WEB_SERVICE_KEY', async () => {
await expect(client.get('/v3/place/text', {})).rejects.toMatchObject({ code: 'AMAP_NOT_CONFIGURED' })
})
it('converts AMap GCJ POI coordinates back to WGS-84', async () => {
fetchMock.mockResolvedValue(amapPoiResponse('116.404,39.915'))
await expect(searchPlaces('天安门')).resolves.toMatchObject({ places: [{ lat: expect.any(Number), lng: expect.any(Number) }] })
})
- Step 2: Run the failing server tests
Run: npm run test --workspace=server -- amap.client.test.ts amap.places.test.ts
Expected: FAIL because the AMap adapter does not exist.
- Step 3: Implement fixed-host client and mapping functions
export class AmapClient {
async get<T>(path: string, params: URLSearchParams): Promise<T> {
// Append server-only key, enforce timeout, redact key from errors, map provider failure.
}
}
export async function autocomplete(input: string, bias?: LocationBias): Promise<MapsAutocompleteResult> { /* input tips → internal suggestions */ }
- Step 4: Replace
/api/mapsprovider wiring and test timeout/rate-limit paths
Run: npm run test --workspace=server -- tests/unit/nest/maps.service.test.ts tests/unit/nest/maps.controller.test.ts tests/unit/services/amap
Expected: PASS.
- Step 5: Commit
git add server/src/services/amap server/src/nest/maps server/src/middleware/globalMiddleware.ts server/tests
git commit -m "feat: proxy amap place services"
Task 3: 增加服务器路线与公交适配器
Files:
- Create:
server/src/services/amap/amap.routes.ts - Create:
server/src/services/amap/amap.transit.ts - Create:
server/src/nest/routes/routes.module.ts - Create:
server/src/nest/routes/routes.controller.ts - Create:
server/src/nest/routes/routes.service.ts - Create:
server/tests/unit/services/amap/amap.routes.test.ts - Create:
server/tests/unit/services/amap/amap.transit.test.ts - Modify:
server/src/app.module.ts - Modify:
server/src/nest/transit/transit.controller.ts - Modify:
server/src/nest/transit/transit.module.ts - Delete:
server/src/services/transitService.ts
Interfaces:
-
Consumes: WGS-84 waypoints from clients; transforms to AMap GCJ-02.
-
Produces:
POST /api/routes/planfor driving/walking/cycling and AMap-adapted transit responses. -
Step 1: Write failing route chunking and transit mapping tests
it('splits a route with more than the AMap waypoint limit and joins segments without duplicate points', async () => {
const result = await planRoadRoute(twentyWgsWaypoints, 'driving')
expect(result.coordinates).toHaveLength(expect.any(Number))
expect(fetchMock).toHaveBeenCalledTimes(2)
})
it('maps an AMap transit segment sequence into internal legs', async () => {
await expect(planTransit(from, to)).resolves.toMatchObject({ itineraries: [{ legs: expect.arrayContaining([expect.objectContaining({ mode: 'walk' })]) }] })
})
- Step 2: Run the failing tests
Run: npm run test --workspace=server -- amap.routes.test.ts amap.transit.test.ts
Expected: FAIL because no route module exists.
- Step 3: Implement route chunking and explicit unsupported-area errors
export async function planRoadRoute(points: Wgs84Point[], mode: RoadMode): Promise<RouteWithLegs> {
// validate, transform, split to provider limit, request sequential chunks, de-duplicate joins, inverse-transform geometry
}
- Step 4: Point the transit endpoint at the AMap transit adapter
Run: npm run test --workspace=server -- tests/unit/services/amap tests/e2e/transit.e2e.test.ts
Expected: PASS with AMap fixtures; no Transitous URL mock remains.
- Step 5: Commit
git add server/src/services/amap server/src/nest/routes server/src/nest/transit server/src/app.module.ts server/tests
git commit -m "feat: add amap route and transit services"
Task 4: 切换客户端搜索、路线和高德外链
Files:
- Create:
client/src/components/Planner/placeAmap.ts - Create:
client/src/components/Planner/placeAmap.test.ts - Modify:
client/src/api/client.ts - Modify:
client/src/hooks/useRouteCalculation.ts - Modify:
client/src/hooks/useTransportRoutes.ts - Modify:
client/src/components/Map/RouteCalculator.ts - Modify:
client/src/components/Map/transitGeometry.ts - Modify:
client/src/components/Planner/TransitSearchPanel.tsx - Modify:
client/src/components/Planner/PlaceInspector.tsx - Delete:
client/src/components/Planner/placeGoogleMaps.ts - Delete:
client/src/components/Map/RouteCalculator.test.ts
Interfaces:
-
Consumes: Task 2 map endpoints and Task 3 route endpoints.
-
Produces: no browser-to-OSRM request; all external map links are AMap URI URLs.
-
Step 1: Write failing client API and URI tests
it('creates an AMap URI with GCJ longitude before latitude', () => {
expect(amapPlaceUri({ name: '天安门', lat: 39.908823, lng: 116.39747 })).toContain('116.')
})
it('calls /api/routes/plan instead of an OSRM host', async () => {
await calculateRouteWithLegs(points)
expect(lastRequest.url).toContain('/api/routes/plan')
})
- Step 2: Run failing focused tests
Run: npm run test --workspace=client -- placeAmap.test.ts RouteCalculator.test.ts useTransportRoutes.test.ts
Expected: FAIL before the client is migrated.
- Step 3: Replace direct provider calls and adapt transit geometry
export const routesApi = { plan: (body: RoutePlanRequest) => apiClient.post('/routes/plan', body).then(r => r.data) }
export function amapPlaceUri(place: Wgs84Point & { name: string }): string { /* WGS→GCJ then URLSearchParams */ }
- Step 4: Run planner and transport tests
Run: npm run test --workspace=client -- src/components/Planner/TransitSearchPanel.test.tsx src/components/Planner/PlaceInspector.test.tsx tests/integration/hooks/useTransportRoutes.test.ts
Expected: PASS.
- Step 5: Commit
git add client/src/api/client.ts client/src/hooks client/src/components/Map client/src/components/Planner
git commit -m "feat: use amap for client routes and links"
Task 5: 用单一高德地图组件替换地图渲染器
Files:
- Create:
client/src/components/Map/amap/loadAmap.ts - Create:
client/src/components/Map/amap/AmapMapView.tsx - Create:
client/src/components/Map/amap/AmapMapView.test.tsx - Create:
client/src/components/Map/amap/amapOverlays.ts - Modify:
client/src/components/Map/MapViewAuto.tsx - Modify:
client/src/components/Journey/JourneyMapAuto.tsx - Modify:
client/src/components/Journey/JourneyMap.tsx - Modify:
client/src/components/Collections/CollectionMap.tsx - Modify:
client/src/pages/SharedTripPage.tsx - Delete:
client/src/components/Map/MapView.tsx - Delete:
client/src/components/Map/MapViewGL.tsx - Delete:
client/src/components/Journey/JourneyMapGL.tsx - Delete:
client/src/components/Map/glProviders.ts - Delete:
client/src/components/Map/mapboxSetup.ts - Delete:
client/src/components/Map/locationMarkerMapbox.ts - Delete:
client/src/components/Map/reservationsMapbox.ts
Interfaces:
-
Consumes: WGS-84 props from existing consumers.
-
Produces: an AMap-only map with WGS-to-GCJ conversion entirely inside the map boundary.
-
Step 1: Write failing loader and rendering tests
it('shows the missing-key state without loading a map provider', async () => {
render(<AmapMapView places={[]} />)
expect(await screen.findByText(/高德地图未配置/i)).toBeVisible()
})
it('converts WGS place coordinates before creating an AMap marker', async () => {
render(<AmapMapView places={[beijingPlace]} />)
await waitFor(() => expect(AMap.Marker).toHaveBeenCalledWith(expect.objectContaining({ position: expect.anything() })))
})
- Step 2: Run the failing component test
Run: npm run test --workspace=client -- AmapMapView.test.tsx
Expected: FAIL because the loader and component do not exist.
- Step 3: Implement loader, security serviceHost configuration, and overlays
window._AMapSecurityConfig = { serviceHost: '/api/amap/security' }
await loadScript(`https://webapi.amap.com/maps?v=2.0&key=${import.meta.env.VITE_AMAP_JS_API_KEY}`)
- Step 4: Switch all map consumers and run focused UI tests
Run: npm run test --workspace=client -- src/components/Map src/components/Journey/JourneyMap.test.tsx src/pages/SharedTripPage.test.tsx
Expected: PASS; no Leaflet/Mapbox/MapLibre module mock remains.
- Step 5: Commit
git add client/src/components/Map client/src/components/Journey client/src/components/Collections client/src/pages/SharedTripPage.tsx
git commit -m "feat: replace map renderers with amap"
Task 6: 处理 Atlas 与离线地图下线
Files:
- Modify:
client/src/pages/atlas/useAtlas.ts - Modify:
client/src/pages/AtlasPage.test.tsx - Modify:
client/src/sync/tilePrefetcher.ts - Modify:
client/src/sync/tripSyncManager.ts - Modify:
client/src/components/Settings/OfflineTab.tsx - Modify:
client/vite.config.js - Delete: offline tile prefetch tests and rules that only cache old providers.
Interfaces:
-
Produces: Atlas either uses supported AMap overlay primitives for supported views or presents an explicit unsupported-world-map state; no CARTO tile request is issued.
-
Produces: no predownloaded third-party map tile cache.
-
Step 1: Add failing regression tests for provider removal
it('does not request a CARTO tile URL when Atlas mounts', async () => { render(<AtlasPage />); expect(fetchMock).not.toHaveBeenCalledWith(expect.stringMatching(/cartocdn/)) })
it('does not enqueue map tile downloads for offline sync', () => expect(buildTilePrefetchPlan(trip)).toEqual([]))
- Step 2: Run the failing tests
Run: npm run test --workspace=client -- AtlasPage.test.tsx
Expected: FAIL while Leaflet/CARTO and tile prefetching remain.
- Step 3: Remove unsupported Leaflet/CARTO and offline XYZ behaviors
// Atlas must use the shared AMap boundary or render an explicit unsupported state.
// Offline sync must not create map-tile jobs.
- Step 4: Run Atlas, offline, and build checks
Run: npm run test --workspace=client -- AtlasPage.test.tsx && npm run build --workspace=client
Expected: PASS.
- Step 5: Commit
git add client/src/pages/atlas client/src/sync client/src/components/Settings/OfflineTab.tsx client/vite.config.js
git commit -m "feat: remove legacy atlas tiles and offline maps"
Task 7: 删除旧设置、导入、MCP 和供应商依赖
Files:
- Modify:
client/src/types.ts - Modify:
client/src/store/settingsStore.ts - Modify:
client/src/components/Settings/MapSettingsTab.tsx - Modify:
client/src/components/Admin/DefaultUserSettingsTab.tsx - Modify:
server/src/services/settingsService.ts - Modify:
server/src/nest/places/places.controller.ts - Modify:
server/src/nest/places/places.service.ts - Modify:
server/src/mcp/tools/mapsWeather.ts - Modify:
server/src/mcp/tools/places.ts - Modify:
server/src/mcp/tools/days.ts - Modify:
server/src/db/migrations.ts - Modify:
client/package.json - Modify: all affected i18n/test/docs files from the provider scan.
- Delete:
client/src/components/Settings/MapboxPreview.tsx, Google/Naver import UI and tests.
Interfaces:
-
Produces: settings APIs accept only map-independent settings; historical old values are ignored safely.
-
Produces: no Google/Naver list import or Google URL resolution endpoint.
-
Step 1: Write failing settings and import-removal tests
it('does not expose a map provider or Mapbox token setting', () => expect(defaultSettings()).not.toHaveProperty('mapbox_access_token'))
it('rejects removed Google list import route with 404', async () => await request(app).post('/api/places/import/google-list').expect(404))
- Step 2: Run focused tests to establish failures
Run: npm run test --workspace=server -- tests/integration/settings.test.ts tests/integration/places.test.ts && npm run test --workspace=client -- MapSettingsTab.test.tsx
Expected: FAIL until old settings/routes are removed.
- Step 3: Remove settings, imports, packages, and old runtime configuration
// Remove leaflet, react-leaflet, mapbox-gl and maplibre-gl from client/package.json.
- Step 4: Regenerate lockfile and run targeted suite
Run: npm install --package-lock-only && npm run test --workspace=server -- tests/integration/settings.test.ts tests/integration/places.test.ts && npm run test --workspace=client -- MapSettingsTab.test.tsx
Expected: PASS.
- Step 5: Commit
git add client server shared package.json package-lock.json
git commit -m "refactor: remove legacy map providers"
Task 8: 全仓验证、预发冒烟与文档
Files:
-
Modify:
.env.exampleor deployment documentation containing map configuration. -
Modify:
README.mdand affected feature docs. -
Modify: tests and MSW fixtures identified by the final provider scan.
-
Step 1: Add a provider-removal guard
rg -n -i '(mapbox|maplibre|leaflet|nominatim|overpass|project-osrm|routing\.openstreetmap|transitous|motis|places\.googleapis|google maps|naver)' client/src server/src shared/src client/package.json server/package.json
Expected: only explicitly documented historical-data compatibility comments; no active code, dependency, URL, setting, test fixture, or CSP rule.
- Step 2: Run complete verification
Run: npm run lint && npm run test && npm run build
Expected: all workspaces pass.
- Step 3: Run pre-production real-key smoke checklist
- Set the three AMap environment variables only in deployment secrets.
- Verify Gitea-hosted staging app loads AMap JS map and no key reaches logs.
- Search and save a China POI; verify marker and generated AMap URI location.
- Verify driving, walking, cycling and transit paths.
- Verify shared trip, collection, Journey and Atlas behavior.
- Verify overseas and cross-border requests show the specified unsupported state.
- Step 4: Commit documentation and test guard
git add README.md docs .env.example client server shared
git commit -m "docs: document amap-only configuration"