From f2e30e51ac49d66d399f6705addcbb9dec355fc0 Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Fri, 17 Apr 2026 16:43:39 +0800 Subject: [PATCH] =?UTF-8?q?=E2=9A=A0=EF=B8=8F=E7=A0=B4=E5=9D=8F=E6=80=A7:?= =?UTF-8?q?=20=E4=BB=B7=E6=A0=BC=E6=97=A5=E5=8E=86=E6=89=B9=E9=87=8F?= =?UTF-8?q?=E6=8E=A5=E5=8F=A3=E5=AD=97=E6=AE=B5=E5=BC=BA=E6=A0=A1=E9=AA=8C?= =?UTF-8?q?(PR=20#748=20Issue=20#747)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - childSellPrice 从可空升级为 @NotNull + @DecimalMin(0.01) - tierSeq 从默认1升级为 @NotNull + @Min(1) + 必须≤产品档位数 - priceType 从默认NORMAL升级为 @NotBlank + @Pattern(NORMAL|PEAK|HOLIDAY|SPECIAL) - adultSellPrice 新增 @DecimalMin(0.01) - 服务: hl-product-service-v2, commit c311e6ee - 前端必须同步升级表单必填校验,否则老前端保存会 400 --- ...4-17_breaking_price-calendar-validation.md | 239 ++++++++++++++++++ 1 file changed, 239 insertions(+) create mode 100644 changelogs/2026-04/2026-04-17_breaking_price-calendar-validation.md diff --git a/changelogs/2026-04/2026-04-17_breaking_price-calendar-validation.md b/changelogs/2026-04/2026-04-17_breaking_price-calendar-validation.md new file mode 100644 index 0000000..c5cea71 --- /dev/null +++ b/changelogs/2026-04/2026-04-17_breaking_price-calendar-validation.md @@ -0,0 +1,239 @@ +# ⚠️ 破坏性变更:价格日历批量接口字段强校验 + +> **类型**: 🔴 **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 失败,定价流程完全走不通。**前端必须在后端发版前或同步发版,别拖。**