这个提交包含在:
@@ -0,0 +1,321 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7339"
|
||||
title: "房态库存操作改为请求体幂等契约并引入补偿轮次"
|
||||
consumer: "internal"
|
||||
author: "wx(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "verified"
|
||||
frontend_status: "not_required"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "resource 与 order-v3 已在测试环境按 merge commit 439e53d1e 配对发布;内部 Feign、跨实例幂等、补偿轮次及 Job 已验证"
|
||||
updated_at: "2026-09-08"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# 房务库存:房态库存幂等与补偿轮次
|
||||
|
||||
> **服务**: hl-resource-service、hl-order-service-v3
|
||||
> **PR**: #7356
|
||||
> **Issue**: #7339
|
||||
> **日期**: 2026-09-08
|
||||
> **影响范围**: order-v3 到 resource 的内部房态扣减/释放 Feign 契约与补偿生命周期
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化
|
||||
|
||||
库存扣减和释放的 `date`、`qty`、`opId` 从 Query 参数改为 JSON 请求体,属于不兼容的内部 Feign 变更;`hl-resource-service` 与 `hl-order-service-v3` 必须同批发布,禁止新旧版本混跑。补偿操作 ID 必须带轮次;普通单夜释放严格为 `release-{logId}-{rollbackRound}`。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
原接口缺少持久化幂等账本,同一业务动作在超时重试、跨实例重放或补偿重试时可能重复改变库存。现在服务端冻结首次请求指纹和首次结果,订单侧用 `rollbackRound` 区分同一持有关系的重新扣减轮次,并保留既有跨夜循环语义。
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 扣减房型库存 | POST | `/internal/resource/hotels/{hotelId}/room-types/{roomTypeId}/deduct` | Query 改 JSON Body | 增加持久化幂等、请求指纹与首次结果重放 |
|
||||
| 2 | 释放房型库存 | POST | `/internal/resource/hotels/{hotelId}/room-types/{roomTypeId}/release` | Query 改 JSON Body | 按实际已用量下界 CAS 释放,避免负数 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 扣减房型库存 `POST /internal/resource/hotels/{hotelId}/room-types/{roomTypeId}/deduct`
|
||||
|
||||
**VO**: `RoomInventoryOpReqDTO -> Result<HouseInventoryOpRespDTO>`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
order-v3 在配房库存持有时通过内部 Feign 调用。调用方必须为每个生命周期轮次生成稳定且唯一的 `opId`;同一 `opId` 的重试必须保持路径、日期、数量和操作类型完全一致。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| hotelId | Path | Long | ✅ | 非空 | 酒店 ID |
|
||||
| roomTypeId | Path | Long | ✅ | 非空 | 房型 ID |
|
||||
| date | Body | LocalDate | ✅ | YYYY-MM-DD | 入住日期 |
|
||||
| qty | Body | Integer | ✅ | >= 1 | 扣减数量 |
|
||||
| opId | Body | String | ✅ | 非空,最长 256 | 单夜为 `deduct-{idempotencyKey}-{rollbackRound}`;仅多夜追加 `-d{nightIndex}` |
|
||||
|
||||
#### 出参 `Result<HouseInventoryOpRespDTO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| changed | Integer | 首次操作实际改变数量 |
|
||||
| remain | Integer | 首次操作后的可用库存 |
|
||||
| duplicated | Boolean | 是否为已提交首次结果的重放 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"qty": 2,
|
||||
"opId": "deduct-asgn-123-d1-rt456-h789-0"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": { "changed": 2, "remain": 8, "duplicated": false },
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
`hotel.stock.enabled=false` 时不改变库存,但仍冻结该 `opId` 的首次成功快照;开关恢复后重放不会补执行。
|
||||
|
||||
```json
|
||||
{ "code": 200, "data": { "changed": 0, "remain": 10, "duplicated": false }, "success": true }
|
||||
```
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 310403,
|
||||
"message": "房型库存不足或日历记录缺失",
|
||||
"success": false,
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 仅允许内部鉴权调用,不通过管理端网关暴露。
|
||||
- 首次账本写入、库存变化和结果快照属于同一事务。
|
||||
- 重复 `opId` 返回首次 `changed/remain` 且 `duplicated=true`,不会读取当前库存伪造新快照。
|
||||
- 同一 `opId` 改变 `hotelId`、`roomTypeId`、`date`、`qty` 或操作类型会拒绝。
|
||||
- 库存不足的 `310403` 结果持久化;补充库存后重放旧 `opId` 仍拒绝。
|
||||
|
||||
### 2. 释放房型库存 `POST /internal/resource/hotels/{hotelId}/room-types/{roomTypeId}/release`
|
||||
|
||||
**VO**: `RoomInventoryOpReqDTO -> Result<HouseInventoryOpRespDTO>`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
order-v3 删除配房、取消订单、换酒店回滚或补偿 Job 释放房态。普通单夜必须使用 `release-{logId}-{rollbackRound}`;日期后缀只用于保留的多夜循环。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| hotelId | Path | Long | ✅ | 非空 | 酒店 ID |
|
||||
| roomTypeId | Path | Long | ✅ | 非空 | 房型 ID |
|
||||
| date | Body | LocalDate | ✅ | YYYY-MM-DD | 释放日期 |
|
||||
| qty | Body | Integer | ✅ | >= 1 | 请求释放上限 |
|
||||
| opId | Body | String | ✅ | 非空,最长 256 | 单夜严格为 `release-{logId}-{rollbackRound}`;仅多夜追加 `-d{stayDate}` |
|
||||
|
||||
#### 出参 `Result<HouseInventoryOpRespDTO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| changed | Integer | 实际释放数量,等于 `min(qty, stockUsed)` |
|
||||
| remain | Integer | 操作后的可用库存 |
|
||||
| duplicated | Boolean | 是否为已提交首次结果的重放 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"date": "2026-09-09",
|
||||
"qty": 999,
|
||||
"opId": "release-2097325826075738113-1"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": { "changed": 1, "remain": 10, "duplicated": false },
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
当前已用量为 0 时,接口成功返回 `changed=0`;不会把已用量更新为负数。
|
||||
|
||||
```json
|
||||
{ "code": 200, "data": { "changed": 0, "remain": 10, "duplicated": false }, "success": true }
|
||||
```
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 310404,
|
||||
"message": "酒店ID/房型ID/日期不可为空",
|
||||
"success": false,
|
||||
"data": null
|
||||
}
|
||||
```
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 仅允许内部鉴权调用,不通过管理端网关暴露。
|
||||
- 释放先读取当前已用量,计算实际释放量,再用类型安全的期望值与下界 CAS 更新;0 行时重读并有界重试。
|
||||
- 同一释放 `opId` 重放不重复回补;不同释放 `opId` 并发也保证 `stockUsed >= 0`。
|
||||
- `rollbackRound` 初始为 0;仅当同一扣减日志从 `DONE` 重新进入持有状态时原子自增一次。
|
||||
- `nights > 1` 继续按既有多夜循环处理,不拆分原扣减日志,也不拒绝多夜请求。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式
|
||||
|
||||
### ✅ 正确 / ❌ 错误 payload 对照
|
||||
|
||||
| 场景 | payload |
|
||||
|------|---------|
|
||||
| ✅ 首次扣减 | `{ "date": "2026-09-09", "qty": 2, "opId": "deduct-asgn-123-d1-rt456-h789-0" }` |
|
||||
| ✅ 同操作重试 | 请求体与路径全部不变 |
|
||||
| ✅ 单夜释放 | `{ "date": "2026-09-09", "qty": 2, "opId": "release-456-0" }` |
|
||||
| ❌ 沿用旧 Query 参数 | `?date=2026-09-09&qty=2&opId=...`,新版本不再接受 |
|
||||
| ❌ 重用 opId 改数量 | 同 `opId` 将 `qty` 从 2 改为 3,会拒绝 |
|
||||
|
||||
### 切换状态时的必要动作
|
||||
|
||||
`hl-resource-service` 与 `hl-order-service-v3` 必须同批停旧、同 commit 启新;禁止任一服务先恢复流量形成 Query/Body 混版。回退必须两服务一起从 R0 回到 R-1,必要时再一起回 R-2;新增迁移保持前向兼容,不回滚 DDL。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为
|
||||
|
||||
| 调用行为 | 外部可观察结果 |
|
||||
|----------|----------------|
|
||||
| 首次成功扣减/释放 | 操作身份、完整请求指纹和首次结果原子提交 |
|
||||
| 相同 opId 完整重放 | 不再改变库存,返回首次快照并标记 duplicated |
|
||||
| 相同 opId 不同指纹 | 拒绝,不覆盖首次身份或结果 |
|
||||
| 库存不足扣减 | 返回并持久化 310403,后续重放仍拒绝 |
|
||||
| DONE 日志重新扣减 | `rollbackRound` 原子 +1,新轮次使用新的扣减/释放 opId |
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 未携带有效内部令牌 → 403。
|
||||
- 扣减库存不足或日历缺失 → 310403,且旧 `opId` 永久关闭。
|
||||
- 日期、数量、路径或操作类型与原 `opId` 指纹不一致 → 310404 拒绝。
|
||||
- 释放数量大于当前已用量 → 只释放实际已用量。
|
||||
- 并发重复和并发不同释放键 → 最终库存一致,`stockUsed` 不小于 0。
|
||||
- 补偿 Job 只认 `updateTime` 已超过宽限期的精确 `PENDING` 快照;新鲜更新不会被旧扫描结果误处理。
|
||||
|
||||
## 六.6、修改前后对比
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| date | Query `@RequestParam` | JSON Body `RoomInventoryOpReqDTO.date` |
|
||||
| qty | Query `@RequestParam` | JSON Body `RoomInventoryOpReqDTO.qty`,>= 1 |
|
||||
| opId | Query `@RequestParam` | JSON Body `RoomInventoryOpReqDTO.opId`,最长 256,完整指纹冻结 |
|
||||
| duplicated | 非稳定重放语义 | 明确表示命中首次已提交快照 |
|
||||
| rollbackRound | 无 | 从 0 开始,DONE 复活时自增一次 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 重复请求 | 可能重复改变库存 | 唯一账本重放首次结果 |
|
||||
| 库存不足后旧请求重试 | 补库存后可能延迟执行 | 310403 持久拒绝,旧 opId 不再执行 |
|
||||
| 超量释放 | 缺少轮次隔离和下界保护 | 按实际已用量释放,类型安全 CAS 有界重试 |
|
||||
| 同一日志重新持有 | 操作身份可能与旧释放碰撞 | rollbackRound 区分各生命周期 |
|
||||
|
||||
## 六.7、影响评估
|
||||
|
||||
- **是否破坏向后兼容**: 是,内部 Feign 从 Query 改为 JSON Body。
|
||||
- **前端是否必须同步上线**: 否;无 frontend API 变化。
|
||||
- **前端 workaround 清理点**: 无。
|
||||
- **服务发布约束**: resource 与 order-v3 必须同批发布/同批回退,不能混版。
|
||||
|
||||
## 七、不影响范围
|
||||
|
||||
- **仅影响**: order-v3 调 resource 的内部房态扣减、释放及补偿生命周期。
|
||||
- **零影响**:
|
||||
- 管理后台和小程序 HTTP 入参/出参。
|
||||
- 既有酒店、房型和价格日历读取接口。
|
||||
- 多夜配房循环;不拆日志、不拒绝 `nights > 1`。
|
||||
- 其他 order-v3 订单、计价与支付流程。
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
```text
|
||||
两服务同批部署 dev-v3@439e53d1e,每服务 2 个新实例,旧 PID 无重叠,四个健康探针均 HTTP 200 ✓
|
||||
网关 GET /admin/hotel/items → HTTP 200 / code 200 ✓
|
||||
网关 GET /v3/admin/house/staff → HTTP 200 / code 200 ✓
|
||||
跨实例重复扣减返回首次快照,date/qty/opType 三类指纹冲突均拒绝 ✓
|
||||
普通单夜轮次 opId:deduct-...-0/1 与 release-{logId}-0/1 精确命中 ✓
|
||||
释放 qty=999、并发不同 release opId 后 stockUsed=0 且全库 stockUsed<0 为 0 ✓
|
||||
库存不足 310403 在补库存后用旧 opId 重放仍拒绝 ✓
|
||||
补偿 Job 跳过新鲜 PENDING;双实例并发触发后每轮 release ledger 恰好 1 条并收敛 DONE ✓
|
||||
测试夹具、账本和订单日志全部清理;Job 1025/1026 已恢复 ACTIVE ✓
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 九、相关历史 PR
|
||||
|
||||
| PR | Issue | 说明 | 是否仍有效 |
|
||||
|----|-------|------|------------|
|
||||
| #7356 | #7339 | 内部库存持久化幂等、释放 CAS 与补偿轮次 | ✅ 最新 |
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7339](https://git.1814.love:8443/wx/HL/issues/7339)
|
||||
- 关联 PR: [wx/HL#7356](https://git.1814.love:8443/wx/HL/pulls/7356)
|
||||
- 部署回退: R0 `439e53d1e`;R-1 `4a39b7fbb`;R-2 `f36a5b4e`
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7339](https://git.1814.love:8443/wx/HL/issues/7339)
|
||||
- **PR**: [#7356](https://git.1814.love:8443/wx/HL/pulls/7356)
|
||||
- **Merge commit**: [439e53d1e273d58e68988c5db39db3e21d4733f2](https://git.1814.love:8443/wx/HL/commit/439e53d1e273d58e68988c5db39db3e21d4733f2)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
在新工单中引用
屏蔽一个用户