# 高德地图单供应商迁移 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** ```ts 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** ```ts 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** ```bash 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** ```ts 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** ```ts export class AmapClient { async get(path: string, params: URLSearchParams): Promise { // Append server-only key, enforce timeout, redact key from errors, map provider failure. } } export async function autocomplete(input: string, bias?: LocationBias): Promise { /* 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** ```bash 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** ```ts 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** ```ts export async function planRoadRoute(points: Wgs84Point[], mode: RoadMode): Promise { // 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** ```bash 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** ```ts 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** ```ts 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** ```bash 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** ```tsx it('shows the missing-key state without loading a map provider', async () => { render() expect(await screen.findByText(/高德地图未配置/i)).toBeVisible() }) it('converts WGS place coordinates before creating an AMap marker', async () => { render() 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** ```ts 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** ```bash 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** ```ts it('does not request a CARTO tile URL when Atlas mounts', async () => { render(); 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** ```ts // 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** ```bash 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** ```ts 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** ```json // 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** ```bash 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** ```bash 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** ```bash git add README.md docs .env.example client server shared git commit -m "docs: document amap-only configuration" ```