# 小程序行程路线图接口 - `/mp/product/{productId}/route-map`

- **日期**: 2026-04-21
- **PR**: [#1098](https://git.1814.love:8443/wx/HL/pulls/1098) (Closes #1093) + 增量修复 [#1106](https://git.1814.love:8443/wx/HL/pulls/1106) (Closes #1104)
- **类型**: FEATURE（新增接口，免登录）
- **服务**: hl-product-service-v2（路由 `/mp/product/**`）
- **前端是否需要改动**: **是**（需新增接口调用 + 地图展示）

> ⚠️ **2026-04-21 增量修复（PR #1106）**：上线初期前端反馈 `origin`/`destination` 全部为空，根因是真实 `gather_place`/`dismissal_place` JSON 来自高德行政区 picker，只有 adcode 没有 `center` 字段。修复后通过 adcode 调高德 `/v3/config/district` 拿区县中心坐标（Redis 缓存 30 天）。**接口契约不变**，前端无需调整。

---

## 一、背景

小程序需要按产品 ID 拉取每天的「路线图」用于地图展示，所有节点必须带经纬度。无需登录（路线图是基础地理信息，浏览阶段即可看到）。

---

## 二、接口

| # | 方法 | 路径 | 鉴权 |
|---|------|------|------|
| 1 | GET | `/mp/product/{productId}/route-map` | 免登录（白名单） |

**Path 参数**

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `productId` | Long (字符串) | 是 | 产品 ID |

**无 Query/Body 参数**。

---

## 三、响应结构

### 顶层 `data: MpProductRouteMapVO`

| 字段 | 类型 | 说明 |
|------|------|------|
| `title` | String | 产品标题 |
| `days` | `MpProductRouteDayVO[]` | 每天的路线图，按 dayNumber 升序 |

### `MpProductRouteDayVO`

| 字段 | 类型 | 说明 |
|------|------|------|
| `dayNumber` | Integer | 第几天，从 1 开始 |
| `title` | String | 当天标题（如 "D1 成都-四姑娘山"） |
| `color` | String / null | 当天主题色（后端不返回，固定 `null`，前端用调色板兜底） |
| `origin` | `MpRoutePointVO` / null | 当天起点；缺数据时为 null |
| `destination` | `MpRoutePointVO` / null | 当天终点；缺数据时为 null |
| `waypoints` | `MpRoutePointVO[]` | 当天途经点（按 sort_order 升序），可能空数组 |
| `scenicSpots` | `MpRouteScenicVO[]` | 当天景点（含封面图），可能空数组 |

### `MpRoutePointVO`（起点 / 终点 / 途经点）

| 字段 | 类型 | 说明 |
|------|------|------|
| `lng` | Number | 经度 |
| `lat` | Number | 纬度 |
| `name` | String | 名称 |

### `MpRouteScenicVO`（景点）

| 字段 | 类型 | 说明 |
|------|------|------|
| `lng` | Number | 经度 |
| `lat` | Number | 纬度 |
| `name` | String | 名称 |
| `coverUrl` | String / null | 封面图完整 URL，缺失为 null |

---

## 四、业务规则

- **起终点**：优先取行程当天「集合地 / 解散地」JSON 字段；若缺失，回退到该天 `product_route_point` 首/末
- **途经点**：取该天 `product_route_point` 全量按 `sort_order` 升序
- **景点**：取该天行程节点中 `nodeType ∈ {SCENIC, ACTIVITY}` 的资源，经纬度 + 封面图来自资源服务（景点表 / 活动表）
- **经纬度缺失节点直接过滤**（不会返回 `lng=0/lat=0` 或 `null` 坐标）—— 前端拿到的所有点都保证有有效坐标
- **未上架 / 已下架 / 不存在产品** → 业务码 `code=404, message="产品不存在或已下架"`（与现有 `/mp/product/{id}` 详情接口对齐）
- **景点封面图加载失败**（资源服务异常）：景点列表降级为空数组，路线骨架仍正常返回（不阻断主流程）

---

## 五、响应示例

### PUBLISHED 产品（部分天有数据 + 部分天缺数据）

```json
{
  "code": 200,
  "success": true,
  "data": {
    "title": "川西亲子研学5日游（升级版）",
    "days": [
      {
        "dayNumber": 1,
        "title": "抵达成都",
        "color": null,
        "origin":      { "lng": 103.947086, "lat": 30.578528, "name": "成都双流机场" },
        "destination": { "lng": 104.061902, "lat": 30.673933, "name": "宽窄巷子" },
        "waypoints":   [
          { "lng": 104.061902, "lat": 30.673933, "name": "宽窄巷子" }
        ],
        "scenicSpots": []
      },
      {
        "dayNumber": 2,
        "title": "成都-四姑娘山",
        "color": null,
        "origin":      { "lng": 104.066301, "lat": 30.572961, "name": "成都" },
        "destination": { "lng": 102.8987,   "lat": 31.0827,   "name": "四姑娘山" },
        "waypoints":   [
          { "lng": 102.8987, "lat": 31.0827, "name": "四姑娘山" }
        ],
        "scenicSpots": []
      },
      {
        "dayNumber": 3,
        "title": "四姑娘山-丹巴",
        "color": null,
        "origin": null,
        "destination": null,
        "waypoints": [],
        "scenicSpots": []
      }
    ]
  }
}
```

### 未上架 / 已下架 / 不存在

```json
{
  "code": 404,
  "success": false,
  "message": "产品不存在或已下架",
  "data": null
}
```

---

## 六、前端使用建议

### 调用示例

```ts
const res = await wx.request({
  url: `https://api.test.1814.love:9443/mp/product/${productId}/route-map`,
  method: 'GET'
  // 不需要带 Authorization header
});

if (res.data.code !== 200) {
  // 已下架 / 不存在
  return;
}

const { title, days } = res.data.data;
```

### 渲染地图（高德/腾讯地图，伪代码）

```ts
const palette = ['#4285F4', '#EA4335', '#FBBC04', '#34A853', '#9C27B0'];

days.forEach((day, idx) => {
  const color = day.color || palette[idx % palette.length];

  // 折线：origin → waypoints → destination
  const polylinePoints = [
    day.origin,
    ...day.waypoints,
    day.destination
  ].filter(Boolean);  // 缺失的点跳过

  drawPolyline(polylinePoints, color);

  // 景点 marker
  day.scenicSpots.forEach(spot => {
    drawMarker(spot, { icon: spot.coverUrl, label: spot.name });
  });
});
```

### ⚠️ 注意

- **所有节点都保证有 lng/lat**，前端无需再判空坐标
- **`origin` / `destination` 可能为 null**（当天数据不全），渲染折线时要过滤
- **`color` 后端固定返回 null**，前端按 dayNumber index 取调色板色
- **`scenicSpots[].coverUrl` 可能为 null**（景点未配封面），需有兜底图
- **`waypoints` / `scenicSpots` 可能空数组**

---

## 七、不兼容变更

**无**。新增接口，与既有接口无交集。

---

## 八、回归验证

```bash
# PUBLISHED 产品
curl -sk "https://api.test.1814.love:9443/mp/product/2044803580070703106/route-map" | jq .

# DRAFT 产品（应返回 404 业务码）
curl -sk "https://api.test.1814.love:9443/mp/product/2045018534152478721/route-map" | jq .
```

**预期**：

- PUBLISHED：`code=200`，`data.title` 非空，`data.days[]` 非空，每个有数据的 day 节点都带有效 lng/lat
- DRAFT：`code=404, message="产品不存在或已下架"`
