# 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 参数)