---
date: 2026-05-12
type: backend-fix
service: hl-product-service-v2
priority: high
notify: ["@mmg"]
status: verified
---

# 价格日历:小童优惠 toddlerDiscount 校验 + 兜底归一化(已测试服端到端验证)

> **PR**: [#2102](https://git.1814.love:8443/wx/HL/pulls/2102) + hotfix [#2107](https://git.1814.love:8443/wx/HL/pulls/2107) — 全部合并 dev + 部署测试服
> **Issue**: [#2101](https://git.1814.love:8443/wx/HL/issues/2101)
> **接口**: `POST /admin/product/item/{id}/price-calendar/batch`
> **状态**: ✅ **测试服已验证** (api.test.1814.love:9443) — 4 case 端到端 curl 通过 + DB 落库确认

---

## ⚠️ 业务铁规(用户确认)

小童优惠 `toddlerDiscount` 语义 = 「相对儿童价的折扣额」, 后端强制约束:

1. **0 ≤ toddlerDiscount ≤ childSellPrice** — 负数无意义, 大于儿童价反义
2. **childSellPrice = 0 时 toddlerDiscount 强制 null** — 儿童免费, 小童优惠无业务语义, 后端兜底覆盖即使前端误传非 0

注:**toddlerDiscount 是正数** (折扣额). 后端公式 `toddlerPrice = max(0, childPrice - toddlerDiscount)`, 越大小童越便宜.

---

## 一、测试服已验证 (硬性凭证)

```
Case 1: child=100, toddler=200 (越界)
  → HTTP 200, code=410104, msg="小童优惠额不能超过儿童售价" ✓

Case 2: child=100, toddler=-10 (负数)
  → HTTP 200, code=410113, msg="小童优惠不能为负数, 当前值: -10" ✓

Case 3: child=0, toddler=10 (儿童免费场景)
  → HTTP 200, code=200, success=true
  → GET 价格日历: date=2099-01-01 child=0.0 toddlerDiscount=None
  → DB 落库 toddler_discount = NULL ✓ (兜底归一化生效)

Case 4: child=100, toddler=30 (正常)
  → HTTP 200, code=200, success=true
  → DB 落库 toddler_discount = 30 ✓
```

---

## 二、变更接口清单

| # | 接口 | 方法 | 变更类型 | 说明 |
|---|------|------|----------|------|
| 1 | 价格日历批量设置 | POST `/admin/product/item/{id}/price-calendar/batch` | **新增校验 + 兜底归一化** | toddlerDiscount<0 抛 410113; child=0 时 toddlerDiscount 强制 null |

**响应字段不变**, 只是请求体校验更严格 + child=0 时自动归一化.

---

## 三、错误码

| code | 错误码常量 | 消息 | 触发场景 |
|------|----------|------|---------|
| **410113** | `PRICE_TODDLER_DISCOUNT_NEGATIVE` | 小童优惠不能为负数, 当前值: {0} | **新增**. toddlerDiscount < 0 |
| 410104 | `PRICE_TODDLER_DISCOUNT_EXCEEDS_CHILD` | 小童优惠额不能超过儿童售价 | 既有. toddlerDiscount > childSellPrice |

注:`childSellPrice == 0` 时**先在校验入口被兜底归一化 toddlerDiscount=null**, 因此 child=0 的请求**不会**触发 410104, 即使前端传了非 0 的 toddler.

---

## 四、需要前端做的事 (@mmg)

### 1) `PriceIntervalEditor.vue` (价格区间编辑表单)

```vue
<!-- 小童优惠额字段, 第 358-361 行附近 -->
<NInputNumber
  v-model:value="formData.toddlerDiscount"
  :min="0"                                   <!-- ✅ 新增 - 不允许负数 -->
  :max="formData.childSellPrice || 0"        <!-- ✅ 新增 - 不超过儿童价 -->
  :disabled="!formData.childSellPrice"       <!-- ✅ 新增 - 儿童=0/null 时锁死 -->
  :precision="2"
  placeholder="选填,正数=折扣额,不能大于儿童价"  <!-- ✅ 改 - 原"选填,负数"语义反 -->
  :show-button="false"
  style="width: 100%"
  @focus="handleNumberFocus">
  <template #prefix>¥</template>
</NInputNumber>
```

加 watch:儿童价改为 0 时,自动把 toddlerDiscount 清零:

```js
watch(() => formData.value.childSellPrice, (newChild) => {
  if (newChild === 0 || newChild === null) {
    formData.value.toddlerDiscount = 0  // 或 null
  }
})
```

### 2) `CalendarDayModal.vue` (单日价格编辑) — 同样改动

第 88-100 行附近的 toddlerDiscount NInputNumber:
- `:min="0"`、`:max="formData.childSellPrice || 0"`、`:disabled="!formData.childSellPrice"`
- placeholder "负数,如 -500" → "正数=折扣额"
- 加同样的 watch

### 3) CSS 误判修正(可选,辅助)

`PriceIntervalEditor.vue` 第 562-565 行 `.pie-price-label` 当前用 `var(--text-color-placeholder)` 浅灰色,看起来像 disabled. 改 `var(--text-color)` 深色, 跟成人/儿童/单房差的 NFormItem label 一致.

---

## 五、风险/兼容性

- **历史脏数据**:DB 里如果存在 `childSellPrice=0 && toddlerDiscount!=null` 的脏数据, **下次该区间编辑保存**会被 step 0 自动清掉(用户无感知). 无需单独数据迁移.
- **历史脏数据 (toddler 负数)**:如果有 toddler<0 的旧数据, **下次编辑时**会被 410113 拦截, 用户需手动清零后才能保存. 影响面小(前端原 placeholder 引导用户输负数, 但实际公式按正数算, 历史负数会让小童变贵,理论上业务方早就发现了).
- **不破坏现有调用**:正常 toddler ∈ [0, childPrice] 的请求行为完全不变.

---

## 六、撤回声明

我今天 15:58 发的 changelog `12_frontend_bug_admin_price-interval-toddler-discount-locked-when-child-zero.md`(诊断 CSS 误导 + 字段语义反 + 单日覆盖 3 根因)**已撤回**(文件删除). 当时未确认业务规则, 错把"儿童=0 时小童优惠应该=0"当成 bug. 用户澄清后这是合理的业务联动, 后端 PR #2102+#2107 已实现兜底.

@mmg 按上面 1)2)3) 三段改 hl-ui 即可, 后端已测过.
