# fix(product-v2): 小童优惠0归一化null + 价格日历tierSeq拆箱加固

> **服务**: hl-product-service-v2 (端口 8083)
> **PR**: #827
> **Issue**: #826
> **Merge commit**: `d9d808cf7594`
> **日期**: 2026-04-18
> **影响范围**: 管理端产品价格日历（批量保存/按月查询）+ 报价计算

---

## 一、背景

本次处理两类问题：

### BUG-3：小童优惠（toddlerDiscount）保存为 0 的语义歧义
- 现象：前端表单 `toddlerDiscount` 字段初始绑定为 `0`，用户不填也会传 `0`，后端照实存成 `0.00`
- 语义问题：`0` 有两种解读 ——「0 元优惠（就是没优惠）」 vs 「没设置优惠（未填）」，容易让后台回显/审核人员误会
- 根因实证：后端本身对 null 和 0 的处理是正确的（DB 允许 NULL，查询按实际值回显），问题发生在前端默认值
- **本次决策（方案 A）**：后端兜底归一化，`toddlerDiscount=0` 入库时自动转成 `NULL`
- `infantPrice=0` **不做归一化**，因为「幼童价=0」=「幼童免费」是合法业务语义，不能误伤

### NPE 加固：Integer tierSeq 拆箱风险
- 分析员代码走查发现两处 `Integer == int` 自动拆箱点：
  - `ProductPricingService.calculateQuote`（L473）：若某条 `product_price_calendar.tier_seq` 为 NULL，报价会直接 500 NPE
  - `PriceCalendarAggregator`（L81）：原有 `!= null` 守卫实际拆箱安全，但为一致性一并改写
- 改用 `java.util.Objects.equals(a, b)` 规避拆箱

### BUG-4（暂挂）：前端报告「小蒙马设计价格日历 500」
- 测试环境实证：两个 GROUP 产品（`2044306857534636034`、`2043696114145644545`）的价格日历/报价/班期相关全部接口均 200
- 历史服务日志已滚动丢失，无法追溯原始 500 堆栈
- 本次先部署 NPE 加固，降低再次出现拆箱 500 的概率；若复现可抓新堆栈再精准定位

---

## 二、变更接口清单

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|---------|------|
| 1 | 批量保存价格日历 | POST | `/admin/product/item/{id}/price-calendar/batch` | 入库逻辑变更 | `toddlerDiscount=0` 后端自动归一化为 `NULL`；`infantPrice=0` 保留不变 |
| 2 | 按月查询价格日历 | GET | `/admin/product/item/{id}/price-calendar?month=YYYY-MM&tierSeq=X` | 回显值可能变化 | 原存 `0` 的 `toddlerDiscount` 本次保存后回显为 `null`（未动存量） |

> 报价接口 `POST /admin/product/item/{id}/quote` 和建议价 `GET /admin/product/item/{id}/suggest-price` 路由不变、入参出参不变，仅内部加固了 tierSeq 拆箱。

---

## 三、接口详情

### 1. 批量保存价格日历 `POST /admin/product/item/{id}/price-calendar/batch`

**请求 body 字段未变**（本次只改后端落库逻辑）：

| 字段 | 类型 | 必填 | 说明 | 本次行为变化 |
|------|------|------|------|-------------|
| `toddlerDiscount` | BigDecimal | 否 | 小童优惠额（元/人） | ⚠️ 传 `0` 后端自动转 `NULL` 入库；传正数/负数保持 |
| `infantPrice` | BigDecimal | 否 | 幼童价（元/人） | 无变化，`0` 保持 `0`（=免费） |
| 其他字段 | - | - | - | 无变化 |

**落库行为对照表**：

| 前端传入 | DB `toddler_discount` 列 | DB `infant_price` 列 |
|---------|--------------------------|----------------------|
| `toddlerDiscount=0, infantPrice=0` | `NULL` ✅ | `0.00` ✅ |
| `toddlerDiscount=50, infantPrice=100` | `50.00` | `100.00` |
| 两字段都不传 | `NULL` | `NULL` |
| `toddlerDiscount=-500` | `-500.00`（不动负数） | - |

### 2. 按月查询价格日历 `GET /admin/product/item/{id}/price-calendar`

响应结构未变，仅 `toddlerDiscount` 字段可能为 `null`：

```json
{
  "code": 0,
  "data": [
    {
      "priceDate": "2026-06-01",
      "tierSeq": 1,
      "adultSellPrice": 1500.00,
      "childSellPrice": 1200.00,
      "infantPrice": 0.00,
      "toddlerDiscount": null
    }
  ]
}
```

---

## 四、前端影响与必须配合

### 影响 1：`toddlerDiscount` 回读值可能从 `0` 变成 `null`

如果前端当前代码这样用：

```js
// ❌ 旧写法（只判 0）
if (form.toddlerDiscount === 0) { /* 视为未设置 */ }

// ❌ 旧写法（只判 null）
if (form.toddlerDiscount == null) { /* 视为未设置 */ }
```

请改为：

```js
// ✅ 推荐：两者都视为"未设置/0 优惠"
if (form.toddlerDiscount == null || form.toddlerDiscount === 0) {
  // 显示为空输入框 / 文案"未设置优惠"
}
```

**语义上 null 和 0 都表示「不减免」，对报价计算（后端 `calculateQuote` 已 `!= null ? x : ZERO` 兜底）零影响**。前端只需保证展示和编辑回填不出 bug。

### 影响 2：`infantPrice` 无变化

- `infantPrice=0` 合法且保留（= 幼童免费）
- 前端逻辑沿用原有判断，**不要**跟着 `toddlerDiscount` 一起归一化

### 影响 3：根治建议（可选）

建议前端同步修复表单默认值：`toddlerDiscount` 初始值由 `0` 改为 `null/undefined`，避免"未填"被序列化成 `0`。后端归一化已做兜底，前端改不改都能用。

---

## 五、NPE 加固说明

### 加固点 1：`ProductPricingService.calculateQuote`

原代码（存在拆箱 NPE）：

```java
int tierSeq = req.getTierSeq() != null ? req.getTierSeq() : ProductConstants.DEFAULT_TIER_SEQ;
// ↓ p.getTierSeq() 为 null 时自动拆箱 → NullPointerException
.filter(p -> p.getTierSeq() == tierSeq)
```

现代码：

```java
Integer tierSeq = req.getTierSeq() != null ? req.getTierSeq() : ProductConstants.DEFAULT_TIER_SEQ;
.filter(p -> Objects.equals(p.getTierSeq(), tierSeq))
```

**影响**：若 DB `product_price_calendar.tier_seq` 历史脏数据有 `NULL` 行，过去调用 `POST /admin/product/item/{id}/quote` 会 500 NPE；**现在改为正常业务异常 `NotFoundException`（查不到对应档位价格）**，HTTP 仍返 200，`Result.code` 为业务错误码。

### 加固点 2：`PriceCalendarAggregator`

原代码有 `!= null` 守卫，拆箱实际安全，但为一致性改写成 `Objects.equals`，降低后续维护误改风险。

---

## 六、BUG-4 仍在排查（前端请补充信息）

前端同事反馈的「小蒙马设计价格日历 500」本次**未能在测试环境复现**：

- 两个 GROUP 产品全部相关接口实测 200
- 历史日志已丢失，无法还原原始堆栈

**请前端同事补充以下信息，才能精准定位**：

1. **完整接口 URL + HTTP method**（例如 `POST /admin/product/item/123/price-calendar/batch`）
2. **产品 ID**
3. **操作步骤**：「设计价格日历」具体在编辑页第几步、点了哪个按钮、是否涉及档位切换/班期绑定
4. **请求 payload**（从浏览器 DevTools Network 复制 Request body）
5. **响应 body**（如能截到 500 的完整响应）

本次部署已经加固 2 处可能的 NPE 拆箱点，如果再次复现 500，可以从新日志抓到更清晰的堆栈，再做针对性修复。

---

## 七、未受影响接口（明确说明）

- 产品创建/编辑/审核/上架下架（`/admin/product-basic/*`、`/admin/product/item/{id}/toggle-publish` 等）：零影响
- 报价接口 `POST /admin/product/item/{id}/quote`：路由/入参/出参不变，仅内部 NPE 加固
- 建议价 `GET /admin/product/item/{id}/suggest-price`：零影响
- 班期管理 `/admin/product/item/{id}/schedule/*`：零影响
- 小程序端全部接口：零影响

---

## 八、受影响服务 + 重启提示

- **仅需重启** `hl-product-service-v2`（端口 8083）
- 测试环境已通过 Deploy Panel 完成部署（commit `d9d808cf7594`）
- 正式环境尚未部署

---

## 九、相关链接

- Issue: https://git.1814.love:8443/wx/HL/issues/826
- PR: https://git.1814.love:8443/wx/HL/pulls/827
- Merge commit: `d9d808cf7594`
