docs: add amap implementation plan

This commit is contained in:
jubnl 2026-07-11 15:25:48 +08:00
parent 5e249b2a59
commit e24394d3f3

View File

@ -0,0 +1,450 @@
# 高德地图单供应商迁移 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<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**
```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<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**
```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(<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**
```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(<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**
```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"
```