# ⚠️ 破坏性变更：价格日历批量接口字段强校验

> **类型**: 🔴 **BREAKING CHANGE（破坏性变更，老前端会 400）**
> **服务**: hl-product-service-v2（端口 8083）
> **接口**: `POST /admin/product/item/{id}/price-calendar/batch`
> **Issue**: #747
> **PR**: #748（已合并到 dev）
> **Commit**: `c311e6ee`
> **日期**: 2026-04-17
> **影响范围**: 管理后台 Step4 定价管理 — 批量设置价格

---

## 🚨 一句话摘要（最重要）

**3 个字段从「兜底默认值」升级为「强校验必传」。老前端如果不传 `childSellPrice` / `tierSeq` / `priceType` 会直接返回 400，无法保存价格日历。前端必须同步升级表单必填校验。**

---

## 一、原漏洞（测试服实测）

测试服（升级前）存在以下脏数据写入漏洞：

| # | 用例 | 原行为（❌ 漏洞） |
|---|------|------------------|
| 1 | `childSellPrice` 缺失 / 为 null | 允许保存（儿童价变 null，前端列表显示空） |
| 2 | `tierSeq=99`（产品实际只有 5 档） | 允许保存（脏数据，点查详情无法匹配档位） |
| 3 | `priceType=XXX`（非字典枚举值） | 允许保存（污染字典） |
| 4 | `adultSellPrice=0` 或负数 | 允许保存（0 元购，业务事故） |
| 5 | `startDate > endDate` | 允许保存（生成 0 条价格但 200） |

## 二、根因

`PriceCalendarBatchSaveReqVO` 对三个关键字段只做了「空值给默认值」的兜底处理，未使用 `@NotNull / @NotBlank / @Pattern`；Service 层也未校验 `tierSeq` 是否在产品实际档位范围内。

## 三、修复后行为（本地经网关实测）

| 用例 | HTTP | 响应 message |
|------|------|--------------|
| T1 正常请求 | **200** | `价格日历设置成功` |
| T2 缺 `childSellPrice` | **400** | `childSellPrice: 儿童售价不能为空` |
| T3 `tierSeq=99`（5 档产品） | **500** | `档位序号99不存在，本产品只有5档` |
| T4 `adultSellPrice=0` | **400** | `adultSellPrice: 成人售价必须大于0` |
| T5 `priceType=XXX` | **400** | `priceType: 价格类型必须是 NORMAL/PEAK/HOLIDAY/SPECIAL 之一` |
| T6 `startDate > endDate` | **500** | `开始日期不能晚于结束日期` |

---

## 四、接口契约

### 4.1 接口签名（未变）

| 方法 | 路径 | 说明 |
|------|------|------|
| POST | `/admin/product/item/{id}/price-calendar/batch` | 批量设置某档位某日期区间的价格 |

### 4.2 请求体字段校验矩阵

| 字段 | 类型 | 必填 | 校验规则 | 说明 |
|------|------|------|----------|------|
| `startDate` | String (yyyy-MM-dd) | ✅ | `@NotNull` | 区间开始日期（含） |
| `endDate` | String (yyyy-MM-dd) | ✅ | `@NotNull` + Service 校验 `endDate ≥ startDate` | 区间结束日期（含） |
| `tierSeq` | Integer | ✅ | 🔴 新：`@NotNull + @Min(1)` + Service 校验 `tierSeq ≤ 产品实际档位数` | 档位序号（1 起），多档位产品必传 |
| `priceType` | String | ✅ | 🔴 新：`@NotBlank + @Pattern(NORMAL\|PEAK\|HOLIDAY\|SPECIAL)` | 价格类型字典值 |
| `adultSellPrice` | BigDecimal | ✅ | 🔴 加强：`@NotNull + @DecimalMin("0.01")` | 成人售价，必须 > 0 |
| `childSellPrice` | BigDecimal | ✅ | 🔴 新：`@NotNull + @DecimalMin("0.01")` | 儿童售价，必须 > 0 |
| `toddlerDiscount` | BigDecimal | 否 | — | 幼童价格调整（可负数，如 -500 表示减 500） |
| `infantPrice` | BigDecimal | 否 | — | 婴儿价格 |
| `dailyStock` | Integer | 否 | — | 每日库存 |

### 4.3 🔴 破坏性变更对比表（前端必改）

| 字段 | 升级前（老行为） | 升级后（新强校验） | 前端必须动作 |
|------|------------------|---------------------|--------------|
| `childSellPrice` | 可不传 / 可为 null → 落库 null | `@NotNull + @DecimalMin(0.01)` | **必须让用户填写并提交** |
| `tierSeq` | 可不传 → 后端默认 1 | `@NotNull + @Min(1)` + 不能超过产品档位数 | **必须传当前选中 tab 的 tierSeq** |
| `priceType` | 可不传 → 后端默认 NORMAL | `@NotBlank + @Pattern` 只能是 NORMAL/PEAK/HOLIDAY/SPECIAL | **必须传字典枚举值（表单下拉默认 NORMAL）** |
| `adultSellPrice` | `@NotNull`（允许 0） | `@NotNull + @DecimalMin(0.01)`（不能为 0 或负） | **表单校验 > 0** |

---

## 五、完整请求 / 响应示例

### 5.1 请求体（合法）

```json
POST /admin/product/item/2045018534152478721/price-calendar/batch
Content-Type: application/json

{
  "startDate": "2026-11-01",
  "endDate": "2026-11-05",
  "tierSeq": 3,
  "priceType": "NORMAL",
  "adultSellPrice": 3515,
  "childSellPrice": 1175,
  "toddlerDiscount": -500,
  "infantPrice": 500,
  "dailyStock": 20
}
```

### 5.2 成功响应（200）

```json
{
  "code": 200,
  "message": "价格日历设置成功",
  "data": null,
  "success": true
}
```

### 5.3 失败响应 — @Valid 校验失败（400）

缺 `childSellPrice`：

```json
{
  "code": 400,
  "message": "childSellPrice: 儿童售价不能为空",
  "data": null,
  "success": false
}
```

`adultSellPrice=0`：

```json
{
  "code": 400,
  "message": "adultSellPrice: 成人售价必须大于0",
  "data": null,
  "success": false
}
```

`priceType=XXX`：

```json
{
  "code": 400,
  "message": "priceType: 价格类型必须是 NORMAL/PEAK/HOLIDAY/SPECIAL 之一",
  "data": null,
  "success": false
}
```

### 5.4 失败响应 — Service 业务校验失败（500，BusinessException）

`tierSeq=99`（产品只有 5 档）：

```json
{
  "code": 500,
  "message": "档位序号99不存在，本产品只有5档",
  "data": null,
  "success": false
}
```

`startDate > endDate`：

```json
{
  "code": 500,
  "message": "开始日期不能晚于结束日期",
  "data": null,
  "success": false
}
```

> ⚠️ 前端展示错误时：**400 和 500 都要弹 toast 显示 `message`**，不能只处理 200。

---

## 六、前端应配合改进的 4 条（P1）

### 1. 保存按钮置灰（多档位产品）

多档位产品（`tiers.length > 1`）的「确认添加」/「保存」按钮：

- **所有档位 tab 必须都填完「成人售价 + 儿童售价」才允许点击**
- 任一档位缺价 → 按钮禁用 + 提示「请完成所有档位的定价」

### 2. 切 tab 时 inline 校验

用户在档位 tab A 填了一半想切到 tab B：

- 当前 tab 的 `adultSellPrice` 或 `childSellPrice` 未填 → 禁止切走 + 弹提示「请先填完当前档位的必填项」
- 避免用户切走后忘了回来，最后提交时才发现少档位

### 3. 服务端 400 / 500 错误高亮

- 400 错误的 `message` 格式为 `{fieldName}: {中文说明}`，可按冒号切分定位到具体字段 → 表单红框高亮
- 500 业务异常（如「档位序号99不存在，本产品只有5档」）直接 toast 显示 message，前端无需猜测语义
- 若后续有多档聚合校验，异常消息可能形如 `档位【舒适】缺少儿童售价` — 可用正则 `/【(.+?)】/` 提取档位名，滚动到对应 tab

### 4. 单档位产品同样校验

`tiers.length === 1` 的产品也必须校验「成人售价 + 儿童售价」非空非 0，不能因为没 tab 就放行。

---

## 七、字典速查 — `priceType` 枚举

| 值 | 中文 | 说明 |
|----|------|------|
| `NORMAL` | 平日 | 默认值（工作日通用） |
| `PEAK` | 旺季 | 节假日 / 旅游旺季 |
| `HOLIDAY` | 节假日 | 法定节假日 |
| `SPECIAL` | 特殊日 | 大促 / 特价 |

前端下拉框硬编码这 4 个选项即可（或走字典接口 `/admin/dict-data/type/price_type`）。

---

## 八、重启提示

- 需要重启：`hl-product-service-v2`（端口 8083）
- 部署方式：通过 Deploy Panel 重新部署最新 jar（测试服 `192.168.100.160`）

---

## 九、合并信息

| 项目 | 值 |
|------|-----|
| Issue | #747 |
| PR | #748 |
| 合并 commit | `c311e6ee` |
| 目标分支 | `dev` |
| 服务 | `hl-product-service-v2` |

---

## 十、优先级

🔴 **P0 破坏性** — 老前端发版后如果不同步改，会让「批量设置价格」整条链路 400 失败，定价流程完全走不通。**前端必须在后端发版前或同步发版，别拖。**
