# 修复：daily-mileage 节点过滤 & Step5 上架预检必填校验

> **服务**: hl-product-service-v2 (端口 8083)
> **PR**: #754
> **Issue**: #751
> **日期**: 2026-04-17
> **影响范围**: Step2 行程编排里程试算、Step5 补充信息上架预检

---

## 问题 1（P1 阻塞）：Step2 行程编排「计算每日里程」接口 400

### 现象
前端在 Step2 行程编排点"下一步"，后端对节点列表里 `resourceId` 为空的自由活动节点返回：

```json
{
  "code": 400,
  "message": "days[0].nodes[1].resourceId: resourceId不能为空"
}
```

### 根因
`DailyMileageCalcReqVO.NodeRef.resourceId` 挂了 `@NotNull`，但业务上 `FREE/NOTE/TRANSPORT/CUSTOM/PHOTOGRAPHY` 等节点本就不需要 resourceId（它们没有对应的景区/活动/酒店资源）。

### 修复
- 移除 `@NotNull` 和 `required=true`
- 接口层面接受任何节点类型传入；Service 层按节点类型白名单自动过滤，不抛错

---

## 问题 2（业务规则）：距离计算节点范围收窄 + 酒店去重

### 业务规则
每日驾车距离/时间计算只取：**景区（SCENIC）+ 游玩项目（ACTIVITY）+ 当天第一个酒店（HOTEL）**。

### 规则前后对比

| 节点类型 | 旧逻辑 | 新逻辑 |
|----------|--------|--------|
| SCENIC | ✅ 参与 | ✅ 参与 |
| ACTIVITY | ✅ 参与 | ✅ 参与 |
| HOTEL | ✅ 全部参与 | ⚠️ **每天只保留 sortOrder 最小的那 1 个** |
| RESTAURANT | ✅ 参与 | ❌ **不参与** |
| SERVICE | ✅ 参与 | ❌ **不参与** |
| TRANSPORT / FREE / CUSTOM / NOTE / PHOTOGRAPHY | ❌ 不参与 | ❌ 不参与（无变化） |

举例：当天节点序列 `景区1 → 餐厅 → 景区2 → 酒店A → 酒店B → 活动1`
- 旧：6 点参与 → `dist(景1→餐→景2→店A→店B→活1)`
- 新：4 点参与 → `dist(景1→景2→店A→活1)`（餐厅/酒店B 过滤）

### 影响接口
| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/product/item/{id}/daily-mileage` | Step3 实时试算（前端传节点序列） |
| GET | `/admin/product/item/{id}/mileage` | 总里程（基于保存后的节点） |
| GET | `/admin/product/item/{id}/daily-mileage-saved` | 基于保存后节点的按天里程 |

### 请求/响应契约
**请求字段（无变化，仅校验收紧/放宽）**：
```json
{
  "days": [
    {
      "dayNumber": 1,
      "nodes": [
        {"nodeType": "SCENIC",     "resourceId": 3001},
        {"nodeType": "FREE"},                        // resourceId 可空
        {"nodeType": "RESTAURANT", "resourceId": 4001},
        {"nodeType": "HOTEL",      "resourceId": 5001},
        {"nodeType": "HOTEL",      "resourceId": 5002}  // 同日第二个酒店 → 过滤
      ]
    }
  ]
}
```

**响应结构（无变化）**：
```json
{
  "code": 200,
  "success": true,
  "data": [
    {"dayNumber": 1, "mileage": 42.35, "duration": 3600}
  ]
}
```

### 字段规范

| 字段 | 类型 | 说明 |
|------|------|------|
| `days[].dayNumber` | Integer | 天序号，从 1 开始 |
| `days[].nodes[].nodeType` | String | 节点类型（字典 `product_node_type`）：`SCENIC / ACTIVITY / HOTEL / RESTAURANT / SERVICE / TRANSPORT / FREE / CUSTOM / NOTE` |
| `days[].nodes[].resourceId` | Long | **只在 SCENIC/ACTIVITY/HOTEL 时必填**；其他节点允许为空 |

---

## 问题 3：Step5 上架预检补必填校验

### 现象
产品 Step5 补充信息里的「保险方案」「合同方案」「行程保障模板」即使没选，**`validatePublish` 也不报 issue**，导致产品能被提交上架但实际缺字段。

### 修复
在 **`GET /admin/product/item/{id}/validate-publish`** 的 L4 条款校验阶段，新增 3 条 issue：

| 条件 | 新增 issue 文案 |
|------|-----------------|
| `insuranceNotice` ∈ `{INCLUDED, OPTIONAL}` 且 `insuranceSchemeId == null` | `"保险方案未选择"` |
| `contractSchemeId == null`（不论保险类型） | `"合同方案未选择"` |
| `guaranteeTemplateId == null`（不论保险类型） | `"行程保障模板未选择"` |

### 响应示例（缺字段时）

```json
{
  "code": 200,
  "success": true,
  "data": [
    "保险方案未选择",
    "合同方案未选择",
    "行程保障模板未选择"
  ]
}
```

### 保存草稿接口保持宽松
- `PUT /admin/product/item/{id}/supplement` **不新增校验** —— 继续允许分步填写（空字段能保存）
- 校验只发生在**上架预检**阶段

### 字典 insurance_notice

| Code | 说明 |
|------|------|
| `INCLUDED` | 含保险（必选保险方案） |
| `OPTIONAL` | 可选保险（必选保险方案） |
| `EXCLUDED` | 不含保险（可不选保险方案） |

---

## 前端适配

### Step2 行程编排
- **无需改动**。前端传原样节点序列即可；后端自动按节点类型过滤不参与计算的节点。
- 旧行为："自由活动"节点进入里程接口时 400 —— 已修复，不再会出现。

### Step3 实时里程试算
- 如果前端之前有"过滤掉餐厅/服务"的代码，**可以去除**（后端已处理）。不去除也无影响（重复过滤 = 过滤）。

### Step5 上架预检
- 前端调 `validate-publish` 时，错误列表里会**新出现**如下 3 个文案，请前端错误映射里补上：
  - `"保险方案未选择"`
  - `"合同方案未选择"`
  - `"行程保障模板未选择"`
- 表现为"点上架 → 后端返回这些 issue → 前端提示用户去 Step5 补充"。

---

## 单元测试

- `AmapDrivingServiceTest`: 21 条（含"同日多酒店取首"、"餐厅/服务过滤"、"跨天多酒店各自取首"三条新增）
- `ProductValidationServiceTest`: 14 条（含 insuranceScheme / contractScheme / guaranteeTemplate 缺失各 1 条）

## 测试环境验证

`api.test.1814.love:9443` 实测通过：

| 用例 | 结果 |
|------|------|
| `POST /daily-mileage` 混入 FREE 节点 | 200 ✅ |
| `POST /daily-mileage` 同日 3 个 HOTEL | 200（按规则只取首个）✅ |
| `POST /daily-mileage` 含 RESTAURANT+SERVICE | 200（已过滤）✅ |
| `GET /validate-publish` 缺 3 字段 | 3 条 issue ✅ |

## 重启提示

需要重启 `hl-product-service-v2`（端口 8083 + 8183）—— 通过 Deploy Panel 重新部署最新 jar。
