diff --git a/changelogs-v2/2026-09/20_8016_团期候选酒店放开池外-修改接口-管理后台.md b/changelogs-v2/2026-09/20_8016_团期候选酒店放开池外-修改接口-管理后台.md new file mode 100644 index 00000000..f66511ae --- /dev/null +++ b/changelogs-v2/2026-09/20_8016_团期候选酒店放开池外-修改接口-管理后台.md @@ -0,0 +1,381 @@ +--- +schema: "hl-changelog/v2" +ticket: "8016" +title: "团期(GROUP)酒店候选:从「恒空」到「同城全量」——产品固定房池由硬门降级为加分项(1 个接口,契约零增删)" +consumer: "admin" +author: "wx(GIT)" +change_type: "修改接口" +backend_status: "deployed" +gateway_status: "verified" +frontend_status: "pending" +frontend_owner: "mmg" +frontend_ref: "" +target_release: "" +verified_at: "2026-09-20" +status_note: "本文件覆盖同一端点上 2026-09-20 先后上线的两次行为变更:#7991(候选恒空修复,dev-v3 @624777771,12:36 部署)与 #8016(放开池外,dev-v3 @7d44ca268,13:36 部署)。两者相隔一小时、改同一个端点、对前端是一条连贯的行为变化,故合写一份而非拆两份。backend_status=deployed 与 gateway_status=verified 均为实测:13:39 经网关 api.test.1814.love:9443 实调订单 2100856430239121409,候选数 1→21、池内排第一、跨区关键词搜生效(读数见第八节)。frontend_status 标 pending 而非 not_required 是如实标注——前端大概率无需改代码(池内徽章渲染与 CORE 共用),但 GROUP 分支若存在「恒为池内」的历史假设则必须改;我无法读 mmg 仓库代码,不替前端下结论,请 mmg 核完自行改为 verified 或 not_required。" +updated_at: "2026-09-20" +base: "dev-v3" +--- + +# 团期酒店候选: 放开池外,产品固定房池由硬门降级为加分项(工单 #7991、#8016) + +> **存放目录**: 二期(order-v3 标签工单)→ `changelogs-v2/2026-09/` +> +> **服务**: hl-order-service-v3(8086 / 8186) +> **PR**: #8014(#7991 候选恒空修复)、#8021(#8016 放开池外) +> **Issue**: #7991、#8016 +> **日期**: 2026-09-20 +> **影响范围**: 管理后台「订单详情 → 调整订单 → 酒店安排 → 选择酒店」弹窗,仅团期(GROUP)产品订单 + +--- + +## ⚠️ 关键变化 + +- 🔴 **GROUP 订单的 `candidates` 此前恒为空数组**(#7991)。根因:排序器以 `batchRoomRemain > 0` 硬过滤候选,而该字段自 2026-05-20 引入起在服务端从未被任何写口赋值、恒为 `null`,于是产品池内每一家酒店都被丢弃。**这不是「没有可用酒店」,是全部被过滤掉了**——若前端据此做过「团期无可选酒店」的兜底文案或降级交互,现在会走到有数据的分支。 + +- 🔴 **GROUP 的四个字段取值域变了,这是最容易打穿前端的一条**: + + | 字段 | 改前(GROUP) | 改后(GROUP) | + |------|--------------|--------------| + | `isPoolMatch` | **恒为 `true`** | `true`(池内)/ `false`(池外) | + | `poolMatchBadge` | **恒非 null** | 池内非 null / 池外 **`null`** | + | `recommended` | **恒为 `true`** | `true`(池内或定制师点名)/ `false`(池外普通候选) | + | `recommendSource` | 恒为 `CONSULTANT` 或 `PRODUCT_POOL` | 两者之一,或**池外普通候选为 `null`** | + + **前端若对 GROUP 分支写过「一定是池内」「徽章一定有」的假设,必须改成按字段判空。** CORE 产品一直是这个取值域,若徽章渲染与 CORE 共用同一段代码则无需改动。 + +- **候选条数量级变了**:同一订单同一晚,测试环境实测从 **1 条变为 21 条**(受 `limit` 约束,默认 30、上限 50)。列表若有高度 / 滚动 / 虚拟化假设需复核。 + +- **`keyword` 对团期订单真正生效了**。此前团期单无论填什么关键词都返回同一批池内酒店(池子限定分支早于关键词分支 return,关键词被整个吞掉);现在与 CORE 一致跨城 / 省搜。弹窗 placeholder「留空查本城,填写可跨城搜」**前端文案无需改,但它从「假承诺」变成了「真行为」**。 + +- ⚠️ **已知取舍:关键词模式下池内酒店不会被强行保留**。填了关键词时,不命中关键词的池内酒店不会出现在结果里(实测填「海拉尔」时池内的「呼伦贝尔香格里拉大酒店」不在列表中)。这是沿用 CORE 的既有形状——改它会同时改变 CORE 行为,故保留。若业务需要「团期任何搜索下都看得到签约房」,需另立工单。 + +- **池内恒排池外之前,且是结构性保证而非约定**:`score = (池内 ? 1000 : 0) + min(可用房分, 999)`,可用房分上限严格小于池内加分,因此「池外但房很多」的候选**不可能**挤掉池内候选。前端可按返回顺序直接展示,不需要自己再排一次。 + +- **本次推翻了工单 #3217 的「GROUP 仅展示产品池内固定房」约束**,依据是 wx 2026-09-20 的明确定案(团期实际常需临时换到池外酒店:池内满房 / 客人指定 / 行程变更,原先运营只能绕过系统线下处理)。 + +--- + +## 一、背景 + +团期订单的「选择酒店」是房务配房链路第一步:定制师 / 房控为每晚挑候选酒店,交房控择一。此前该弹窗对**所有**团期订单显示「无数据」,定制师一家也选不了。排查定位为两层问题叠加: + +1. **恒空(#7991)**:排序器把一个从未接线的字段当硬过滤条件,池内每家酒店都被丢弃。 +2. **池子限定(#8016)**:修好第 1 层后每晚只能看到产品房池里配的那 1 家,而池外酒店连关键词都搜不出来。 + +--- + +## 二、变更接口清单 + +| # | 接口 | 方法 | 路径 | 变更类型 | 说明 | +|---|------|------|------|----------|------| +| 1 | 酒店候选查询(统一入口) | GET | `/v3/admin/hotel-candidates` | 行为变更(契约零增删) | GROUP 从「恒空 / 仅池内」改为「同城全量、池内加分置顶」;keyword 对 GROUP 生效 | + +--- + +## 三、接口详情 + +### 1. 酒店候选查询(统一入口) `GET /v3/admin/hotel-candidates` + +**VO**: `HotelCandidateRespVO`(出参;内部类 `Candidate` / `RoomTypeOption` / `Badge`)。入参 VO 为 `HotelCandidateQueryReqVO`。 + +#### 使用场景 + +管理后台订单详情 →「调整订单」→「酒店安排」Tab → 点某一晚的「选择酒店」弹窗。定制师为行程的每一晚挑若干候选酒店,交房控择一。CORE / GROUP / CUSTOM 三种产品类型共用本端点,服务端按订单的 `productType` 自行分流排序策略,前端不需要区分。 + +#### 入参 + +本次**未增删任何参数**,列全供对照: + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `orderId` | query | Long | 是 | — | 订单 ID | +| `dayNumber` | query | Integer | 否 | ≥1 | 第几天,用于推算 `stayDate = departDate + dayNumber - 1` | +| `stayDate` | query | LocalDate | 否 | `yyyy-MM-dd` | 入住日期,直接指定时优先于 `dayNumber` | +| `city` | query | String | 否 | — | 城市代码 | +| `keyword` | query | String | 否 | — | 关键词;非空时跨城 / 省搜(**本次起对 GROUP 生效**),匹配酒店名 / 城市 / 省份 / 地址 | +| `limit` | query | Integer | 否 | 1–50 | 返回条数上限,默认 30 | +| `roomCategory` | query | String | 否 | — | 房型大类字典 code。⚠️ 当前**不参与房型级过滤**,仅作 `matchedRoomTypeLabel` 展示标签 | +| `roomCount` | query | Integer | 否 | ≥1 | 需要的房间数,决定 matched 房型的可用数阈值 | +| `preferredHotelId` | query | Long | 否 | — | 定制师指定的优先酒店 ID | +| `requirementId` | query | Long | 否 | — | 用房需求 ID | + +#### 出参 `Result` + +本次**未增删任何字段**,仅部分字段取值域变化(见「⚠️ 关键变化」与「六.6」)。 + +| 字段 | 类型 | 说明 | +|------|------|------| +| `stayDate` | LocalDate | 入住日期 | +| `city` | String | 城市代码,可为 null | +| `productType` | String | `CORE` / `GROUP` / `CUSTOM` | +| `candidates[]` | Candidate[] | 候选酒店列表,已按 `score` 降序 | +| `candidates[].hotelId` | Long(字符串序列化) | 酒店 ID | +| `candidates[].hotelName` | String | 酒店名称 | +| `candidates[].city` / `.district` | String | 所在城市 / 区县 | +| `candidates[].protoPrice` | BigDecimal(字符串) | 协议价,无当日记录返 null | +| `candidates[].settlementPrice` | BigDecimal(字符串) | 结算价,未维护返 null | +| `candidates[].todayAvailable` | Integer | 当日可用房数(全房型合计),无快照返 null | +| `candidates[].roomTypes[]` | RoomTypeOption[] | 当日真实房型列表(含每房型协议价 / 可用数 / 库存状态) | +| `candidates[].isPoolMatch` | Boolean | 是否在产品固定房池内。**GROUP 改后可为 false** | +| `candidates[].poolMatchBadge` | Badge | 池内徽章。**GROUP 改后池外为 null** | +| `candidates[].isConsultantRecommended` | Boolean | 是否被定制师点名 | +| `candidates[].recommended` | Boolean | 是否标记为推荐。**GROUP 改后可为 false** | +| `candidates[].recommendSource` | String | `CONSULTANT` / `PRODUCT_POOL` / **null(GROUP 池外普通候选)** | +| `candidates[].score` | Double | 排序分,仅供调试;前端按数组顺序展示即可 | +| `candidates[].recommendation` | String | 推荐语,GROUP 池内为 `跟团池内`、池外为 `池外可售` | + +#### 请求示例 + +```http +GET /v3/admin/hotel-candidates?orderId=2100856430239121409&dayNumber=1&stayDate=2026-10-08&limit=50&roomCount=1 +Host: api.test.1814.love:9443 +Authorization: Bearer {token} +``` + +跨城搜(本次起对 GROUP 生效): + +```http +GET /v3/admin/hotel-candidates?orderId=2100856430239121409&dayNumber=1&stayDate=2026-10-08&limit=50&keyword=%E6%B5%B7%E6%8B%89%E5%B0%94 +``` + +#### 响应示例 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "stayDate": "2026-10-08", + "city": null, + "productType": "GROUP", + "candidates": [ + { + "hotelId": "2023714929877450753", + "hotelName": "呼伦贝尔香格里拉大酒店", + "city": "呼伦贝尔市", + "district": "满洲里市", + "protoPrice": "280.00", + "settlementPrice": "279.00", + "todayAvailable": 70, + "isPoolMatch": true, + "poolMatchBadge": { "label": "产品池内", "color": "blue", "tooltip": "本酒店在跟团产品固定房池内" }, + "isConsultantRecommended": false, + "recommended": true, + "recommendSource": "PRODUCT_POOL", + "score": 1070.0, + "recommendation": "跟团池内" + }, + { + "hotelId": "3001000000000000002", + "hotelName": "海拉尔海棠酒店", + "city": "呼伦贝尔市", + "district": "海拉尔区", + "protoPrice": "320.00", + "todayAvailable": 15, + "isPoolMatch": false, + "poolMatchBadge": null, + "isConsultantRecommended": false, + "recommended": false, + "recommendSource": null, + "score": 15.0, + "recommendation": "池外可售" + } + ] + }, + "success": true +} +``` + +#### 空数据 / 降级响应 + +- 仍返回 HTTP 200 + `code=200`,`candidates` 为**空数组**,不是 null。 +- **产品固定房池为空不再导致返空**(改前 GROUP 池为空时直接返空且不发起查询)。 +- 未传 `stayDate` 且无法由 `dayNumber` 推算时,`candidates` 为空数组——这是既有行为、本次未改;前端始终传 `dayNumber` 即可。 + +```json +{ "code": 200, "message": "成功", "data": { "stayDate": null, "city": null, "productType": "GROUP", "candidates": [] }, "success": true } +``` + +#### 错误响应 + +本端点**无新增错误码**,本次也未改动任何错误码。业务性失败一律 HTTP 200 + 空 `candidates`。鉴权失败由网关返回,形如: + +```json +{ "code": 401, "message": "用户名或密码错误", "data": null, "success": false } +``` + +⚠️ 网关对失效 token 返回的是 **HTTP 200 + body `code=401`**,不是 HTTP 401——前端与取证脚本都必须读 body 的 `code`,不能只看状态码。 + +#### 业务边界 + +- 池内酒店**恒排在池外之前**,由评分结构保证(池内加分 1000 > 可用房分上限 999),不受可用房数影响。 +- `limit` 截断发生在排序**之后**,因此池内酒店不会被截掉。 +- 关键词模式下池内酒店**不做强制保留**(不命中关键词就不出现)。 +- `todayAvailable` 为 null 时可用房分记 0,该候选排在同组最后,但仍会展示。 +- CORE / CUSTOM 产品行为完全不变。 + +--- + +## 四、契约约束与正确调用方式(接口类必写) + +### ✅ 正确 / ❌ 错误 payload 对照 + +| | 调用 | 说明 | +|---|------|------| +| ✅ | `?orderId=...&dayNumber=1&stayDate=2026-10-08&limit=50` | 推荐:同时给 `dayNumber` 与 `stayDate`,服务端以 `stayDate` 为准 | +| ✅ | `?orderId=...&dayNumber=1&stayDate=2026-10-08&keyword=海拉尔` | 跨城搜;本次起对 GROUP 生效 | +| ❌ | `?orderId=...`(不带 `dayNumber` 也不带 `stayDate`) | 推不出入住日期,返回空数组,不报错——**容易被误读为「没有酒店」** | +| ❌ | `?orderId=...&limit=100` | `limit` 上限 50,超出被参数校验拒绝 | + +### 切换状态时的必要动作 + +本端点是**只读查询**,不涉及状态切换,调用方无需在调用前后做任何状态写入。选定候选酒店后的写入走既有的用房需求保存接口,本次未改。 + +--- + +## 五、数据库行为(涉及写操作时必写) + +**无写操作。** 本端点只读;本次变更**无表结构变更、无 Flyway 迁移、无 H2 schema 变更**。 + +--- + +## 六、边界行为 + +| 场景 | 行为 | +|------|------| +| 产品固定房池为空 | 按城市正常返回候选(改前直接返空) | +| 城市取不到 | 不返空,查全部在售酒店 | +| 同城可售酒店数 > `limit` | 截断在排序之后取前缀,池内酒店因分数 ≥1000 恒在头部、不会被截掉 | +| `todayAvailable` 为 null | 可用房分记 0,候选仍展示 | +| 关键词不命中任何池内酒店 | 结果中可以完全没有池内酒店(见「已知取舍」) | +| 资源库存在同名不同 ID 的酒店 | 按 `hotelId` 唯一、结果无重复,但界面上会看到两个同名条目(数据侧问题,非本次引入) | + +--- + +## 六.5、枚举 / 数据字典(接口出现枚举时必写) + +### `recommendSource`(`com.hulalv.hotelcandidate.ranker.RecommendSource`) + +| 取值 | 含义 | GROUP 改前 | GROUP 改后 | +|------|------|-----------|-----------| +| `CONSULTANT` | 定制师点名 | 可能 | 可能 | +| `PRODUCT_POOL` | 产品固定房池内 | 可能 | 可能(池内) | +| `null` | 普通候选,两者都不是 | **不可能** | **可能(池外普通候选)** | + +### `productType`(`com.hulalv.order.core.enums.ProductType`) + +| 取值 | 含义 | +|------|------| +| `CORE` | 核心订单 | +| `GROUP` | 团期(跟团)订单——本次变更仅影响该类型 | +| `CUSTOM` | 定制订单 | + +--- + +## 六.6、修改前后对比(修改/删除类接口必写,新增跳过) + +### 字段级对比 + +| 字段 | 改前(GROUP) | 改后(GROUP) | 是否破坏性 | +|------|--------------|--------------|-----------| +| `isPoolMatch` | 恒 `true` | `true` / `false` | 取值域扩大,前端若假设恒真则破坏 | +| `poolMatchBadge` | 恒非 null | 池内非 null / 池外 `null` | 同上 | +| `recommended` | 恒 `true` | `true` / `false` | 同上 | +| `recommendSource` | 恒非 null | 池外普通候选为 `null` | 同上 | +| `recommendation` | 恒 `跟团池内 · 剩余 N 间` 或 `跟团池内` | 池内 `跟团池内` / 池外 `池外可售` | 文案变化,非结构变化 | +| 其余全部字段 | — | — | 无变化 | + +### 行为级对比 + +| 维度 | 改前 | 改后 | +|------|------|------| +| GROUP 候选可见性 | 恒空(#7991 前)→ 仅产品固定房池内(#7991 后) | 同城可售全量,池内 + 池外 | +| 产品房池的作用 | 硬门,池外一律不展示 | 加分项,池内恒排最前 + 带徽章 | +| GROUP 的 `keyword` | 被整个忽略 | 跨城 / 省搜生效,与 CORE 一致 | +| 产品房池为空 | 直接返空、不发起查询 | 按城市正常返回候选 | +| CORE / CUSTOM | — | 完全不变 | + +--- + +## 六.7、影响评估(修改/删除类必写) + +| 消费方 | 影响 | 需要动作 | +|--------|------|----------| +| 管理后台「选择酒店」弹窗(hl-ui,mmg) | 结果集变大;GROUP 出现 `isPoolMatch=false` 的条目 | **核查**是否对 GROUP 写过「恒为池内」的假设;徽章渲染若与 CORE 共用则无需改 | +| 小程序 | 不调用本端点 | 无 | +| 其它后端服务 | 本端点无内部调用方(`/v3/admin/` 前缀,仅管理后台) | 无 | +| 数据 / 报表 | 本端点只读、不落库 | 无 | + +**回滚方式**:revert PR #8021 即回到「仅池内」;revert PR #8014 会回到「恒空」的缺陷态,不建议单独回滚。 + +--- + +## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面) + +- **CORE / CUSTOM 产品的候选行为零变化**。本次对 `CoreCandidateRanker` 的唯一改动是把私有方法 `resolveRecommendSource` 上提为父类共用方法,逻辑逐行未改;`CandidateConsultantChoiceRankTest`(覆盖 CORE 与定制师主 / 副选排序)7 条用例全绿可证。 +- **接口契约零增删**:请求参数、响应字段、错误码均未增删改。 +- **无表结构变更、无 Flyway、无网关路由改动**。 +- **不影响选定候选之后的写入链路**(用房需求保存、房控配房、配房审核等均未改动)。 +- **不影响 `batchRoomRemain` 的语义**:该字段仍未接线、仍恒为 null,本次未新增写口;批次剩余房数的真实接线与售罄过滤是后续工单。 + +--- + +## 八、测试环境已验证 + +**环境**:测试服 `192.168.100.236`,hl-order-service-v3 部署于 `dev-v3 @7d44ca268`(2026-09-20 13:36 滚动部署,双实例 8086 / 8186 均 UP,`deploy-status.sh` 报 `BEHIND 0/N`、`STATE=ok`)。调用经网关 `api.test.1814.love:9443`,使用独立取证车道账号,未使用共用 admin 账号。 + +**部署字节核对**:`git merge-base --is-ancestor 7d44ca268 ` 通过——跑的确实是本次改动的字节,而非「已合并但未部署」。 + +**实测读数**(订单 `2100856430239121409`,团期,出行 2026-10-08,3 天 2 晚): + +| 入参 | 候选数 | 首条 | body `code` | 说明 | +|------|--------|------|------------|------| +| `dayNumber=1&stayDate=2026-10-08&limit=50` | **21** | 呼伦贝尔香格里拉大酒店(`isPoolMatch=true`) | 200 | 改前为 1 | +| 同上 + `keyword=酒店` | **15** | 呼伦贝尔香格里拉大酒店(`isPoolMatch=true`) | 200 | 改前为 1 | +| 同上 + `keyword=海拉尔` | **5** | 海拉尔嘉世豪酒店(`isPoolMatch=false`) | 200 | 改前为 1;池内不命中关键词故不在列表 | + +三次结果内 `hotelId` 均无重复;`recommendation` 中「剩余 null 间」零条目。 + +**单元测试**:`mvn -o -pl hl-order-service-v3 -am -Dtest='GroupCandidateRankerTest,CandidateConsultantChoiceRankTest,HotelCandidateLoaderTest,HotelCandidateServiceTest' test` = **Tests run 75 / Failures 0 / Errors 0 / BUILD SUCCESS**。含「池外 5000 间压不过池内 1 间」「limit=3 时 35 家池外不能把唯一池内候选挤掉」两条针对性用例。 + +⚠️ **尚未完成**:order-v3 **全量**单测 + ArchTest 门禁(工单 #7991 AC-4 / #8016 AC-8)因本机内存被多会话占满,今日三次尝试均挂在内存(堆溢出 / 无法启动 fork),尚未取得一次完整 BUILD SUCCESS,**故两张工单均未关闭、对应验收项如实未勾**。本节只声明已实测的部分,不以「跑过的部分都是绿的」替代全量结论。 + +--- + +## 九、相关历史 PR(纠错 / 功能演进时必写) + +| PR | 工单 | 内容 | +|----|------|------| +| #8014 | #7991 | 修复 GROUP 候选恒空:去掉以未接线字段 `batchRoomRemain` 做的硬过滤 | +| #8021 | #8016 | 放开池外:产品固定房池由硬门降级为加分项 | + +被本次推翻 / 修正的既有约定: + +- 工单 **#3217**「GROUP 仅展示产品池内固定房」——本次推翻。 +- 工单 **#4047** 定义的 `keyword` 跨城语义——此前对 GROUP 不生效,本次兑现。 +- 工单 **#4364** 的「返回条数上限由 `HotelCandidateService.truncate` 单源管控」——本次沿用,未在排序器内另加截断。 + +--- + +## 十、相关文档 + +- 工单 #7991(团期选择酒店候选恒空)、#8016(团期候选酒店放开池外) +- 接口源码:`hl-order-service-v3/src/main/java/com/hulalv/hotelcandidate/`(`controller/admin/HotelCandidateAdminController`、`service/HotelCandidateLoader`、`ranker/GroupCandidateRanker`) +- 既有约束出处:工单 #3217、#4047、#4364 + +--- + +## 关联 / 联系人 + +### 链接 + +- Issue #7991: https://git.1814.love:8443/wx/HL/issues/7991 +- Issue #8016: https://git.1814.love:8443/wx/HL/issues/8016 +- PR #8014: https://git.1814.love:8443/wx/HL/pulls/8014 +- PR #8021: https://git.1814.love:8443/wx/HL/pulls/8021 + +### 联系人 + +- 后端:wx(hl-order-service-v3) +- 前端:mmg(hl-ui 管理后台「选择酒店」弹窗)