docs(changelog): #7244 选品接口透出 productType 与团期班期列表
changelog-filename-gate / validate (push) Successful in 3s

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01N7Xpgcv9nncadAXQtWhg4P
这个提交包含在:
API Changelog Bot
2026-09-07 12:40:28 +08:00
共同撰写人 Claude Opus 5
父节点 eb535dbc50
当前提交 97608cfca6
@@ -0,0 +1,428 @@
---
schema: "hl-changelog/v2"
ticket: "7244"
title: "订单选品接口透出 productType 与团期班期列表 batches"
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-07"
status_note: "PR #7259 已合入 dev-v3(9800cddde);2026-09-07 测试环境网关实测 15/15 通过(AC-1/1b/2/3/4/5/6)。前端待办:新建订单选择 GROUP 产品时必须在选品页选定班期,未选不允许进入下一步——详见第八.5 节。"
updated_at: "2026-09-07"
base: "dev-v3"
---
# 订单选品接口: 新增 productType 与内嵌可售班期列表 batches
> **服务**: hl-product-service-v2 | **PR**: #7259 | **Issue**: #7244 | **合并提交**: `9800cddde`
> **影响范围**: 管理后台「新建订单 → 选择产品」页
---
## ⚠️ 关键变化
**新建订单时,团期产品(`productType === "GROUP"`)必须让运营选定一个班期,不选不允许进入下一步。班期数据现在直接内嵌在选品接口的每个产品项里,前端不需要再单独请求班期接口。**
响应新增两个字段:`productType`(产品类型)与 `batches`(该产品当前可售班期列表)。**11 个旧字段的名称、类型、顺序一字未动**,不消费新字段的页面不受影响。
---
## 一、背景
新建订单页选完产品后直接让运营手填出发日期,团期产品因此可能建出「没有归属班期」或「出发日与任何班期都对不上」的订单,后续团期看板认领不到该单。
要在选品页当场选班期,就需要选品接口把班期带出来。原本可以再加一个「按产品查班期」的接口,但选品页是分页列表,每个产品一次请求会退化成 N+1;因此改为在选品接口内嵌,一次请求把本页所有团期产品的可售班期全部带回。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 订单选品列表 | GET | `/admin/product/item/order-picker` | **响应新增字段** | 每个产品项新增 `productType` 与 `batches[]` |
入参、路径、错误码均未变。
---
## 三、接口详情
### 1. 订单选品列表 `GET /admin/product/item/order-picker`
**VO**: `OrderPickerProductVO`(本次新增两个静态嵌套类 `OrderPickerProductVO.BatchItem`、`OrderPickerProductVO.BatchTierPrice`)
#### 使用场景
管理后台「新建订单」流程第 1 步「选择产品」页加载与翻页时调用。运营按主题或关键字筛出产品,选中产品后进入第 2 步选档位。本次改动后,团期产品的可售班期随产品一起返回,运营在同一页选定班期,不再需要单独请求班期接口,也不再手填出发日期。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `lineId` | query | Long | 否 | - | 主题/线路 ID,按主题筛选产品 |
| `keyword` | query | String | 否 | - | 产品名关键字 |
| `page` | query | Integer | 否 | 默认 1 | 页码 |
| `pageSize` | query | Integer | 否 | 默认 10 | 每页条数 |
#### 出参 `Result<PageResult<OrderPickerProductVO>>`
`data.records[]` 的每一项:
| 字段 | 类型 | 说明 |
|------|------|------|
| `productId` | String | 产品 ID(雪花,字符串序列化)。非空 |
| `name` | String | 产品名。非空 |
| `subtitle` | String | 副标题。可空 |
| `tripDays` | Integer | 行程天数。可空 |
| `tripNights` | Integer | 行程晚数。可空 |
| `tags` | String[] | 标签。可空 |
| `coverImageUrl` | String | 封面图。可空 |
| `startPrice` | String | 起价(金额字符串)。可空 |
| `tierPrices` | Object[] | 产品档位起价(`tierSeq` / `tierName` / `tierDescription` / `startPrice`)。可空 |
| `hot` | Boolean | 是否热门。非空 |
| `nextSaleDate` | String | 最近可售日。可空 |
| **`productType`** | **String** | **新增**。产品类型,见六.5。非空 |
| **`batches`** | **Object[]** | **新增**。当前可售班期列表。非 GROUP 产品恒为 `[]`(空数组,**不是 null**)。非空 |
**`batches[]` 每一项**
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `batchId` | String | 否 | 班期 ID(雪花,字符串序列化)。创建订单时作为 `productBatchId` 回传 |
| `batchNo` | String | 否 | 班期编号 |
| `batchLabel` | String | 否 | 期号,如 `"7"` 表示第 7 期。**按该产品全部未删班期的出发日升序位置计算**,与团期看板显示的期号一致 |
| `batchName` | String | 是 | 班期名(后端已去除前后空格) |
| `departureDate` | String | 否 | 出发日 `yyyy-MM-dd` |
| `endDate` | String | 是 | 返团日 `yyyy-MM-dd` |
| `enrollmentDeadline` | String | 是 | 报名截止日 `yyyy-MM-dd` |
| `maxRooms` | Integer | 否 | 房间数上限。**本接口恒非 null**,`0` = 不限 |
| `remainRooms` | Integer | 是 | 剩余房数。**`null` = 不限,`0` = 已满**。含运营线下占位房数 |
| `maxParticipants` | Integer | 是 | 人数上限,`null` 或 `0` = 不限 |
| `remainSlots` | Integer | 是 | 剩余人数。**`null` = 不限,`0` = 已满** |
| `batchStatus` | String | 否 | 班期状态,见六.5 |
| `batchStatusLabel` | String | 否 | 班期状态中文文案,可直接显示 |
| `tierPrices` | Object[] | 否 | 该班期的档位价,至少 1 项 |
**`batches[].tierPrices[]` 每一项**
| 字段 | 类型 | 可空 | 说明 |
|------|------|------|------|
| `tierSeq` | Integer | 否 | 档位序号,未配置档位时为 `1` |
| `tierName` | String | 否 | 档位名,未配置档位时为 `"默认档"` |
| `adultPrice` | String | 是 | 成人价(金额字符串) |
| `childPrice` | String | 是 | 儿童价(金额字符串) |
#### 请求示例
```http
GET /admin/product/item/order-picker?lineId=2044248925572919297&page=1&pageSize=20
Authorization: Bearer {token}
```
#### 响应示例
测试环境实测,团期产品:
```json
{
"code": 200,
"msg": null,
"data": {
"total": 1,
"records": [
{
"productId": "2044306857534636034",
"name": "冻干粉发短信给",
"subtitle": "发短信给对方搞定",
"tripDays": 3,
"tripNights": 2,
"tags": ["亲子", "研学", "露营"],
"coverImageUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/ed278d1da74c3827cb06b7f0cf6091fe.jpg",
"startPrice": "1000.00",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "tierDescription": null, "startPrice": "1000.00" }
],
"hot": true,
"nextSaleDate": "2026-10-01",
"productType": "GROUP",
"batches": [
{
"batchId": "2052935476557328386",
"batchNo": "Q202610012052935476548939777",
"batchLabel": "7",
"batchName": "没,那你",
"departureDate": "2026-10-01",
"endDate": "2026-10-03",
"enrollmentDeadline": "2026-09-30",
"maxRooms": 80,
"remainRooms": 25,
"maxParticipants": 0,
"remainSlots": null,
"batchStatus": "ENROLLING",
"batchStatusLabel": "报名中",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "adultPrice": "2925.00", "childPrice": "2425.00" }
]
},
{
"batchId": "2096631555760807938",
"batchNo": "Q202612012096631555752419329",
"batchLabel": "8",
"batchName": "QA-7189-1201",
"departureDate": "2026-12-01",
"endDate": "2026-12-03",
"enrollmentDeadline": "2026-11-30",
"maxRooms": 4,
"remainRooms": 4,
"maxParticipants": 6,
"remainSlots": 6,
"batchStatus": "ENROLLING",
"batchStatusLabel": "报名中",
"tierPrices": [
{ "tierSeq": 1, "tierName": "轻奢", "adultPrice": "1000.00", "childPrice": "600.00" }
]
}
]
}
]
}
}
```
#### 空数据 / 降级响应
非团期产品的 `batches` 是空数组而不是 `null`(测试环境实测):
```json
{
"productId": "2049754271968047106",
"name": "的风格发多少",
"productType": "CORE",
"batches": []
}
```
该产品全部班期都已返团或已取消时,`batches` 同样是 `[]`,产品本身仍出现在列表里。
整页无匹配产品时:
```json
{ "code": 200, "msg": null, "data": { "total": 0, "records": [] } }
```
订单侧班期统计服务不可用时,本接口**不失败**:该批班期按「无活跃订单」计算,`remainRooms` / `remainSlots` 可能偏大(偏向显示为可订),响应结构与 `code` 不变,服务端记 ERROR 日志。
#### 错误响应
未新增错误码,沿用既有:
```json
{
"code": 401,
"msg": "未登录或登录已过期",
"data": null
}
```
```json
{
"code": 403,
"msg": "无权限访问该数据",
"data": null
}
```
#### 业务边界
- **鉴权**: 需登录;`@DataScope` 行级校验产品数据权限,越权返 403。
- **只读**: 无任何写操作,不产生数据变更,可重复调用。
- **班期可见性**: 只返回「未取消 且 返团日未过(返团日为空视为未过)」的班期;已返团、取消中、已取消、已软删的班期不出现。满员(`FULL`)的班期**仍然返回**,由前端灰显。
- **期号口径**: `batchLabel` 按该产品**全部未删班期**的出发日升序位置计算,再做可售过滤——所以它与数组下标不对应,与团期看板显示的期号一致。
- **空值语义**: `remainRooms` / `remainSlots` 为 `null` 表示不限,为 `0` 表示已满;`maxRooms` 恒非 null,`0` 表示不限。
- **降级**: 订单侧统计不可用时按无活跃订单计算并记 ERROR,不 500、不阻断页面。
- **兼容**: 11 个旧字段的名称、类型、顺序完全未变,不消费新字段的调用方无需改动。
---
## 四、契约约束与正确调用方式
本接口是只读 GET,无请求体。约束体现在**怎么读响应**,下面是三条读法对照。
### ✅ 正确 / ❌ 错误 读法对照
| 场景 | 读法 |
|------|------|
| ✅ 判断班期能否选中 | `["ENROLLING", "NEARLY_FULL"].includes(batch.batchStatus)` |
| ❌ 判断班期能否选中 | `batch.remainRooms > 0` → 不限容量时 `remainRooms` 为 `null`,`null > 0` 为 `false`,会把不限的班期误判成不可选 |
| ✅ 显示剩余房数 | `batch.remainRooms ?? "不限"` |
| ❌ 显示剩余房数 | `batch.remainRooms \|\| "不限"` → `0`(已满)会被显示成「不限」 |
| ✅ 取期号 | `batch.batchLabel` |
| ❌ 取期号 | `index + 1` → `batches` 已过滤掉已返团班期,下标与真实期号对不上 |
| ✅ 回传班期 | `productBatchId: String(batch.batchId)` |
| ❌ 回传班期 | `productBatchId: Number(batch.batchId)` → 雪花 ID 超出 JS 安全整数范围,精度丢失 |
### 哪些班期会出现在 `batches` 里
后端已过滤,只返回「**未取消** 且 **返团日未过**(返团日为空视为未过)」的班期。已返团、取消中、已取消、已软删的班期不会出现,**前端不需要再过滤一次**。
---
## 五、数据库行为
本接口为只读查询,**无任何写操作**,不产生数据变更。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- 无该产品数据权限 → 403(`@DataScope` 行级校验)
- 该产品无任何可售班期(全部已返团/已取消)→ `batches` 为 `[]`,产品本身仍出现在列表里
- 非 GROUP 产品 → `batches` 恒为 `[]`,不是 `null`
- 班期未配置档位 → `tierPrices` 返回一项 `tierSeq=1`、`tierName="默认档"` 的兜底档
- 订单侧统计服务降级 → 该批班期按「无活跃订单」计算,剩余数可能偏大,接口不 500 不阻断页面(服务端记 ERROR 日志)
- 本页可售班期总数超过 500 → 服务端记 warn,**不截断**,仍全量返回
---
## 六.5、枚举 / 数据字典
### productType(com.hulalv.enums.ProductTypeEnum)
**所属字段**: `records[].productType` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `CORE` | 核心产品 | `batches` 恒为 `[]` |
| `GROUP` | 小蒙马(即团期产品) | `batches` 为可售班期列表,创建订单必须选定其一 |
| `CUSTOM` | 私人定制 | `batches` 恒为 `[]` |
(中文名取自枚举定义本身,与后台产品管理页的类型显示一致。)
### batchStatus(com.hulalv.enums.BatchStatusEnum)
**所属字段**: `records[].batches[].batchStatus` | **类型**: `String`
| 值 | 中文 | 说明 |
|----|------|------|
| `ENROLLING` | 报名中 | **可选中下单**;会出现在 `batches` |
| `NEARLY_FULL` | 即将满额 | **可选中下单**;会出现在 `batches` |
| `FULL` | 已满额 | 不可选(灰显);仍会出现在 `batches` |
| `FINISHED` | 已结束 | 不可选;一般不会出现(返团日已过会被过滤) |
| `CANCELLING` | 取消中 | 不可选;**不会出现**(后端已过滤) |
| `CANCELLED` | 已取消 | 不可选;**不会出现**(后端已过滤) |
---
## 六.6、修改前后对比
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `records[].productType` | 不存在 | 新增,`CORE` / `GROUP` / `CUSTOM` |
| `records[].batches` | 不存在 | 新增,可售班期数组(非 GROUP 恒 `[]`) |
| 其余 11 个字段 | 有 | **完全不变**(名称、类型、顺序、取值均未动) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 选品页拿班期 | 拿不到,需另外调接口或让运营手填出发日 | 一次请求随产品带回 |
| 班期期号 | 无 | `batchLabel`,与团期看板一致 |
| 请求次数 | 1 次 | 仍是 1 次(班期用一次 `IN` 查询取回,不随产品数增长) |
---
## 六.7、影响评估
- **是否破坏向后兼容**: 否。11 个旧字段完全未动,只追加两个新字段。
- **前端是否必须同步上线**: 否(不上线则维持现状,选品页看不到班期)。但**团期订单必须选班期**这条业务要求要靠前端落地,见八.5。
- **前端 workaround 清理点**: 若前端此前为团期产品单独请求过班期接口或让用户手填出发日,改用本接口的 `batches` 后可撤除。
---
## 七、不影响范围
- **仅影响**: 管理后台「新建订单 → 选择产品」页调用的 `GET /admin/product/item/order-picker`
- **零影响**:
- 订单创建接口 `POST /v3/admin/order`(本次未改 order-v3 任何代码)
- 报价 `POST /admin/product/item/{id}/quote`、价格日历 `pricing-calendar`
- 团期看板全部读端点(`/v3/admin/order/group-batch` 系列)
- 产品侧班期管理接口(`/admin/product/item/{id}/schedule` 系列)
- 小程序 C 端全部接口
- 存量数据(不做任何数据迁移,班期名的前后空格只在输出时去除,不回写库)
---
## 八、测试环境已验证
2026-09-07 测试环境(`api.test.1814.love`)网关实测 **15/15 通过**:
```
GET /admin/product/item/order-picker?lineId=2044248925572919297&page=1&pageSize=20 → 200 ✓
productType=GROUP,batches 14 个字段齐全 ✓
各字段与 GET /admin/product/item/2044306857534636034/schedule/list 逐字段一致 ✓
6 个已返团班期被过滤,10-01 与 12-01 期在列,按 departureDate 升序 ✓
期号 10-01="7"、12-01="8",与 /v3/admin/order/group-batch/board?scope=ALL 逐一相等 ✓
班期名 " 没,那你" → "没,那你"(已去空格) ✓
11 个旧字段一个不少 ✓
线下占位: 容量 8 房 + 占位 3 房 + 线上 0 单 → remainRooms=5 ✓
不限容量: maxRooms=0 → remainRooms=null, remainSlots=null, batchStatus=ENROLLING ✓
满员: 容量 1 房 + 占位 1 房 → remainRooms=0, FULL, "已满额", 且仍在 batches 里 ✓
取消与软删的班期不再出现在 batches ✓
非 GROUP 产品 batches 为 [] 而非 null ✓
```
验证产品: `productId=2044306857534636034`(冻干粉发短信给,GROUP,8 期)、`productId=2049754271968047106`(的风格发多少,CORE)
单元测试: `mvn -pl hl-product-service-v2 test` → `Tests run: 1574, Failures: 0, Errors: 0, Skipped: 0`
---
## 八.5、前端配套(mmg 待办)
1. **`Step1Sku.vue`**: `productType === "GROUP"` 的产品卡展开显示 `batches[]`,每行建议格式
`第{batchLabel}期 · {batchName} · {departureDate} 到 {endDate} · 剩 {remainRooms ?? "不限"} 房 · {batchStatusLabel}`;
`batchStatus` 不属于 `{ENROLLING, NEARLY_FULL}` 的班期灰显不可选。选中档位与班期后 emit 带上 `productBatchId` 与 `batchDepartureDate`。
2. **GROUP 未选班期不允许进入第 3 步**。第 3 步的出发日直接取所选班期的 `departureDate`(不再让用户手选日期),报价 `POST /admin/product/item/{id}/quote` 传 `batchId`。
3. **创建订单 payload**: `productBatchId = String(batch.batchId)`;非 GROUP 产品不传该字段。
4. **深链**: 带 `?productBatchId=…` 进入时,在 `batches` 中预选该期;若该期不在列表里(已结束或已取消),给出提示而不是静默忽略。
5. **金额只显示后端报价结果**,`batches[].tierPrices` 仅用于展示参考价,不参与前端算价。
---
## 九、相关历史 PR
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #7219 | #7189 | 团期看板以产品全班期为基底 + scope 筛选,`batchLabel` 期号口径在此确立 | ✅ 有效 |
| #7256 | #7245 | 班期名去空格 + 不限容量 remain 统一 null + board 库存同源 | ✅ 有效(本接口沿用同一口径) |
| **本 PR #7259** | **#7244** | 选品接口透出 `productType` 与 `batches` | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#7244](https://git.1814.love:8443/wx/HL/issues/7244)
- 关联 PR: [wx/HL#7259](https://git.1814.love:8443/wx/HL/pulls/7259)
- 期号口径来源: `changelogs-v2/2026-09/07_7189_团期看板产品全班期基底与scope范围筛选-修改接口-管理后台.md`
- 剩余库存口径来源: `changelogs-v2/2026-09/07_7245_团期看板班期名与不限容量口径修正-修改接口-管理后台.md`
## 关联 / 联系人
### 链接
- **Issue**: [#7244](https://git.1814.love:8443/wx/HL/issues/7244)
- **PR**: [#7259](https://git.1814.love:8443/wx/HL/pulls/7259)
- **Merge commit**: [9800cddde](https://git.1814.love:8443/wx/HL/commit/9800cddde)
### 联系人
- **后端负责人**: @wx
- **前端负责人**: @mmg