diff --git a/changelogs/2026-05/18_feat_product_fee_included_split_no_hotel_with_icon.md b/changelogs/2026-05/18_feat_product_fee_included_split_no_hotel_with_icon.md new file mode 100644 index 0000000..dc399ea --- /dev/null +++ b/changelogs/2026-05/18_feat_product_fee_included_split_no_hotel_with_icon.md @@ -0,0 +1,193 @@ +# 产品费用包含: 自动生成不再产「住宿」+ 每节点 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>` + +| 字段 | 类型 | 说明 | +|------|------|------| +| 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": "" + }, + { "feeType": "景区", "name": "黑山头", "source": "AUTO", "sourceNodeName": "黑山头", "icon": "" }, + { "feeType": "景区", "name": "百里雾凇画廊", "source": "AUTO", "sourceNodeName": "百里雾凇画廊", "icon": "" }, + { "feeType": "景区", "name": "海拉尔国家森林公园", "source": "AUTO", "sourceNodeName": "海拉尔国家森林公园", "icon": "" }, + { "feeType": "游玩项目", "name": "满洲里草原篝火晚会", "source": "AUTO", "sourceNodeName": "满洲里草原篝火晚会", "icon": "" }, + { "feeType": "游玩项目", "name": "冬日烟花", "source": "AUTO", "sourceNodeName": "冬日烟花", "icon": null }, + { "feeType": "游玩项目", "name": "ATV全地形越野车穿越", "source": "AUTO", "sourceNodeName": "ATV全地形越野车穿越", "icon": "" }, + { "feeType": "游玩项目", "name": "蒙古族小型冰雪那达慕", "source": "AUTO", "sourceNodeName": "蒙古族小型冰雪那达慕", "icon": "" } + ], + "success": true +} +``` + +### 2 / 3 / 4. 保存与详情接口 + +VO 结构未变(`FeeItem.icon` / `FeeInfo.icon` 字段早已预留),只是含义换成「N 条独立项」:每个景区/游玩项目 1 个 chip,前端无需聚合。 + +--- + +## 四、契约与渲染建议(前端必看) + +### icon 字段三种格式都要兼容渲染 + +| 来源 | icon 形态 | 渲染方式建议 | +|------|-----------|------| +| 自动推导(行程节点 emojiIcon) | lucide 图标名(如 `"mountain"`)或 emoji(如 `"🏔"`) | lucide 图标渲染器 + emoji 直接 text 渲染 | +| 手动添加(图标库选择) | SVG 字符串 `...` | dangerouslySetInnerHTML 渲染 | +| 节点没设图标 | `null` | 占位或不渲染 | + +**最简兼容写法**:`if (icon?.startsWith('