# 产品费用包含: 自动生成不再产「住宿」+ 每节点 1 条独立 VO 带行程图标

> **服务**: hl-product-service-v2 (端口 8083)
> **PR**: #2484
> **Issue**: #2477
> **日期**: 2026-05-18
> **影响范围**: 管理端 产品编辑「补充信息 → 费用说明 → 费用包含」+ 小程序产品详情 includedFees

---

## ⚠️ 关键变化

1. 「费用包含 → 根据行程自动生成」按钮的后端返回结构**从 N 条合并改为 N 条独立**:
   - 之前: 同一 feeType 的所有节点合并成 1 条,`name="A，B，C，D"` 用「，」拼接字符串
   - 现在: 每个 SCENIC / ACTIVITY 行程节点产出 1 条独立 `FeeDeductionPreviewVO`,`name=单个节点名`
2. **不再产出 `feeType="住宿"`**:酒店表 `product_day_hotel` + HOTEL 行程节点两个来源全部禁用
3. **icon 字段透传行程节点 `emoji_icon`**:可能是 lucide 图标名 / emoji / SVG 字符串,**前端需兼容渲染**
4. 同名节点跨天去重:同一景区在多天出现只保留行程顺序最早的那条
5. 排序:外层按 feeLabel 优先级(景区 → 游玩项目 → 餐饮 → 交通),内层按 dayId asc + sortOrder asc

---

## 一、背景

用户期望小程序产品详情「景区门票」「游玩项目」分组下每个景区/游玩项目独立 1 个 chip + 图标(参见小程序详情截图),但当前合并字符串无法满足,且自动产出的「住宿」条目跟专门住宿配置重复,语义混乱。

| 维度 | 旧行为 | 新行为 |
|------|--------|--------|
| 同 feeType 多节点 | 合并 1 条 name="A，B，C" | N 条独立 VO |
| HOTEL 节点 / day_hotel | 产「住宿」 1 条 | **不产出** |
| icon | 一直 null | 透传 `node.emojiIcon` |
| 同名跨天 | 合并字符串中重复出现 | 去重保留首次出现 |

---

## 二、变更接口清单

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 费用推导预览 | GET | `/admin/product/item/{id}/fee-deduction-preview` | 返回结构(数组语义) | 从 N 条合并 → N 条独立,无「住宿」,带 icon |
| 2 | 保存补充信息 | PUT | `/admin/product/item/{id}/supplement` | 入参语义(不强制变) | `includedFees` 现在支持 N 条独立项 + icon(VO 字段已有,只是含义) |
| 3 | 管理端产品详情 | GET | `/admin/product/item/{id}` | 返回 includedFees 数组形态 | DB 保存什么读什么(取决于用户保存方式) |
| 4 | 小程序产品详情 | GET | `/mp/product/{id}` | 返回 includedFees 数组形态 | 同上 |

---

## 三、接口详情

### 1. 费用推导预览 `GET /admin/product/item/{id}/fee-deduction-preview`

**VO**: `FeeDeductionPreviewVO`

#### 入参

| 字段 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| id | Path | Long | ✅ | 产品ID |

#### 出参 `Result<List<FeeDeductionPreviewVO>>`

| 字段 | 类型 | 说明 |
|------|------|------|
| feeType | String | 景区 / 游玩项目 / 餐饮 / 交通(**不再出现「住宿」**) |
| name | String | 单个节点名(`node.nodeName`),**不再用「，」拼接** |
| source | String | `AUTO`(常量) |
| sourceNodeName | String | 来源节点名(目前 = name) |
| icon | String? | 来自 `node.emojiIcon`:**可能是 lucide 名 / emoji / SVG 字符串**,前端兼容渲染;无图标的节点为 null |

#### 响应示例(测试服真测产品 2045345825172639746)

```json
{
  "code": 200,
  "data": [
    {
      "feeType": "景区",
      "name": "中俄边境公路(卡线)",
      "source": "AUTO",
      "sourceNodeName": "中俄边境公路(卡线)",
      "icon": "<svg xmlns=\"http://www.w3.org/...\"/>"
    },
    { "feeType": "景区", "name": "黑山头", "source": "AUTO", "sourceNodeName": "黑山头", "icon": "<svg ...>" },
    { "feeType": "景区", "name": "百里雾凇画廊", "source": "AUTO", "sourceNodeName": "百里雾凇画廊", "icon": "<svg ...>" },
    { "feeType": "景区", "name": "海拉尔国家森林公园", "source": "AUTO", "sourceNodeName": "海拉尔国家森林公园", "icon": "<svg ...>" },
    { "feeType": "游玩项目", "name": "满洲里草原篝火晚会", "source": "AUTO", "sourceNodeName": "满洲里草原篝火晚会", "icon": "<svg ...>" },
    { "feeType": "游玩项目", "name": "冬日烟花", "source": "AUTO", "sourceNodeName": "冬日烟花", "icon": null },
    { "feeType": "游玩项目", "name": "ATV全地形越野车穿越", "source": "AUTO", "sourceNodeName": "ATV全地形越野车穿越", "icon": "<svg ...>" },
    { "feeType": "游玩项目", "name": "蒙古族小型冰雪那达慕", "source": "AUTO", "sourceNodeName": "蒙古族小型冰雪那达慕", "icon": "<svg ...>" }
  ],
  "success": true
}
```

### 2 / 3 / 4. 保存与详情接口

VO 结构未变(`FeeItem.icon` / `FeeInfo.icon` 字段早已预留),只是含义换成「N 条独立项」:每个景区/游玩项目 1 个 chip,前端无需聚合。

---

## 四、契约与渲染建议(前端必看)

### icon 字段三种格式都要兼容渲染

| 来源 | icon 形态 | 渲染方式建议 |
|------|-----------|------|
| 自动推导(行程节点 emojiIcon) | lucide 图标名(如 `"mountain"`)或 emoji(如 `"🏔"`) | lucide 图标渲染器 + emoji 直接 text 渲染 |
| 手动添加(图标库选择) | SVG 字符串 `<svg ...>...</svg>` | dangerouslySetInnerHTML 渲染 |
| 节点没设图标 | `null` | 占位或不渲染 |

**最简兼容写法**:`if (icon?.startsWith('<svg')) {渲染 SVG} else if (icon) {lucide(icon) or 当 emoji} else {默认占位}`

### 展示方式变化

- admin 产品编辑第 5 步「费用包含」: 从 1 条多名字字符串 → **N 个独立 chip**,与小程序「景区门票/游玩项目」分组下的 chip 对齐
- 小程序产品详情 includedFees: 每条 1 个 chip,无需前端拆分逗号

---

## 五、数据库行为

| 场景 | product_fee_item 表 |
|------|---------------------|
| 自动生成 + 保存 1 个含 4 景区 3 游玩项目的产品 | 7 行 INCLUDED + AUTO 记录(每节点 1 行),icon 各自从 emoji_icon 来 |
| 不再产出「住宿」自动行 | 0 行 source=AUTO + fee_category=INCLUDED + fee_type=住宿 |
| 同名跨天 | 1 行(去重) |

### ⚠️ 测试环境数据已清理(2026-05-18 10:58)

部署后跑了一次性 SQL,软删了 117 条 source=AUTO + fee_category=INCLUDED 的历史数据(涉及 32 个产品):

```sql
UPDATE product_fee_item SET deleted_at = NOW()
WHERE source = 'AUTO' AND fee_category = 'INCLUDED' AND deleted_at IS NULL;
```

**这些产品的费用包含目前为空**,需要运营/定制师重新打开产品编辑页 → 第 5 步「补充信息 → 费用包含 → 根据行程自动生成」+ 保存,系统会按新格式产出 N 条独立条目。

**未受影响**:`excludedFees`(费用不含)/ `customFees`(自定义费用)/ 手动添加的 INCLUDED 项保留不动。

---

## 六、边界行为

- 产品无节点 / 无 SCENIC + ACTIVITY → 返回 `[]`
- 节点 emoji_icon 为 null → icon 字段为 null(前端走「无图标占位」分支)
- 节点 nodeType=HOTEL → 不进入返回(被过滤)
- supplement.feeIgnoredTypes 含「门票」/「活动」/等 → 该类型整组被过滤

---

## 七、不影响范围

- **仅影响**:
  - admin 产品编辑第 5 步「费用包含」展示与保存
  - 小程序产品详情 `includedFees` 渲染
- **零影响**:
  - 费用不含 `excludedFees` / 自定义费用 `customFees`
  - 产品定价 / 班期 / 订单创建 / 算价 / 报价
  - 已有的住宿专门配置(`product_day_hotel`)— 它仍然是住宿数据的权威来源,只是不再走「费用包含-自动生成」这条路
  - 手动添加的费用项不动

---

## 八、测试环境已验证

```
GET /admin/product/item/2045345825172639746/fee-deduction-preview  → 200 + 8 条独立 VO ✓
  - 4 景区 + 4 游玩项目,无「住宿」
  - icon non-null 7/8(对应行程节点 emoji_icon 已设)
  - feeType breakdown 与小程序产品详情「景区门票/游玩项目」chip 精确对应

UPDATE product_fee_item ... → 117 条 / 32 个产品 软删,清理精准(excludedFees 7 条保留) ✓

GET /admin/product/item/2045345825172639746  → includedFees=[](清理生效) ✓
```

验证产品: `productId=2045345825172639746`(测试服「游牧的森林-短途版」)

---

## 九、相关历史 PR

| PR | Issue | 说明 |
|----|-------|------|
| 早期 | (无) | 「自动生成」按钮的合并字符串实现 |
| **#2484** | **#2477** | 取消合并 + 去住宿 + 带 icon,本次 |

---

## 十、相关文档

- 关联 Issue: [wx/HL#2477](https://git.1814.love:8443/wx/HL/issues/2477)
- 关联 PR: [wx/HL#2484](https://git.1814.love:8443/wx/HL/pulls/2484)
