产品v2: 小童优惠0归一化null + 价格日历tierSeq拆箱加固 (PR #827)
这个提交包含在:
父节点
4bf04ab4a4
当前提交
d5bab64983
@ -0,0 +1,194 @@
|
|||||||
|
# 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`
|
||||||
正在加载...
x
在新工单中引用
屏蔽一个用户