- 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
8.0 KiB
⚠️ 破坏性变更:价格日历批量接口字段强校验
类型: 🔴 BREAKING CHANGE(破坏性变更,老前端会 400) 服务: hl-product-service-v2(端口 8083) 接口:
POST /admin/product/item/{id}/price-calendar/batchIssue: #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 请求体(合法)
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)
{
"code": 200,
"message": "价格日历设置成功",
"data": null,
"success": true
}
5.3 失败响应 — @Valid 校验失败(400)
缺 childSellPrice:
{
"code": 400,
"message": "childSellPrice: 儿童售价不能为空",
"data": null,
"success": false
}
adultSellPrice=0:
{
"code": 400,
"message": "adultSellPrice: 成人售价必须大于0",
"data": null,
"success": false
}
priceType=XXX:
{
"code": 400,
"message": "priceType: 价格类型必须是 NORMAL/PEAK/HOLIDAY/SPECIAL 之一",
"data": null,
"success": false
}
5.4 失败响应 — Service 业务校验失败(500,BusinessException)
tierSeq=99(产品只有 5 档):
{
"code": 500,
"message": "档位序号99不存在,本产品只有5档",
"data": null,
"success": false
}
startDate > endDate:
{
"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 失败,定价流程完全走不通。前端必须在后端发版前或同步发版,别拖。