diff --git a/changelogs/2026-05/12_fix_product_v2_toddler_discount_normalize_and_validate.md b/changelogs/2026-05/12_fix_product_v2_toddler_discount_normalize_and_validate.md new file mode 100644 index 0000000..4622aa4 --- /dev/null +++ b/changelogs/2026-05/12_fix_product_v2_toddler_discount_normalize_and_validate.md @@ -0,0 +1,127 @@ +--- +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 + + + :max="formData.childSellPrice || 0" + :disabled="!formData.childSellPrice" + :precision="2" + placeholder="选填,正数=折扣额,不能大于儿童价" + :show-button="false" + style="width: 100%" + @focus="handleNumberFocus"> + + +``` + +加 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 即可, 后端已测过. diff --git a/changelogs/2026-05/12_frontend_bug_admin_price-interval-toddler-discount-locked-when-child-zero.md b/changelogs/2026-05/12_frontend_bug_admin_price-interval-toddler-discount-locked-when-child-zero.md deleted file mode 100644 index 1db7633..0000000 --- a/changelogs/2026-05/12_frontend_bug_admin_price-interval-toddler-discount-locked-when-child-zero.md +++ /dev/null @@ -1,164 +0,0 @@ ---- -date: 2026-05-12 -type: frontend-bug -module: admin-price-interval-editor -priority: high -notify: ["@mmg"] -status: pending ---- - -# 价格区间编辑:儿童价=0 时小童优惠看起来「不可编辑」且回显为 0 - -## 现象(用户反馈) - -产品编辑 → 第 4 步「定价管理」→ 点某价格区间「编辑」: - -1. 区间原本数据:儿童 ¥X、**小童优惠 ¥10** -2. 用户把儿童价改成 0,保存 -3. 重新打开发现:**小童优惠展示成 ¥0**、看起来**改不动**(label 浅色像 disabled) - -期望:儿童=0 是合法业务场景(儿童与成人同价/不收儿童费),**小童优惠不应受儿童=0 影响**,既要可编辑,也要保留原值 10。 - -文件:`hl-ui/src/views/product/edit/components/pricing/PriceIntervalEditor.vue` - -## 根因分析(已查代码) - -### 根因 1 - **CSS 误导:label 颜色让用户误判 disabled** - -```css -/* 第 562-565 行 */ -.pie-price-label { - font-size: 11px; - color: var(--text-color-placeholder); /* ❌ 浅灰 placeholder 色 */ -} -``` - -成人/儿童/单房差用的是 `NFormItem :label="..."` (深色文字),小童优惠额/幼童用的是 `...` (浅灰),**视觉上看起来一个深一个浅**。再叠加 NInputNumber 当 value=0 时数字也是浅色,用户**100% 会误判为 disabled**。 - -实际上第 358-361 行 NInputNumber **没有 disabled 属性**: - -```vue -
- 小童优惠额 - - - -
-``` - -→ 点输入框其实**可以输入**,只是看起来不能。 - -### 根因 2 - **toddlerDiscount 字段语义跟后端公式相反** - -```vue -placeholder="选填,负数" -``` - -但后端 `ProductPricingService.java` 第 706 行计算公式: - -```java -BigDecimal toddlerPrice = childPrice.subtract(toddlerDiscount).max(BigDecimal.ZERO); -// 即 toddlerPrice = childPrice - toddlerDiscount -``` - -要让小童**比儿童便宜**(toddlerPrice < childPrice),需要 `toddlerDiscount > 0` (**正数**)。 - -如果用户按 placeholder 提示输 -10: -- `toddlerPrice = childPrice - (-10) = childPrice + 10` → 小童反而**贵 10 元** -- 完全相反语义,业务上错的 - -### 根因 3 - **后端会把 toddlerDiscount=0 归一化为 null** - -```java -// ProductPricingService.java 第 367-372 行 -// 小童优惠 0 语义歧义("0 优惠" vs "未设置"),统一归一化为 null -if (template.getToddlerDiscount() != null - && BigDecimal.ZERO.compareTo(template.getToddlerDiscount()) == 0) { - template.setToddlerDiscount(null); -} -``` - -本身合理。但如果**前端**: -- 用户没改 toddlerDiscount(原值 10) → handleConfirm 第 249 行 `body.toddlerDiscount = f.toddlerDiscount` 应该传 10 → DB 存 10 ✓ -- 用户改成 0 → 后端归一化为 null → DB 存 null ✓ (符合"未设置") -- 但用户**没动这个字段**且**重开发现是 0** → 说明在某个保存路径里 toddlerDiscount 被**意外清成了 null/0** - -### 根因 4 - **怀疑 CalendarDayModal 单日编辑会覆盖整区间** - -`hl-ui/src/views/product/edit/components/CalendarDayModal.vue` 单日改时,如果用户只改儿童=0、没动小童优惠 → `formData.toddlerDiscount = info?.toddlerDiscount ?? null` 第 256 行,如果传入的 info 没带 toddlerDiscount,默认 null,保存时把这天的小童优惠清掉了。 - -如果用户最初是从这个弹框改的,那就解释了"回显 0":单日清空导致那天 toddlerDiscount=null,聚合后展示 null → `formatPrice(null)` 显示 "-" 或某种 fallback。 - -需要 mmg 核实 CalendarDayModal 的 info 传入是否完整带 toddlerDiscount。 - -## 修复建议(优先级排序) - -### 1️⃣ 最紧急 - 修 CSS 误导 - -```css -.pie-price-label { - font-size: 14px; /* 从 11 改回正常字号 */ - color: var(--text-color); /* 从 placeholder 改深色 */ - font-weight: normal; -} -``` - -或者**统一改用 NFormItem**,所有 4 个价格字段(成人/儿童/小童优惠/幼童)风格一致。**只此一项就解决「看起来 disabled」的误判**。 - -### 2️⃣ 修字段语义 - toddlerDiscount placeholder 与文档统一 - -确认业务真实意图后,统一改: -- **方案 A**(推荐): toddlerDiscount 改成**正数语义**(折扣额) - - placeholder 改 `"选填,正数=折扣额"` - - NInputNumber 加 `:min="0"` - - 后端公式 `childPrice - toddlerDiscount` 保持不变 ✓ -- 方案 B: 改语义为"小童价 = 儿童价 + toddlerDiscount"(可正可负),placeholder 改 `"可负数=便宜,正数=加价"`,后端公式改成 `childPrice + toddlerDiscount` - -两个方案选一,**当前"placeholder 写负数 + 后端减号公式"是矛盾的**。 - -### 3️⃣ 修单日编辑覆盖问题 - -确认 `CalendarDayModal.vue` 在保存单日时,如果用户没动 toddlerDiscount,**应当保留原值**,不要传 null 给后端。 - -具体看 `handleSubmit` 函数(第 319-320 行): - -```js -childSellPrice: formData.childSellPrice, -toddlerDiscount: formData.toddlerDiscount, // ← 如果是 null 也会覆盖 -``` - -如果调用方 `fillFormData(info)` 传入的 info.toddlerDiscount 是 null/undefined,formData.toddlerDiscount = null,保存时把这天的优惠清了。 - -修法:`fillFormData` 时**从聚合区间数据中带上 toddlerDiscount**(确认调用 fillFormData 的地方传入的 info 已包含此字段)。 - -### 4️⃣ NInputNumber value=0 显示样式 - -NInputNumber 在 value=0 且 disabled=false 时数字应该是深色。如果看起来浅,可能跟 naive-ui 主题变量有关,确认 `--input-text-color` 没被覆盖。这是小问题,**根因 1 解决后视觉上就不再误导**。 - -## 后端是否需要改 - -| 项 | 是否需要 | -|----|---------| -| toddlerDiscount=0 归一化为 null | ❌ 不改,语义正确 | -| toddlerDiscount 字段语义/公式 | ❓ 待与前端对齐后,**如果选方案 B 需改公式**;选方案 A 不改 | -| 其他 | ❌ 后端不参与 UI 问题 | - -## 验证清单(改完自测) - -- [ ] 价格区间「小童优惠额」label 颜色与「成人」「儿童」「单房差」一致 -- [ ] toddlerDiscount 输入框点击后能输入(无 disabled),输入 100 能保存 -- [ ] placeholder 文案与后端公式语义一致(正数/负数) -- [ ] 区间原本 toddlerDiscount=10、儿童改 0 后保存,**重开仍是 10**(单日和批量两种入口都测) -- [ ] 单日编辑(CalendarDayModal)只改其中一天的儿童价,**其他天的 toddlerDiscount 不被影响** - -## 复现路径(给 mmg 测试) - -1. 产品 `2045345825172639746`(游牧的森林-短途版)第 4 步「定价管理」 -2. 「轻奢」档点某价格区间「编辑」 -3. 把儿童价从 X 改成 0,点确认 -4. 弹框关闭后再点同一区间「编辑」 -5. 观察小童优惠额:值是否变 0?label 是否浅色看似 disabled? - -@mmg 请按根因 1(CSS)+ 根因 2(语义)+ 根因 4(单日覆盖)三处一起改。根因 3(后端归一化)保持不变。