# 修复: 费用包含按名字去重 + 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<String> 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<String> 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)
