# admin/product-v2: POI 搜索按城市限定 — 城市字段已在响应里, 在 `itinerary[0].gatherPlace`

> **服务**: hl-product-service-v2 (端口 8083)
> **关联**: 11_feat_admin_itinerary-mileage-anomaly-warnings.md 的第四节"前端建议改造 B"
> **日期**: 2026-05-11
> **影响范围**: 管理端产品编辑(step2) POI 搜索控件 — 仅前端改造, 后端 0 改动

---

## ⚠️ 关键变化(澄清前端误判)

前端反馈"产品详情没返回城市, 没法按 cityCode 限定 POI 搜索"。**这个判断不准确**:

- 顶层 `ProductDetailRespVO` 确实没有 `city/cityCode` 字段
- 但 `itinerary[i].gatherPlace` / `itinerary[i].dismissalPlace` 这两个 JSON 字段 **早就含完整城市/行政区信息**(后端从高德 POI 选点时就一并存了 cityName / adcode / districtName / provinceName / fullPath / township)

后端**不需要新加字段**, 前端直接读 `itinerary[0].gatherPlace.cityName / adcode` 即可。

---

## 一、字段位置实证

正式服 GET `/admin/product/item/{id}` 实测响应(产品 2053003990289129474、2052985893312008193、2052926308213891073 三例一致):

```json
{
  "data": {
    "itinerary": [
      {
        "dayNumber": 1,
        "gatherPlace": {
          "name": "云上呼伦贝尔",
          "poiId": "B0FFL...",
          "adcode": "150702",
          "address": "呼伦贝尔东山国际机场",
          "cityName": "呼伦贝尔市",
          "fullPath": "内蒙古自治区/呼伦贝尔市/海拉尔区/胜利街道",
          "location": { "lng": 119.809336, "lat": 49.5568 },
          "towncode": "150702010000",
          "township": "胜利街道",
          "districtName": "海拉尔区",
          "provinceName": "内蒙古自治区"
        },
        "dismissalPlace": { /* 同样结构 */ }
      }
    ]
  }
}
```

`dismissalPlace` 字段结构同 `gatherPlace`。

---

## 二、前端实现建议

### 1. 城市数据源(优先级)

```typescript
function getProductCity(detail) {
  const firstDay = detail.itinerary?.[0];
  const gather = firstDay?.gatherPlace;
  // 高德 POI 搜索 region 参数支持 adcode (推荐) 或 cityname
  return {
    cityName: gather?.cityName,
    adcode: gather?.adcode,
    fullPath: gather?.fullPath,
  };
}
```

### 2. 高德 POI 搜索调用

```typescript
// 高德 web 服务 v3 关键字搜索: region + citylimit=true
const params = {
  key: AMAP_KEY,
  keywords: userInput,
  region: productCity.adcode,      // adcode 比 cityname 更精确
  city_limit: true,                 // 强制只在 region 内搜
  output: 'json',
};
```

### 3. POI 搜索结果显示完整地址(根因防御)

每个 POI 结果项除了 `name` 之外, 把 `district + township` 也显示出来:

```
[结果项]
  恩和                                       ← name
  呼伦贝尔市 / 额尔古纳市 / 恩和俄罗斯族民族乡  ← district + township
```

让运营肉眼区分同名异地 POI(例如"恩和"在宝日希勒镇 vs 恩和俄罗斯族民族乡)。

---

## 三、边界场景处理

### Case A: DAY 1 集合地还没填(新建产品首次进 step2)

- 此时 `itinerary[0].gatherPlace` 是 `null`
- 第一个 POI 选择无 city 可限定 → **降级为全国搜索**, 选完后 setState 本地缓存 `cityName/adcode`
- 后续所有 POI 选择(DAY 1 节点 / DAY 1 解散 / DAY 2+...)都拿本地缓存做 region 限定

```typescript
const [productCity, setProductCity] = useState(null);
// 编辑已有产品时初始化:
useEffect(() => {
  if (detail.itinerary?.[0]?.gatherPlace?.cityName) {
    setProductCity(getProductCity(detail));
  }
}, [detail]);
// 用户选完 DAY 1 集合地后:
function onGatherPlacePicked(poi) {
  if (!productCity) {
    setProductCity({ cityName: poi.cityName, adcode: poi.adcode });
  }
  // ... 落库
}
```

### Case B: 跨城市产品(如内蒙呼伦贝尔 + 蒙古国边境)

- 取 DAY 1 gather 即可, 这是产品的"主城市"
- 跨城市产品本身在编辑时, 跨城段的 POI 会被 detector 的 `单段 > 100km` warning 命中, 让运营人工确认, 不依赖 region 限定

### Case C: gatherPlace 是老行政区格式(无 cityName, 只有 adcode + fullPath)

- 测试服新 schema 是行政区粒度, 字段更少
- 兜底: `cityName = fullPath?.split('/')?.[1]`(第 1 段省, 第 2 段市)
- 或: 拿 adcode 前 4 位 → 高德反查城市

```typescript
function extractCity(place) {
  if (!place) return null;
  if (place.cityName && place.adcode) return { cityName: place.cityName, adcode: place.adcode };
  if (place.fullPath) {
    const parts = place.fullPath.split('/');
    return { cityName: parts[1], adcode: place.adcode };
  }
  return null;
}
```

---

## 四、后端不需要做的事

明确不做(避免误改):

- ❌ 不在 `ProductDetailRespVO` 顶层加 `cityName/adcode` 冗余字段(数据已在 itinerary 里, 加冗余字段反而双源更易错乱)
- ❌ 不加 `product.city` 数据库字段(产品级城市当前业务无明确归属, "产品起点 = itinerary[0].gather" 这个隐式约定足够用)
- ❌ 不抽 `routeInfo.city` 字段(routeInfo VO 当前不含 city, 改它需要改 Entity + VO + 保存逻辑, 不值)

---

## 五、Test plan(给 mmg)

- [ ] 编辑产品 `2053003990289129474`, 在 DAY 4 集合地 POI 搜索输入"恩和", 验证只返回额尔古纳市恩和(50.83°), 不返回宝日希勒错点(49.28°)
- [ ] 新建产品, DAY 1 集合地选呼伦贝尔机场后, DAY 2/3/4 节点 POI 搜索自动限定到呼伦贝尔市
- [ ] POI 结果项显示 `district + township`, 让运营肉眼区分同名 POI
- [ ] 跨城市产品(如海拉尔出发去阿尔山)能正常编辑, 跨城段触发 detector warning 但不阻塞保存

---

## 六、参考

- 上一份 changelog: `11_feat_admin_itinerary-mileage-anomaly-warnings.md` 第四节有 POI 搜索改造的高层建议
- 实证脚本(只读 dump 字段): `D:\work2\HL\.tmp\prod_smoke_pr1939_mileage_warnings.py` (修改版可复用)
- 高德 web 服务文档: https://lbs.amap.com/api/webservice/guide/api/search (POI 搜索, region + citylimit 参数)
