Trek_CN/docs/superpowers/plans/2026-07-11-amap-single-provider.md
2026-07-11 15:25:48 +08:00

19 KiB
Raw Blame History

高德地图单供应商迁移 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_KEYAMAP_JS_SECURITY_CODE 仅留在服务端并经 serviceHost 反代,禁止使用 VITE_AMAP_JS_SECURITY_CODE
  • 业务/数据库坐标始终是 WGS-84高德入参、出参与地图叠加层是 GCJ-02中国大陆外不转换。
  • 旧数据字段只停止使用,不物理删除:google_place_idgoogle_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, pois backed 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/maps provider 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/plan for 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.example or deployment documentation containing map configuration.

  • Modify: README.md and 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
  1. Set the three AMap environment variables only in deployment secrets.
  2. Verify Gitea-hosted staging app loads AMap JS map and no key reaches logs.
  3. Search and save a China POI; verify marker and generated AMap URI location.
  4. Verify driving, walking, cycling and transit paths.
  5. Verify shared trip, collection, Journey and Atlas behavior.
  6. 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"