--- schema: "hl-changelog/v2" ticket: "7284" title: "团期下单校验改判实时库存:满员班期退单后能重新下单,满员时错误码由 581028 改为 581031/581034" consumer: "multiple" author: "jw(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "verified" frontend_status: "not_required" frontend_owner: "mmg" frontend_ref: "" target_release: "" verified_at: "2026-09-08" status_note: "前端不改:接口出入参零变化;满员错误码 581028→581031/581034 由拦截器透后端 message(本系统无中央错误码字典,581027 新触发源同此);前端未消费 581028 业务码(仅 disband.js 注释)。实时库存改判纯后端。" updated_at: "2026-09-08" base: "dev-v3" --- # 团期: 下单校验改判实时库存 + 产品域库存聚合 fail-closed > **服务**: hl-order-service-v3、hl-product-service-v2、hl-order-service-v2(**必须按序部署**: product-v2 → order-v3 → order-v2) > **PR**: #7330 > **Issue**: #7284 > **日期**: 2026-09-08 > **影响范围**: GROUP 产品下单(管理后台代客下单 + 小程序下单 + order-v2 admin 创单) --- ## ⚠️ 关键变化 **出入参结构一个字段都没改。** 前端不改不报错,但有两件事必须知道: 1. **「满员班期退单后卖不出去」这个 bug 消失了**——班期一旦被推成「已满额」, 即使客户退单腾出真实空位,下单仍会被 **581028** 拒;现在改判实时库存,有余位就能下。 小程序日历上那个「点进去能选、下单却报错」的分裂也随之消失。 2. **真正满员时的错误码变了**:由 **581028**「团期状态不允许报名」改为 **581031**「团期剩余房间不足」或 **581034**「团期剩余名额不足」。 前端若对 581028 有特殊文案,需知悉该码现在只在「班期已取消 / 已结束 / 状态未知」时出现。 改前实测(AC-1,在部署本次改动**之前**于测试服复现):`maxRooms=2` 的班期下满 2 单后 `batch_status=FULL`,退掉 1 单后同一次 `getBatchInfo` 响应里 `remainingRooms=1`(余量已自愈) 而 `batchStatus` 仍是 `FULL`(死缓存不自愈),再下单返回 `code=581028`。 --- ## 一、背景 GROUP 下单校验读的是产品域**持久列** `group_tour_batch.batch_status`,只放行「报名中 / 即将满额」。 而这个持久列翻回「报名中」的唯一入口是产品服务的 `unenroll`,**order-v3 的普通取消链路 (超时自动取消 / 退款 / admin 取消 / C 端取消)一个都不调它**;唯一能翻回去的 `enroll` 又只在**创单成功后**才调——形成死结。 同时这是一个「展示与下单两套真相」的问题:小程序价格日历的可选性由**实时库存**算, 所以退单后那一天在日历上是可选的,客户点进去下单才被 581028 拒。 第二件事:产品域 `GET /internal/product/batch/{batchId}/info` 的库存聚合原本在失败时 **静默降级为「无活跃订单」**,返回偏大的剩余名额。本次去掉了下单闸对持久状态的依赖后, 这两个余量数就是创单路径上**唯一**的超卖闸,故同单把它改成 fail-closed。 --- ## 二、变更接口清单 | # | 接口 | 方法 | 路径 | 变更类型 | 说明 | |---|------|------|------|----------|------| | 1 | 管理后台代客下单 | POST | `/v3/admin/order` | 行为变化 | 满员判定改用实时库存;出入参与响应结构**不变**,错误码触发集合收窄 | | 2 | 小程序下单 | POST | `/v3/internal/mp/order/create` | 行为变化 | 同上 | --- ## 三、接口详情 ### 1. 管理后台代客下单 `POST /v3/admin/order` **VO**: `OrderCreateRespVO` #### 使用场景 管理后台定制师代客下单。调用方 hl-ui 订单创建页。GROUP 产品必须带 `productBatchId`。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | productId | Body | Long | ✅ | — | 产品 ID;**入参一个都没变** | | productBatchId | Body | Long | GROUP 必填 | — | 产品侧班期 ID;为空抛 581026 | | tierSeq | Body | Integer | ✅ | — | 档位序号 | | departureDate | Body | LocalDate | ✅ | `yyyy-MM-dd` | 出发日期 | | adultCount / childCount / youngChildCount | Body | Integer | ✅ / ❌ / ❌ | ≥0 | 参与名额校验的三项(babyCount 不参与) | | roomCount | Body | Integer | ❌ | ≥0 | 房间(户)数,参与房间库存校验 | | customerName / customerPhone | Body | String | ✅ | — | 客户姓名 / 手机号 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | id | Long | 订单 ID;**响应结构完全不变** | | orderNo | String | 订单号 | | orderStatus | String | PENDING_PAY 等 | | 其余全部字段 | — | **完全不变** | #### 请求示例 ```http POST /v3/admin/order HTTP/1.1 Authorization: Bearer Content-Type: application/json { "productId": 2056947670512971778, "productBatchId": 2097208296820678658, "tierSeq": 1, "departureDate": "2026-11-07", "adultCount": 2, "childCount": 0, "roomCount": 1, "customerName": "张三", "customerPhone": "13800002043" } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "id": "2097211100872290306", "orderNo": "HL20260908143105038", "orderStatus": "PENDING_PAY", "orderStatusName": "待支付" } } ``` 上面这一单下在了一个 `batch_status=FULL`、`remainingRooms=1` 的班期上——**改前它会被 581028 拒**。 #### 空数据 / 降级响应 **产品域库存聚合失败时本接口 fail-closed 拒单**,返回 581027,不再放行。 改前是静默按「无活跃订单」降级、返回偏大的剩余名额从而放行,属超卖风险。 展示链路(小程序团期列表 / 详情、管理后台班期列表 / 价格日历)**保持原有降级不变**, 聚合失败时照常 200 返回,不会整片报错。 ```json { "code": 581027, "message": "团期信息获取失败", "success": false, "data": null } ``` #### 错误响应 ```json { "code": 581031, "message": "团期剩余房间不足", "success": false, "data": null } ``` **错误码集合无新增**,但**触发条件变了**: | 码 | 含义 | 变化 | |----|------|------| | 581028 | 团期状态不允许报名 | **触发集收窄**:只在班期「取消中 / 已取消 / 已结束 / 状态未知」时出现。满员不再走这个码 | | 581031 | 团期剩余房间不足 | **新增触发场景**:真正满员(房间维度)由它承担 | | 581034 | 团期剩余名额不足 | **新增触发场景**:真正满员(人数维度)由它承担 | | 581027 | 团期信息获取失败 | **新增触发源**:产品域库存聚合失败(fail-closed) | #### 业务边界 - **未知状态一律拒单**:班期状态为空或不在字典内时按 581028 拒。产品侧将来新增状态值而订单侧 镜像未同步时,应当暴露成拒单而不是静默放行——跨服务枚举漂移不该由客户承担。 - **满员仍然拒得住**:改的只是「用哪个数判满」,不是「不判满」。实测真满员时返 581031。 - **产品域仍会继续写「已满额」持久值**,只是不再有人拿它做下单闸门;管理后台班期列表上 该状态照常显示,前端无需改动。 - **并发超卖窗口客观存在**:本次不做 TCC 强一致,创单入口仍无按班期的分布式锁, 预查是 TOCTOU 不可靠防护——这是既有限制,本次未改变。 ### 2. 小程序下单 `POST /v3/internal/mp/order/create` **VO**: `MpOrderDetailVO` #### 使用场景 小程序下单,经 mp BFF 转发到 order-v3。与管理后台代客下单共用同一套 GROUP 校验逻辑。 #### 入参 | 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | |------|------|------|------|------|------| | productId | Body | Long | ✅ | — | 产品 ID;**入参一个都没变** | | productBatchId | Body | Long | GROUP 必填 | — | 产品侧班期 ID | | tierSeq | Body | Integer | ✅ | — | 档位序号 | | departureDate | Body | LocalDate | ✅ | `yyyy-MM-dd` | 出发日期 | | adultCount / childCount / youngChildCount | Body | Integer | ✅ / ❌ / ❌ | ≥0 | 参与名额校验的三项 | | roomCount | Body | Integer | ❌ | ≥0 | 房间(户)数 | #### 出参 `Result` | 字段 | 类型 | 说明 | |------|------|------| | orderId | Long | 订单 ID;**响应结构完全不变** | | orderNo | String | 订单号 | | orderStatus | String | 订单状态 | | 其余全部字段 | — | **完全不变** | #### 请求示例 ```http POST /v3/internal/mp/order/create HTTP/1.1 Content-Type: application/json { "productId": 2056947670512971778, "productBatchId": 2097208296820678658, "tierSeq": 1, "departureDate": "2026-11-07", "adultCount": 2, "roomCount": 1 } ``` #### 响应示例 ```json { "code": 200, "message": "成功", "success": true, "data": { "orderId": "2097211100872290306", "orderNo": "HL20260908143105038", "orderStatus": "PENDING_PAY" } } ``` #### 空数据 / 降级响应 同管理后台下单:产品域库存聚合失败时返 581027 拒单;展示链路不受影响, 价格日历与班期列表在同一场景下仍正常 200 返回。 ```json { "code": 581027, "message": "团期信息获取失败", "success": false, "data": null } ``` #### 错误响应 ```json { "code": 581034, "message": "团期剩余名额不足", "success": false, "data": null } ``` 错误码变化同管理后台下单那张表。 #### 业务边界 - **「日历可选、下单报错」这个分裂消失**:日历可选性本来就按实时库存算,现在下单闸也按实时库存判, 两边终于是同一个真相。 - **小程序侧无需改代码**:出入参与响应结构零变化;只是原本会拿到 581028 的场景, 现在要么下单成功,要么拿到 581031 / 581034。 --- ## 四、契约约束与正确调用方式 - **不要再把 581028 当作「满员」的信号**。满员请判 581031 / 581034。 581028 从此只表示「这个班期已取消 / 已结束 / 状态异常」,属于不可恢复的拒绝, 提示客户换一期,而不是提示「稍后再试」。 - **581027 是可重试的**:它表示产品域暂时算不出库存,客户稍后重试通常会成功。 - 班期列表 / 价格日历的可选性判定**不要改**:它们本来就读实时库存,与下单闸现在一致。 --- ## 五、数据库行为 - 本次**无表结构变更、无数据迁移**。 - 产品域班期的「已满额」持久值仍照常写入,只是不再参与下单判定。 - 订单创建、名额计数的写入行为一字未改。 --- ## 六、边界行为 - **班期不属于该产品**:仍返 581055,未变。 - **已过报名截止日 / 已出发**:仍分别返 581029 / 581030,未变。 - **未选班期**:GROUP 产品未传 `productBatchId` 仍返 581026,未变。 - **零元班期 / 不限名额班期**:`maxRooms` 或 `maxParticipants` 为 0/空表示不限, 对应的余量为 null,跳过该维度校验,行为未变。 ## 六.6、修改前后对比 | 场景 | 改前 | 改后 | |------|------|------| | 班期已满员被推成「已满额」,客户退单腾出真实空位后再下单 | **581028 拒单**,且永远解不开(取消链路不回写产品域状态) | **200 创单成功** | | 班期真正满员(剩余房间为 0)再下单 | 581028「团期状态不允许报名」 | **581031「团期剩余房间不足」** | | 班期真正满员(剩余名额为 0)再下单 | 581028 | **581034「团期剩余名额不足」** | | 班期已取消 / 取消中 / 已结束 | 581028 | 581028(**不变**) | | 班期状态为空或不在字典内 | 581028 | 581028(**不变**,fail-closed) | | 产品域库存聚合失败时下单 | 按「无活跃订单」降级,**放行**(超卖风险) | **581027 拒单**(fail-closed) | | 产品域库存聚合失败时看班期列表 / 价格日历 | 正常 200 | 正常 200(**不变**,展示链路保持 fail-open) | ## 六.7、影响评估 - **前端零改动**:两个下单接口的出入参与响应结构完全不变。 - **需要前端知悉的一点**:若对 581028 做过「团期状态不允许报名」的特殊文案或埋点, 满员场景现在走 581031 / 581034,文案分支需相应调整(不调也只是提示词不够精准,不会报错)。 - **对客户的净效果**:原本会被莫名其妙拒掉的下单现在能成功;原本模糊的「团期状态不允许报名」 换成了明确的「剩余房间/名额不足」。 - **对运营的净效果**:不再需要靠「调整满团名额」或「去产品后台编辑一次班期」来玄学解锁班期。 - **风险**:产品域库存聚合失败期间 GROUP 下单会整体拒单(581027)。这是刻意的取舍—— 宁可拒单,不可超卖。该失败本身是产品域 → 订单域的内部调用异常,正常情况下不发生。 --- ## 七、不影响范围 - 非 GROUP 产品(自由行 / 定制)的下单校验**完全未触碰**。 - 订单取消、退款、转期链路未改。 - 产品域班期的展示态计算(`GroupBatchDisplayStatusResolver`)未改。 - 团期名额计数与对账 Job 未改。 - 管理后台「调整满团名额」的校验规则未改。 --- ## 八、测试环境已验证 部署次序(硬约束):**product-v2 14:27:25 DONE → order-v3 14:28:43 STEP1 → order-v2 14:30:14 DONE**, 三服务同一 commit。回滚反序(先 order-v3 后 product-v2)也已实测走通。 | 验证项 | 结果 | |--------|------| | 满员班期退单后再下单(改前 → 改后) | **581028 → 200 创单成功** | | 下满即查产品域状态 | 仍「报名中」——填满班期的那一单不会把状态推成「已满额」 | | 真满员再下单 | **581031「团期剩余房间不足」** | | 班期「已取消」/「取消中」/「已结束」 | 均 **581028** | | 班期状态置为字典外的值 | **581028**(fail-closed) | | 产品域库存聚合失败时 `getBatchInfo` | **480203**(产品域内部码,订单侧翻成 581027) | | 同场景下 order-v2 admin 创单 | **503102** 拒单(锁内库存硬守卫不再拿到虚高余量) | | 同场景下团期转期 | **200 成功**,`warnings` 含「目标团期行程天数未取到,订单沿用原天数,请人工核对」 | | 同场景下展示链路 | 小程序班期列表 / 产品详情、管理后台班期列表 / 价格日历**全部仍 200** | | 单元测试 | product-v2 1606 例、order-v3 9048 例,均 0 失败 0 错误 | --- ## 十、相关文档 - Issue #7284、PR #7330 - 关联工单:#7283(流团后班期置为不可售,本单的前置)、#7293(库存聚合失败不再持久改写班期状态) - 设计文档校正:SRS 与 HOUSE 详设中「订单取消时主 API 调 product unenroll」的描述与代码不符, 已在本次一并标注校正(order-v3 的取消链路从不调用该端点) --- ## 关联 / 联系人 - 后端:jw - 前端:mmg(hl-ui / 小程序) - 前端待办:①若对 581028 有特殊文案,满员场景改判 581031 / 581034; ②可选补录 581027 的新触发源说明。**接口结构零变化,不改也不会报错。**