# 修复: 费用包含按名字去重 + name 字段填具体景区/活动/酒店名 **类型**: 后端 BUG 修复(PR #1568 follow-up) **关联**: 工单 #1576 / PR #1577 **日期**: 2026-04-30 **前端处理者**: mmg **影响范围**: 管理后台「产品编辑 → 补充信息 → 费用说明 →『根据行程自动生成』」按钮 --- ## 背景 PR #1568 已修复「自动生成」漏景区/活动/酒店的问题(每节点 1 条 + 追加酒店),但用户使用后反馈 2 个新问题: ### 问题 1: 同名条目重复 同一酒店多天住就出现多次 — 产品 `2045018534152478721`「游牧的森林·南线经典」自动生成 35 条结果中: - 「海拉尔洲季酒店」出现 **11 次**(11 天住同一酒店) - 「呼伦贝尔香格里拉大酒店」出现 **4 次** - 「额尔古纳玖成万豪水汇智能酒店」出现 **4 次** - 27 条住宿真实只有 **9 家不同酒店** ### 问题 2: `name` 字段无信息量 三种类型(景区/活动/酒店)的 `name` 字段都填 `feeType` 标签 — 即: - 6 条门票项 `name` 都是 `"门票"`(实际景区名是黑山头/白桦林景区/莫日格勒河等) - 27 条住宿项 `name` 都是 `"住宿"`(实际酒店名各不同) 前端中间 label 区域显示重复的 chip 文字,无信息量。 ## 修复 `hl-product-service-v2` `ProductValidationService.feeDeductionPreview()`: ### 1. 按名字去重 ```java LinkedHashSet seenNodeNames = new LinkedHashSet<>(); for (ProductItineraryNodeDO node : sortedNodes) { String nodeName = node.getNodeName(); if (nodeName == null || nodeName.isBlank()) continue; if (!seenNodeNames.add(nodeName)) continue; // ← 按名字去重 result.add(...); } LinkedHashSet seenHotelNames = new LinkedHashSet<>(); for (ProductDayHotelDO hotel : sortedHotels) { String hotelName = hotel.getHotelName(); if (hotelName == null || hotelName.isBlank()) continue; if (!seenHotelNames.add(hotelName)) continue; result.add(...); } ``` `LinkedHashSet` 保留**首次出现**(行程顺序里第一次见到该名字的那条)。 ### 2. `name` 字段填具体名 | 字段 | 改前 | 改后 | |---|---|---| | `feeType` | 门票/活动/住宿/餐饮/交通 | **不变**(前端 chip 用) | | `name` | 同 feeType | **节点名/酒店名** | | `sourceNodeName` | 节点名/酒店名 | **不变**(与 name 同值) | | `icon` | null | **不变**(PR #1573 行为) | ## 影响接口 | 方法 | 路径 | 改动 | |------|------|------| | GET | `/admin/product/item/{id}/fee-deduction-preview` | 数组**长度变短**(去重),每项 `name` 改为具体名,其他字段语义不变 | 请求签名完全不变,响应字段不变。 ## 响应示例 ```jsonc GET /admin/product/item/2045018534152478721/fee-deduction-preview { "code": 200, "data": [ /* 6 门票 */ {"feeType": "门票", "name": "黑山头", "source": "AUTO", "sourceNodeName": "黑山头", "icon": null}, {"feeType": "门票", "name": "白桦林景区", "source": "AUTO", "sourceNodeName": "白桦林景区", "icon": null}, /* ... 共 6 条 */ /* 2 活动 */ {"feeType": "活动", "name": "ATV全地形越野车穿越", "source": "AUTO", "sourceNodeName": "ATV全地形越野车穿越", "icon": null}, {"feeType": "活动", "name": "驯鹿拉车体验", "source": "AUTO", "sourceNodeName": "驯鹿拉车体验", "icon": null}, /* 9 住宿(去重后,不再有重复) */ {"feeType": "住宿", "name": "海拉尔洲季酒店", "source": "AUTO", "sourceNodeName": "海拉尔洲季酒店", "icon": null}, /* ... 共 9 条 */ ] } ``` 旧返回 35 条,新返回 **17 条**(6 门票 + 2 活动 + 9 住宿)。 ## 前端要做的改动 **0 改动**。前端模板 `{{ item.name }}` 直接渲染就显示具体名字了。 `sourceNodeName` 字段值与 `name` 同值,如果前端模板里有 `← {{ item.sourceNodeName }}` 来源标签,会出现"门票 chip + 黑山头 + ← 黑山头"的视觉重复。前端**可选**: - 保留来源标签作为视觉标识(用户已习惯) - 或加 `v-if="item.sourceNodeName !== item.name"` 隐藏冗余 ## 测试覆盖 `mvn test -pl hl-product-service-v2`: **1045/1045 全绿**。 新增 3 单测: - `feeDeductionPreview_duplicateNodeNames_dedupedByName` — 5 个同名 SCENIC 节点 → result.size=1 - `feeDeductionPreview_sameHotelMultipleDays_dedupedByName` — 11 个同酒店多天 → result.size=1 - `feeDeductionPreview_nullOrBlankName_skipped` — null/空名字跳过 测试服 round-trip(产品 `2045018534152478721`)实测: - 总计 **17 条**(6 + 2 + 9) - 全部 `name` unique(去重生效) - 全部 `name` 是具体名字(黑山头/ATV 全地形越野车穿越/海拉尔洲季酒店 等) - 全部 `icon` null(PR #1573 行为不变) ## 部署状态 - ✅ Deploy Panel 已部署测试服 hl-product-service-v2 双实例(2026-04-30 16:56) - 正式环境暂未发布(等 dev → main release PR)