--- schema: "hl-changelog/v2" ticket: "7244" title: "订单选品接口透出 productType 与团期班期列表 batches" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "verified" frontend_owner: "mmg" frontend_ref: "ccbbc446" target_release: "" verified_at: "2026-09-07" status_note: "前端已交付并验证(功能主体 ccbbc446 + API 契约注释 7894071e):新增 groupBatch utils(可选仅 ENROLLING/NEARLY_FULL、剩房 ?? 不吞 0、期号取 batchLabel、batchId 全程字符串)与 useOrderDeepLink(详情 lineId 拉选品 batches 定位预选,缺期/已满警告转 Step1);Step1Sku GROUP 卡展开班期行、Step2Info batchLocked 锁出发日跳日历反解、Step3Confirm 增「第N期」、index canNext 缺班期拦截+创单 productBatchId 优先 draft、报价 quote 传所选 batchId。order-v2/new 定向 Vitest 29 绿 + checkpoint 全绿(含 Vitest 全量+生产构建)。" 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>` `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 待办) > **2026-09-07 13:50 复测结论**:后端接口已就绪。`GET /admin/product/item/order-picker?lineId=2044248925572919297` 对产品「冻干粉发短信给」返回 `productType=GROUP` 与 2 期可售班期——第 7 期「没,那你」2026-10-01→2026-10-03 剩 25 房 ENROLLING;第 8 期「QA-7189-1201」2026-12-01→2026-12-03 剩 4 房 ENROLLING。但此刻管理后台「新建订单 → 选择产品」页展开产品后仍只显示档位、没有班期列表,经查 `origin/master` 的 `src/views/order-v2/new/components/Step1Sku.vue` 中 `batches` / `productType` / `batchLabel` / `productBatchId` **均未出现(0 次命中)**,即前端尚未消费本契约。**这不是后端缺陷**,下面 5 条待办完成后该页即可选班期。 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