21 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 8016 | 团期(GROUP)酒店候选:从「恒空」到「同城全量」——产品固定房池由硬门降级为加分项(1 个接口,契约零增删) | admin | wx(GIT) | 修改接口 | deployed | verified | not_required | mmg | 2026-09-20 | 本文件覆盖同一端点上 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。前端实证(hl-ui v2.1,2026-09-20):两消费面(调整订单 FunItemAdjustModal 轻量选酒店、房务 PickHotelModal)的 isPoolMatch/poolMatchBadge/recommended/recommendSource 全部条件渲染或未消费,无「恒池内」「恒空兜底」历史假设;keyword placeholder「留空查本城,填写可跨城搜」文案本就对;候选量级 1→21 走既有 max-height 滚动与 limit 截断;零代码改动,翻 not_required(不填认领字段)。 | 2026-09-20 | 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恒为 truetrue(池内)/false(池外)poolMatchBadge恒非 null 池内非 null / 池外 nullrecommended恒为 truetrue(池内或定制师点名)/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 的明确定案(团期实际常需临时换到池外酒店:池内满房 / 客人指定 / 行程变更,原先运营只能绕过系统线下处理)。
一、背景
团期订单的「选择酒店」是房务配房链路第一步:定制师 / 房控为每晚挑候选酒店,交房控择一。此前该弹窗对所有团期订单显示「无数据」,定制师一家也选不了。排查定位为两层问题叠加:
- 恒空(#7991):排序器把一个从未接线的字段当硬过滤条件,池内每家酒店都被丢弃。
- 池子限定(#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<HotelCandidateRespVO>
本次未增删任何字段,仅部分字段取值域变化(见「⚠️ 关键变化」与「六.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 池内为 跟团池内、池外为 池外可售 |
请求示例
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 生效):
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
响应示例
{
"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即可。
{ "code": 200, "message": "成功", "data": { "stayDate": null, "city": null, "productType": "GROUP", "candidates": [] }, "success": true }
错误响应
本端点无新增错误码,本次也未改动任何错误码。业务性失败一律 HTTP 200 + 空 candidates。鉴权失败由网关返回,形如:
{ "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 <deploy-status 报的 sha> 通过——跑的确实是本次改动的字节,而非「已合并但未部署」。
实测读数(订单 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 管理后台「选择酒店」弹窗)