文件
hl-api-changelog/changelogs-v2/2026-09/20_8016_团期候选酒店放开池外-修改接口-管理后台.md

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 恒为 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<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

关联 / 联系人

链接

联系人

  • 后端:wx(hl-order-service-v3)
  • 前端:mmg(hl-ui 管理后台「选择酒店」弹窗)