2026-04-30: 上架校验跳过最后一天酒店 (PR #1587) + 前端 BUG changelog 升级排查指引

- fix(product): 上架/完成设计校验跳过最后一天酒店(返程日豁免) (PR #1587, Closes #1586)
- update: 前端 includedFees 缺 feeType BUG changelog 加 yst 第二次反馈截图 + 后端 round-trip 实测证据 + JS 完整打印调试指引
这个提交包含在:
wx 2026-04-30 23:42:39 +08:00
父节点 77934fce45
当前提交 82e2f1713f
共有 2 个文件被更改,包括 148 次插入0 次删除

查看文件

@ -0,0 +1,81 @@
# 修复: 上架/完成设计校验跳过最后一天酒店(返程日豁免)
**类型**: 后端 BUG 修复
**关联**: 工单 #1586 / PR #1587
**日期**: 2026-04-30
**前端处理者**: yst / mmg
**影响范围**: 管理后台「完成设计」「提交审核 / 直接上架」按钮的预检逻辑
---
## 现象
用户反馈:产品上架/完成设计预检时,如果**最后一天**没在 `product_day_hotel` 配酒店,就被「Day N 住宿安排为空」拦截无法上架。但**最后一天通常是返程日不需要住宿**,这个校验是错的。
用户原文:「最后一天行程提示也得有住宿安排,实际不需要」
## 根因
`hl-product-service-v2` `ProductValidationService.validateItineraryCompleteness()` 对每一天循环硬要求 `product_day_hotel` 非空,**没有跳过最后一天**。
## 修复
判定最后一天豁免酒店校验:
```java
int totalDays = days.size();
for (int i = 0; i < totalDays; i++) {
ProductItineraryDayDO day = days.get(i);
// ... 描述/集合地/解散地/活动校验保持不变 ...
// 最后一天通常是返程,不强制要求配酒店(2026-04-30 用户反馈)
boolean isLastDay = (day.getDayNumber() != null)
? day.getDayNumber() == totalDays
: i == totalDays - 1;
if (!isLastDay) {
if (dayHotels.isEmpty()) issues.add(dayLabel + " 住宿安排为空");
}
}
```
判定规则:
- 优先 `day.getDayNumber() == totalDays`
- `dayNumber` 为 null 时回退索引 `i == totalDays - 1`
## 行为变化
| tripDays | 最后一天 | 行为 |
|---|---|---|
| 1 | i=0 是唯一一天即最后一天 | 单天产品没酒店通过 |
| 2 | Day1 校验 / Day2 豁免 | OK |
| 3 | Day1/Day2 校验 / Day3 豁免 | OK |
| dayNumber=null | 列表最后一项兜底 | 跳过 |
## 影响接口
| 方法 | 路径 | 改动 |
|------|------|------|
| POST | `/admin/product/item/{id}/complete-design` | 最后一天没酒店不再返 400 |
| POST | `/admin/product/item/{id}/toggle-publish` (SUBMIT_PUBLISH/DIRECT_PUBLISH) | 同上 |
| PUT | `/admin/product/item/{id}/supplement` | 不变(草稿允许任意) |
请求/响应签名不变,只是错误条件减少。
## 前端要做的改动
**0 改动**。如果前端之前对「Day N 住宿安排为空」错误做过 UX 引导(比如跳到行程编排页面),保留即可,不会被误触发。
## 测试覆盖
新增 4 单测:
- `validateItinerary_lastDayNoHotel_shouldPass` — 3 天 day3 没酒店,通过
- `validateItinerary_middleDayNoHotel_shouldFail` — 3 天 day2 没酒店,报错;day3 不计入
- `validateItinerary_singleDay_noHotel_shouldPass` — 1 天产品没酒店通过
- `validateItinerary_dayNumberNull_useListIndexFallback` — dayNumber=null 兜底
`mvn test`: ProductValidationServiceTest **53/53 全绿**
## 部署状态
- ✅ 测试服 hl-product-service-v2 双实例部署完成(2026-04-30 23:40)
- 正式环境暂未发布(等 dev → main release PR)

查看文件

@ -11,6 +11,73 @@
**日期**: 2026-04-30 **日期**: 2026-04-30
**影响范围**: 管理后台「产品编辑 → 补充信息 → 费用说明」页面 — 自动生成的费用包含列表 **影响范围**: 管理后台「产品编辑 → 补充信息 → 费用说明」页面 — 自动生成的费用包含列表
## ⚠️ 用户 yst 第二次反馈(2026-04-30 20:43)
yst 截图:
```
includedFees: [{name: "扎赉诺尔博物馆,巴尔虎蒙古部落,海拉尔国家森林公园", description: null}, ...]
0: {name: "扎赉诺尔博物馆,巴尔虎蒙古部落,海拉尔国家森林公园", description: null}
1: {name: "海拉尔首旅京伦酒店, 额尔古纳豪星酒店", description: null}
```
→ "返回的费用包含不对 自动的没有类型"
## 后端实测(管理者再次 round-trip 确认)
`GET /admin/product/item/2045018534152478721`(测试服) 实测响应:
```jsonc
includedFees: [
{
"id": "2049778058755268609",
"productId": "2045018534152478721",
"feeCategory": "INCLUDED",
"feeType": "门票", // ← 后端有这个字段!
"source": "AUTO",
"name": "黑山头",
"description": null,
...
}, ... // 17 条单条记录,全部含 feeType
]
```
`PUT /admin/product/item/{id}/supplement` 写入 `[{feeType:"景区",name:"X1,X2",sortOrder:1}]`
`GET /admin/product/item/{id}` 读出来 `includedFees=[{feeType:"景区",name:"X1,X2",...}]`
**字符串完全一致透传**,后端 round-trip 验证齐全。
## 前端排查焦点
1. yst console 截图只看到 2 项(0/1),**第三项「游玩项目」消失** — 跟首次反馈一致
2. 每项 keys 只显示 `name + description`,但后端实际返 16 个字段(`id/productId/feeCategory/feeType/source/name/description/resourceType/resourceId/costItemId/unitPrice/quantity/daily/unit/icon/sortOrder`)→ 前端 console 打印应该是只挑了部分字段(可能 `console.log({name:..., description:...})`)还是真的丢了完整字段?
## 排查建议升级版
请前端在 `mounted()` 钩子加完整打印:
```js
async function loadIncludedCosts() {
const res = await getFeeDeductionPreview(productId)
console.log('[FULL res]', JSON.parse(JSON.stringify(res))) // ← 看完整字段
console.log('[res.length]', res?.length) // ← 看是不是真的少了 1 项
console.log('[item keys]', res?.[0] && Object.keys(res[0])) // ← 看每项实际字段
// ...
}
```
如果 `JSON.parse(JSON.stringify(res))` 完整打印里:
- 看到 3 项含 `feeType="景区"/"游玩项目"/"住宿"` 字段 → 是渲染层 v-for 过滤了「游玩项目」(跟之前推测一致),修 v-for 模板 / filter 逻辑
- 看到 2 项缺「游玩项目」/字段不全 → axios 拦截器 / store / 数据预处理吞了字段(grep `filter.*feeType` / `pick.*name.*description` 之类)
## 后端 round-trip 实测命令(yst 可复测)
```bash
TOKEN=...
curl -k "https://api.test.1814.love:9443/admin/product/item/2045018534152478721/fee-deduction-preview" \
-H "Authorization: Bearer $TOKEN" | jq '.data | length, .data[].feeType'
# 期望:3 / "景区" / "游玩项目" / "住宿"
```
--- ---
## 现象 ## 现象