docs(changelog): #7980 补三份 @Lock4j 同名锁 changelog(房务7口/出行人+发票8口/产品库存2口)
changelog-filename-gate / validate (push) Failing after 1s
changelog-filename-gate / validate (push) Failing after 1s
三份同属一个机制:@Lock4j 未写 name 时 lock4j 把方法名拼进锁键,keys 逐字相同的 多个方法各持一把锁、互不阻塞。本批给各组补同一个显式 name,互斥才真正生效。 对前端唯一可见的变化是这些端点新增一个可能返回的 100503(HTTP 200 + body code)。 路径、方法、请求/响应字段全部零变化。 - 配房工作台 7 个写口(PR #8069):全 admin,无 mp/C 端。 - 出行人删改 + 发票申请 8 个写口(PR #8049):三组全部触达小程序 C 端, 逐端点沿 hl-mp-service 的 Feign 与公网 Controller 查证了调用链。 - 产品库存扣减/恢复 2 个 internal 口(PR #8047):当前零调用方,修复是预防性的。 三份 backend_status 均为 deployed 且附真实部署读数:product-v2 滚到 ba8aab3ab (部署前 4cbccc26b,落后 93 个提交)、order-v3 滚到 ba8aab3ab(部署前 c35251b07), merge-base --is-ancestor 逐个验过。其中 AC-5 的 b9738a20f 在部署前确实不在运行版本内, 是真正的「从不生效到生效」。 🔴 三份的「八、测试环境已验证」都明写了没有做功能调用取证及其原因(写口调用会真实 改动配房行/出行人行/库存,测试服数据多会话共用且撤不回来),并写明 100503 一次都 没触发过——是「没测」不是「测过不会触发」。互斥由落库级并发 IT 与按效果枚举的契约 用例覆盖,这是 name 契约类改动的恰当取证层级。 Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
@@ -0,0 +1,291 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503,并订正 Swagger notes 里『乐观锁』的错误说法(接口路径/字段零增删)"
|
||||
consumer: "internal"
|
||||
author: "wx(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "not_required"
|
||||
frontend_status: "not_required"
|
||||
frontend_owner: ""
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "PR #8047 已 squash 合并 dev-v3(合并提交 f72a7548c)。2026-09-20 23:20 已部署测试服 hl-product-service-v2:滚动更新两实例(8183 先起、7 秒 UP,再滚 8083、7 秒 UP),部署前 COMMIT=4cbccc26b(停在 2026-09-19,落后 93 个提交且这些提交触及本服务),部署后 COMMIT=ba8aab3ab;git merge-base --is-ancestor f72a7548c ba8aab3ab 返回 ANCESTOR_YES,确认本次改动已在运行版本内。同轮顺带把 #8011 的用例修复(4c2ec76a2)一并带上。📌 订正草稿里的一处理由错误:本仓是 monorepo、所有合并都进 dev-v3,f72a7548c 当然落在 dev-v3 祖先链内;此前用 order-v3 的部署点 5d14bc524 做 --is-ancestor 得到 NO,原因只是「那时还没滚到」,不是「属于另一条分支的独立提交」——错误理由若留着,会让人去找一条并不存在的分支线。部署前已核对 4cbccc26b..origin/dev-v3 的 hl-common 改动为纯新增(VehicleCategoryNameDTO +16 行、一个测试 +51 行),无破坏性契约变更,故单滚 product-v2 不需要连消费方一起滚。🔴 **未做任何功能调用取证,这是有意为之**:两个端点是 /internal/product/** 且当前零调用方,既不经网关、也没有任何 Feign 声明指向它们;直接打端口调用会真实扣减/恢复库存,副作用落在共享测试服上由其他会话承担,且 restore 不保证是 deduct 的精确逆操作。互斥本身由单测 ProductStockLockNameGuardTest(按效果反查两处 name 非空且相等)覆盖,这是 name 契约类改动的恰当取证层级。100503 在本服务上未做任何触发尝试,不要读成「测过不会触发」。gateway_status=not_required:两个端点路径/方法零变化,且 /internal/** 按 HL 架构约定不暴露至公网。frontend_status=not_required:全仓 grep 确认无任何 @FeignClient 声明指向 deduct-stock/restore-stock,order-v3 的 ProductFeignClient javadoc 亦明写这些接口「待后续 Issue 按需添加」,不存在任何前端或其他服务的触达路径,故本次互斥修复目前是预防性的。consumer 字段填 internal:这两个端点不属于 admin/mp 任何一端的直接接口。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# product-v2: 产品价格日历库存扣减/恢复两写口补同一把 @Lock4j name,新增可重试冲突码 100503
|
||||
|
||||
> **存放目录**:
|
||||
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
|
||||
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
|
||||
>
|
||||
> **服务**: hl-product-service-v2
|
||||
> **PR**: #8047
|
||||
> **Issue**: #7980(AC-8)
|
||||
> **日期**: 2026-09-20
|
||||
> **影响范围**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个跨服务 internal 端点(**当前零跨服务调用方**,见业务边界)
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
|
||||
|
||||
- `ProductPriceCalendarService.deductStock` 与 `restoreStock` 的 `@Lock4j` 的 `keys` **逐字相同**,但**两处都没写 `name`**——lock4j 缺省 `name` 时把「包名+类名+方法名」拼进 Redis 键,两处因此**各持一把锁**,可以并发互相踩踏。而 Mapper 层的实现是 `select → 改 sold → updateById` 的读改写(本身不原子),正确性完全押在这把此前不生效的锁上。
|
||||
- 本次给两处补上同一个显式 `name = "product:stock"`,互斥才真正生效。**没有新增接口、没有增删字段**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。
|
||||
- 🔴 **本次同时订正了 `InternalProductController:121` Swagger notes 里的错误说法**:改前写的是「乐观锁扣减价格日历库存」,但 `product_price_calendar` 表**无 `version` 列**、`ProductPriceCalendarDO` **无 `@Version`**,「乐观锁」这个说法是假的——真实机制是本次修复的这把分布式锁。前端/调用方若据旧 notes 认为这里有乐观锁重试语义,应以本文档为准撤销该认知。
|
||||
- 🔴 **这把锁的覆盖边界(避免误读成"sold 并发已全面安全")**:同一行 `sold` 还有第三个写口 `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用),同样是 select→改 sold→updateById 的读改写,且**不取本锁**,表上无 `version`/CAS,唯一键 `uk_product_date_tier` 只约束行身份不约束 `sold`。所以「管理端批量设价 ∥ 扣库存/恢复库存」的丢更新依旧存在——那是另一个问题,本次这把锁挡不住,本 PR 未处理,不在本文档改动范围内。
|
||||
- 🔴 **两个端点当前零跨服务调用方**:全仓 grep `*Feign*.java` 对 `deduct-stock`/`restore-stock` 零命中,没有任何其他微服务的 Feign 客户端声明指向这两个路径。补锁不是因为"现在正在出问题",而是补齐一条本该存在但一直缺失的互斥闸口——回归面可实证为零(不影响任何现存调用方),闸口本身仍要补齐,不能以"零调用方"为由不修。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景(选填)
|
||||
|
||||
工单 #7980 AC-8 原文写的是"零调用方",实测更准确的说法是「**零跨服务调用方**」——`InternalProductController:127/137` → `InternalProductService:590/600` → `deductStock`/`restoreStock` 这条链路在 `origin/dev-v3` 上本身是通的(Controller→Service→Mapper 三层代码完整、可编译、可被同服务内其他代码或未来的 Feign 客户端调用),只是当前没有任何其他微服务声明了指向这两个路径的 `@FeignClient` 方法。
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 扣减库存(内部) | POST | `/internal/product/{productId}/deduct-stock` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
|
||||
| 2 | 恢复库存(内部) | POST | `/internal/product/{productId}/restore-stock` | 新增可能返回的错误码 | 与 §1 共用同一把 `product:stock` 锁 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 扣减库存(内部) `POST /internal/product/{productId}/deduct-stock`
|
||||
|
||||
**VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
供其他微服务(如 order 系)通过 Feign 跨服务扣减产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层是 `select → 改 sold → updateById` 的读改写,靠 `@Lock4j(name=product:stock)` 分布式锁串行化;本表无 `version` 列、非乐观锁(Swagger notes 已随本次改动订正);本端点不做幂等,幂等由调用方保证。本次改动不涉及入参/出参,只新增一条「被 §2 恢复库存占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| productId | Path | Long | ✅ | - | 产品 ID |
|
||||
| date | Query | String | ✅ | yyyy-MM-dd | 日期 |
|
||||
| count | Query | int | ✅ | `@Min(1)` | 扣减数量,至少为 1 |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据,成功恒为 `null` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
POST /internal/product/1234567890123456789/deduct-stock?date=2026-10-01&count=2
|
||||
```
|
||||
|
||||
(跨服务 Feign 调用,无请求体,参数走 Query。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」/降级概念(成功恒返回 `data: null`;库存不足时不是"空数据"而是显式失败,见错误响应)。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`count < 1`) |
|
||||
| 480201 | 该日期库存不足或未开放(`sold + count` 超过 `dailyStock`,或该产品该日期无价格日历记录),请重新选择出发日期 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §2 恢复库存共用同一把 `product:stock` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`——本端点不做幂等,幂等由调用方(order)保证,本次改动未涉及此设计。
|
||||
- 抢锁失败(100503)时该次请求未进入方法体,零写入——`sold` 列不会被部分更新。
|
||||
- 库存判定只按 `tier_seq=1`(默认档位)查询(`selectByProductIdAndDate` 硬编码),多档位产品的其余档位库存不受本端点影响——这是既有实现的既有边界,非本次改动引入,本 PR 未处理。
|
||||
- 该锁**只覆盖本方法与 §2 恢复库存**,不覆盖 `batchUpsert`(管理端批量设价),见「⚠️ 关键变化」。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 2. 恢复库存(内部) `POST /internal/product/{productId}/restore-stock`
|
||||
|
||||
**VO**: `无 ReqVO(Query 参数直绑,无请求体) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
供其他微服务在订单取消/过期时通过 Feign 跨服务恢复产品价格日历库存(**当前无实际调用方**,见「⚠️ 关键变化」)。底层同样是 `select → 改 sold → updateById` 的读改写,`sold` 不减到负数(`Math.max(sold-count, 0)`)。本次改动不涉及入参/出参,只新增一条「被 §1 扣减库存占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| productId | Path | Long | ✅ | - | 产品 ID |
|
||||
| date | Query | String | ✅ | yyyy-MM-dd | 日期 |
|
||||
| count | Query | int | ✅ | `@Min(1)` | 恢复数量,至少为 1 |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据,成功恒为 `null` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
POST /internal/product/1234567890123456789/restore-stock?date=2026-10-01&count=2
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
该产品该日期无价格日历记录时静默跳过(不抛异常,`ProductPriceCalendarMapper#restoreStock` 内部 `record == null` 时直接 return,无任何写入)。恢复数量超过已扣数量时 `sold` 按 0 兜底,不会变负数,同样不报错。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(`count < 1`) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 扣减库存共用同一把 `product:stock` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`,同 §1。
|
||||
- 抢锁失败(100503)时零写入。
|
||||
- 本方法本身不会因"库存记录不存在"或"恢复超过已扣数量"抛错,均静默处理(这是既有行为,非本次改动引入)。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式(接口类必写)
|
||||
|
||||
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
|
||||
|
||||
### 并发冲突(100503)的正确处理方式
|
||||
|
||||
两个端点本次开始**真正互斥**(同一 `productId+date` 维度):同一时刻只有一个能执行,其余排队等待,等待超过 3 秒(`acquireTimeout`,本组 `@Lock4j` 未显式设置,取 lock4j-core 注解默认值 3000ms)直接返回失败。
|
||||
|
||||
| 场景 | 响应 |
|
||||
|------|------|
|
||||
| ✅ 单个请求,无并发 | 200 + 正常业务结果 |
|
||||
| ✅ 同 `productId+date` 两个请求先后到达,第二个在 3 秒内轮到锁 | 两个都 200(第二个排队等待) |
|
||||
| ❌ 同 `productId+date` 两个请求并发,第二个等待超过 3 秒未抢到锁 | `{ "code": 100503, "message": "资源被占用,请稍后重试", "success": false }`,**HTTP 状态码仍是 200** |
|
||||
| ✅ 不同 `productId` 或不同 `date` 并发 | 互不影响,各自独立加锁(锁键含 `productId` 与 `date`) |
|
||||
|
||||
**调用方(若未来接入)必须做的事**:判断响应体 `code === 100503`(不是判 HTTP 状态码),命中时按业务语义重试或提示上游;**不要**当作系统异常。
|
||||
|
||||
### Swagger notes 订正说明(供以此为契约的调用方核对)
|
||||
|
||||
`InternalProductController:121` 扣减库存端点的 Swagger notes 已从「乐观锁扣减价格日历库存」改为「实现是 select→改 sold→updateById 的读改写,靠 @Lock4j(name=product:stock) 分布式锁串行化,本表无 version 列、非乐观锁」。任何据旧 notes 编写的调用方文档/注释若沿用了"乐观锁"这一表述,应据本次改动订正。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为(涉及写操作时必写)
|
||||
|
||||
本次不改变任何一次**成功**写操作实际写入的行数或内容——两个端点各自原有的写入逻辑(`sold` 列增减)逐字节不变。变化的只是"谁能在同一时刻对同一 `productId+date` 执行写入":
|
||||
|
||||
| 维度 | 改动前 | 改动后 |
|
||||
|------|--------|--------|
|
||||
| 两个端点是否互斥(同 productId+date) | 否(各自持独立锁,可并发) | 是(同一把锁,串行执行) |
|
||||
| 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常,不产生任何 SQL) |
|
||||
| `batchUpsert`(管理端批量设价)与本组两端点的并发关系 | 不受本锁保护,仍可能丢更新 | 不变——本次改动未处理,见「⚠️ 关键变化」 |
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 参数校验失败(`count<1`)→ 400
|
||||
- 库存不足或未开放(仅 `deduct-stock`)→ 480201
|
||||
- **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入**
|
||||
- `restore-stock` 对不存在的价格日历记录/超额恢复 → 静默跳过或按 0 兜底,不报错,与本次改动无关
|
||||
- 老数据兼容:本次不涉及字段增删,存量数据无需迁移
|
||||
|
||||
---
|
||||
|
||||
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
|
||||
|
||||
本组两个接口均为纯数值/日期参数,无枚举字段,本节不适用。
|
||||
|
||||
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| (无)| 两个接口的请求参数、响应结构逐字节不变 | 同左 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 两个端点同 productId+date 并发时 | 各自持独立锁,可同时执行(读改写非原子,可能丢更新) | 同一把锁,串行执行,互斥真正生效 |
|
||||
| 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 |
|
||||
| `InternalProductController:121` Swagger notes | 「乐观锁扣减价格日历库存」(与表结构不符的错误描述) | 订正为读改写 + 分布式锁串行化的真实机制描述 |
|
||||
| 请求/响应字段、既有业务错误码(480201) | 不变 | 不变 |
|
||||
|
||||
## 六.7、影响评估(修改/删除类必写)
|
||||
|
||||
- **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;新增的 100503 失败路径由于**当前零跨服务调用方**,对现存系统零影响面。
|
||||
- **前端是否必须同步上线**: 不适用——两个端点没有任何前端或其他服务的直接调用方,本次改动不需要任何下游同步。若未来有服务接入 Feign 调用这两个端点,调用方需按「四、契约约束」处理 100503。
|
||||
- **前端 workaround 清理点**: 无。
|
||||
|
||||
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
|
||||
|
||||
- **仅影响**: `/internal/product/{productId}/deduct-stock`、`/internal/product/{productId}/restore-stock` 两个端点在**未来有并发调用方时**的失败分支;当前因零调用方,实际影响面为零
|
||||
- **零影响**:
|
||||
- `ProductPriceCalendarMapper#batchUpsert`(管理端批量设价,经 `ProductPricingService`/`ProductSnapshotService` 调用)——不取本锁,行为未变,仍可能与本组两端点产生 `sold` 丢更新(既有问题,本次未处理)
|
||||
- 价格日历的其余读接口(按产品/月份/日期范围查询等)
|
||||
- `InternalProductController` 其余端点(产品详情、简单报价、班期信息/报价、报名入团等)
|
||||
- 两个端点的请求参数、响应结构
|
||||
- 既有业务错误码 480201 的触发条件与文案
|
||||
- hl-gateway 路由配置——`/internal/**` 本就不对公网暴露,本次未新增任何路由
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
**部署读数(2026-09-20 23:20)**:hl-product-service-v2 滚动更新两实例,部署前 `4cbccc26b`(停在 2026-09-19,落后 93 个提交)→ 部署后 `ba8aab3ab`,8183 与 8083 各 7 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor f72a7548c ba8aab3ab` 返回 **ANCESTOR_YES**,本次改动确在运行版本内。
|
||||
|
||||
🔴 **本节到此为止,没有功能调用读数,这是有意为之而不是遗漏**:两个端点是 `/internal/product/**`、当前**零调用方**,既不经网关也无任何 Feign 声明指向;直接打端口调用会**真实扣减/恢复库存**,副作用落在共享测试服上由其他会话承担,且 `restore-stock` 不保证是 `deduct-stock` 的精确逆操作(不可靠地撤回)。互斥本身由单测 `ProductStockLockNameGuardTest` 覆盖(按效果反查两处 `name` 非空且相等),这是 `name` 契约类改动的恰当取证层级。
|
||||
|
||||
⚠️ **`100503` 在本服务上一次都没有触发过**——不是「测过、不会触发」,是**没测**。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-8)
|
||||
- 关联 PR: [wx/HL#8047](https://git.1814.love:8443/wx/HL/pulls/8047)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8047](https://git.1814.love:8443/wx/HL/pulls/8047)
|
||||
- **Merge commit**: [f72a7548c](https://git.1814.love:8443/wx/HL/commit/f72a7548c)(合并后回填,backend_status 待部署后翻转)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
@@ -0,0 +1,880 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "出行人删除/批量编辑与发票申请三组写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端)"
|
||||
consumer: "multiple"
|
||||
author: "wx(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "not_required"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: "mmg"
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "PR #8049 已 squash 合并 dev-v3(合并提交 43c53c4c9)。2026-09-20 23:24 已部署测试服 hl-order-service-v3:滚动更新两实例,部署前 COMMIT=c35251b07、部署后 COMMIT=ba8aab3ab,8186 与 8086 各 12 秒内起监听。`git merge-base --is-ancestor 43c53c4c9 ba8aab3ab` 返回 ANCESTOR_YES。gateway_status=not_required:端点路径/方法零变化,本次与网关路由无关,不是漏验证。🔴 本次未在测试服做功能调用取证,这是有意为之而不是遗漏:本组端点是删除出行人与批量改出行人,调用它们会真实删改订单下的出行人行;测试服订单数据被多个会话共用,删除撤不回来,副作用会落在一个不知情的人头上。互斥本身由落库级并发 IT `Lock4jSharedNameConcurrencyIT` 覆盖(3 条用例,含专为自调用入口 `deleteByInternal` 单写的一条),不变量是「订单至少留 1 位成人」;另有 `Lock4jSharedNameContractTest` 按 SpEL 反查全类、钉死三组的成员集合,能抓住日后新加的第 N 个方法。⚠️ `100503` 在测试服上一次都没有触发过——不是「测过、不会触发」,是没测;临界区短不等于永不超时,更长事务/更大载荷/生产数据量下仍可能出现。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# order-v3: 出行人删除/批量编辑与发票申请三组写口补同一把 @Lock4j name,新增可重试冲突码 100503(会传导至小程序 C 端)
|
||||
|
||||
> **存放目录**:
|
||||
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
|
||||
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
|
||||
>
|
||||
> **服务**: hl-order-service-v3(另涉 hl-mp-service 四个透传/BFF 入口)
|
||||
> **PR**: #8049
|
||||
> **Issue**: #7980(AC-7 第一/三/四组)
|
||||
> **日期**: 2026-09-20
|
||||
> **影响范围**: 管理后台「订单出行人」删除/批量编辑、「订单调整」提交(出行人 tab)、「发票管理」申请开票 + 小程序 C 端「出行人补全/删除」「申请发票」
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
|
||||
|
||||
- 三组写口的 `@Lock4j` 都写了**逐字相同**的 `keys`,却都**没写 `name`**——lock4j 2.2.7 缺省 `name` 时用「类全限定名 + 方法名」当默认值拼进 Redis 键,于是每组内的多个方法**各持一把互不相干的锁**,可以并发互相踩踏:
|
||||
1. **`traveler:delete:order`**(3 处):`TravelerService.deleteTraveler`(admin)/ `mpDeleteTraveler`(mp)/ `deleteByInternal`(internal Feign,且它经 `this.deleteTraveler(...)` 自调用,不走 Spring 代理,之前**一次都没触发过**锁)。「最后 1 位成人禁删」是内存计数的 check-then-act,`order_traveler` 无 `@Version`、无行锁复读,没有第二道锁兜底——两个并发删除请求各读到「还有 2 个成人」的旧快照就会把成人删到 0。
|
||||
2. **`traveler:batch-edit`**(3 处):`TravelerService.batchEditTravelers`(admin 直接批量编辑)/ `batchEditTravelersForAdjustment`(订单调整提交里的出行人 tab)/ `mpBatchEditTravelers`(mp)。「基于内存合并的最终生效集合先校验后写库」同样是 check-then-act,没有第二道锁兜底。
|
||||
3. **`order-invoice:apply`**(2 处):`InvoiceAdminService.adminApplyInvoice`(admin)/ `InvoiceMpService.mpApplyInvoice`(mp)。两者前置条件相同(同一个 `validateApplyEligibility`,含「一单一票」existsActive 检查),`order_invoice` 无 `UNIQUE`、`OrderInvoice` 无 `@Version`,补 `name` 之前 admin 申请 ∥ mp 申请可各读到 existsActive 旧快照并各插一条申请单。
|
||||
- 本次给三组各自补上**同一个**显式 `name`(分别为 `"traveler:delete:order"` / `"traveler:batch-edit"` / `"order-invoice:apply"`),组内互斥才真正生效。**接口路径、请求/响应字段、既有业务错误码全部零增删**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。
|
||||
- 🔴 **新增的失败路径会到达小程序 C 端**:出行人补全/删除、申请发票均是 mp 端用户自助操作,命中锁冲突时前端会等待最多约 3 秒后收到 `code=100503`("资源被占用,请稍后重试")。**传输层仍是 HTTP 200**,失败信息在响应体的 `code` 字段里——前端如果只看 HTTP 状态码会把这次失败误判为成功。
|
||||
- 🔴 **本 PR 只做 AC-7 里判定为「要修」的三组**。同一份判定还覆盖了另外三组,均**不修**(不在本文档「二、变更接口清单」范围内,行为未变,仅供背景参考):
|
||||
- `order-ins:` 组(保险 `purchaseCore`):判定为「有意不加 name」——`purchaseCore` 本身没有按单去重逻辑,补锁对「同单不重复出单」零贡献;真正的缺口在工单未列出的 `autoPurchase`/`autoPurchaseByScheme`(连 `@Lock4j` 都没有),本轮只订正了两处描述这把锁效果的假注释,完整修复另起工单,本文档不涉及。
|
||||
- `material:tag:name:`(resource 服务):有 `uk_tag_name` 唯一索引兜底,判定不修。
|
||||
- `fleet:msg-tpl:default:`(fleet 服务):有 InnoDB 行锁兜底,判定不修。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景
|
||||
|
||||
工单 #7980 第四节原文对以下几处描述与代码不符,本 PR 已订正(不影响本次「要不要补锁」的结论,只是让文档准确):① 第 5 组「同名标签双插(无唯一索引)」——`material_tag` 实际**有** `UNIQUE KEY uk_tag_name`;② 第 3 组两处行号已漂移(AC-4 改动注释所致),实际为 `TravelerService.java:126`/`:150`(现已改为本次改动后的真实行号);③ 第 3 组组大小实际是 **3** 个方法(`deleteTraveler`/`mpDeleteTraveler`/`deleteByInternal`)不是 2 个,其中 `deleteByInternal` 因自调用问题此前**从未触发过**锁,是最危险的一处。
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 出行人软删(admin) | POST | `/v3/admin/order/{id}/traveler/{travelerId}/delete` | 新增可能返回的错误码 | 与 §2/§3 共用 `traveler:delete:order` 锁 |
|
||||
| 2 | 客户删除出行人(mp internal) | POST | `/v3/internal/mp/order/{id}/traveler/{travelerId}/delete` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 |
|
||||
| 3 | 跨服务删除出行人(internal Feign) | DELETE | `/v3/internal/order/orders/{orderId}/travelers/{travelerId}` | 新增可能返回的错误码 | 当前零跨服务调用方,见业务边界 |
|
||||
| 4 | 批量 upsert 出行人(admin) | POST | `/v3/admin/order/{id}/traveler/batch-edit` | 新增可能返回的错误码 | 与 §5/§6 共用 `traveler:batch-edit` 锁 |
|
||||
| 5 | 调整订单统一提交(含出行人 tab) | POST | `/v3/admin/order/{id}/adjustment/submit` | 新增可能返回的错误码(条件触发) | 仅当请求体含出行人增删改时才会命中本组锁 |
|
||||
| 6 | 客户出行人补全(mp internal) | POST | `/v3/internal/mp/order/{id}/traveler/batch-edit` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 |
|
||||
| 7 | 定制师代客申请发票(admin) | POST | `/v3/admin/order/{orderId}/invoice/apply` | 新增可能返回的错误码 | 与 §8 共用 `order-invoice:apply` 锁 |
|
||||
| 8 | 用户申请开票(mp internal) | POST | `/v3/internal/mp/order/{orderId}/invoice/apply` | 新增可能返回的错误码 | 经 hl-mp-service 到达小程序 C 端 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 出行人软删(admin) `POST /v3/admin/order/{id}/traveler/{travelerId}/delete`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → Boolean`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
管理后台订单详情页,定制师/客服删除某个出行人(已签电子合同禁删;订单最后 1 位成人禁删)。本次改动不涉及入参/出参,只新增一条「被本组另外 2 个写口占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 订单 ID |
|
||||
| travelerId | Path | Long | ✅ | - | 出行人 ID |
|
||||
|
||||
#### 出参 `Result<Boolean>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | Boolean | 恒为 `true`(失败走异常,不会返回 `false`) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
POST /v3/admin/order/1234567890123456789/traveler/70123456789012/delete
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": true, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」/降级概念(成功恒返回 `true`,失败一律抛业务异常)。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 581102 | 订单不存在 |
|
||||
| 581110 | 出行人 ID 不属于该订单 |
|
||||
| 581106 | 已签电子合同,禁止删除出行人 |
|
||||
| 581107 | 出行人是订单最后 1 位成人,禁止删除 |
|
||||
| 100502 | 3 秒幂等窗口内重复提交(键=id+travelerId) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §2 mp 删除、§3 internal Feign 删除共用同一把 `traveler:delete:order` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent`(`keyPrefix=traveler:delete`)3 秒窗口,键含订单 ID + 出行人 ID。
|
||||
- 抢锁失败(100503)时该次请求未进入方法体,零写入——「最后 1 位成人」的计数不会被并发破坏。
|
||||
- 软删:级联软删桥接表 `order_transport_plan_traveler`,并写 `order_status_log` 流水(`TRAVELER_DELETE`)。
|
||||
- 已签约阶段删人会触发"作废旧合同/保单 + 重新签约/投保"事件(按订单状态白名单过滤),不受本次改动影响。
|
||||
|
||||
---
|
||||
|
||||
### 2. 客户删除出行人(mp internal) `POST /v3/internal/mp/order/{id}/traveler/{travelerId}/delete`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
小程序 C 端用户在「我的订单」自助删除出行人,仅订单确认前(PENDING_PAY/CUSTOMIZING)放行,且只能删自己名下订单的出行人。**本端点是 order-v3 的 internal 路径,C 端不直接访问它**,而是经 hl-mp-service 的公网入口 `DELETE /mp/order/{orderId}/traveler/{travelerId}`(`MpOrderTravelerController`)透传调用。本次改动不涉及入参/出参,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 订单 ID |
|
||||
| travelerId | Path | Long | ✅ | - | 出行人 ID |
|
||||
|
||||
(`userId` 从 JWT 解析,前端不传,由网关透传 `X-User-Id`。)
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /mp/order/1234567890123456789/traveler/70123456789012
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
(以上为小程序 C 端实际调用的公网地址;order-v3 侧 internal 路径见本节标题。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」/降级概念。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 581122 | 订单不属于当前用户(越权) |
|
||||
| 581145 | 订单已确认,出行人信息不可再经小程序修改(须走 admin) |
|
||||
| 581102/581110/581106/581107 | 同 §1(复用 `deleteTraveler` 全部业务规则) |
|
||||
| 100502 | 3 秒幂等窗口内重复提交 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 admin 删除、§3 internal Feign 删除共用同一把 `traveler:delete:order` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。
|
||||
- 越权校验:`order.userId == 当前登录 userId`,不满足统一返 581122,不额外区分"不存在"与"无权限"。
|
||||
- 状态门禁:仅 `PENDING_PAY`/`CUSTOMIZING` 放行,订单确认后(`PENDING_DEPARTURE` 起)及已完成/已取消一律禁止,防止客户改动触发合同作废重签/退保重投保。
|
||||
- 抢锁失败(100503)时零写入,业务规则全部委托 `deleteTraveler`,见 §1。
|
||||
|
||||
---
|
||||
|
||||
### 3. 跨服务删除出行人(internal Feign) `DELETE /v3/internal/order/orders/{orderId}/travelers/{travelerId}`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → Boolean`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
供其他微服务通过 Feign 跨服务删除出行人(无 JWT 鉴权,依赖网络隔离,Gateway 不暴露 `/internal/**` 至公网)。🔴 全仓 grep 确认**当前零跨服务调用方**——没有任何 `@FeignClient` 接口声明指向本路径,是本组三处里唯一没有现存调用方的一处。本次改动不涉及入参/出参,只是把此前**完全裸奔**(因自调用问题一次都没触发过锁)的这条链路纳入本组互斥。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| orderId | Path | Long | ✅ | - | 订单 ID |
|
||||
| travelerId | Path | Long | ✅ | - | 出行人 ID |
|
||||
|
||||
#### 出参 `Result<Boolean>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | Boolean | 恒为 `true`(委托 `deleteTraveler`,失败走异常) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /v3/internal/order/orders/1234567890123456789/travelers/70123456789012
|
||||
```
|
||||
|
||||
(跨服务 Feign 调用,无鉴权头。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": true, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」/降级概念,也无当前真实流量。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 581102/581110/581106/581107 | 同 §1(委托 `deleteTraveler`,`adminId` 传 `null`) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §1 admin 删除、§2 mp 删除共用同一把 `traveler:delete:order` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`(internal Feign 端点通常由调用方自行保证幂等)。
|
||||
- 🔴 **本次改动前,本端点的调用链(`deleteByInternal` → `this.deleteTraveler(...)`)是自调用,不经 Spring 代理,`deleteTraveler` 上的 `@Lock4j` 在这条链路上此前一次都不会触发**——`deleteByInternal` 方法本身现已补上独立的 `@Lock4j(name="traveler:delete:order")`,才真正纳入互斥;这不是"补 name"本身的效果,而是本次一并修的一个更深的问题(自调用丢锁)。
|
||||
- 当前零调用方,不代表本条不需要修:与 A/B/C 三份 changelog 里 C 份(库存扣减)同类判定——回归面可实证为零,但闸口本身要补齐。
|
||||
- 抢锁失败(100503)时零写入。
|
||||
|
||||
---
|
||||
|
||||
### 4. 批量 upsert 出行人(admin) `POST /v3/admin/order/{id}/traveler/batch-edit`
|
||||
|
||||
**VO**: `TravelerBatchEditReqVO → TravelerBatchEditRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
管理后台订单详情页,定制师/客服批量新增/更新出行人(id=null 新增,id 非 null 更新,整体事务,≤30 行)。本次改动不涉及入参/出参字段,只新增一条「被本组另外 2 个写口占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 订单 ID |
|
||||
| travelers | Body | List\<Item\> | ✅ | `@NotEmpty`,≤30 | 待修改出行人数组 |
|
||||
| travelers[].id | Body | Long | - | null=新增/非 null=更新 | 更新时必须属于该 orderId |
|
||||
| travelers[].name | Body | String | - | - | 姓名 |
|
||||
| travelers[].gender | Body | String | - | 1=男/2=女/0=未知 | 性别 |
|
||||
| travelers[].birthday | Body | LocalDate | ✅ | - | 出生日期,后端按年龄自动派生出行人类型 |
|
||||
| travelers[].idType | Body | String | - | 字典 id_card_type | 证件类型 |
|
||||
| travelers[].idNo | Body | String | - | 格式按证件类型校验 | 证件号(明文传,DB 加密) |
|
||||
| travelers[].nationality | Body | String | - | 不可空字符串 | 国籍(12301 必报字段) |
|
||||
| travelers[].race | Body | String | - | 不可空字符串 | 民族(12301 必报字段) |
|
||||
| travelers[].phone | Body | String | - | 11 位数字 | 出行人手机(明文传,DB 加密) |
|
||||
| travelers[].email | Body | String | - | 宽松格式 | 电子邮箱 |
|
||||
| travelers[].emergencyContact | Body | String | - | - | 紧急联系人姓名 |
|
||||
| travelers[].emergencyPhone | Body | String | - | - | 紧急联系人电话 |
|
||||
| travelers[].roomGroupNo | Body | Integer | - | 不超家庭数上限 | 同住分组号 |
|
||||
|
||||
#### 出参 `Result<TravelerBatchEditRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| createdCount | Integer | 本次新增的出行人数(id=null 行) |
|
||||
| updatedCount | Integer | 本次更新的出行人数(id 非 null 行) |
|
||||
| completedCount | Integer | 本次操作后变为 COMPLETED 的行数 |
|
||||
| pendingCount | Integer | 仍为 PENDING 的行数 |
|
||||
| allCompleted | Boolean | 该订单所有出行人是否已完善 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"travelers": [
|
||||
{
|
||||
"id": null,
|
||||
"name": "张三",
|
||||
"gender": "1",
|
||||
"birthday": "1985-08-12",
|
||||
"idType": "ID_CARD",
|
||||
"idNo": "220103198508121234",
|
||||
"nationality": "中国",
|
||||
"race": "汉族",
|
||||
"phone": "13812342046"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true },
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(`travelers` 要求非空),无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 581105 | 批量大小超限(>30) |
|
||||
| 581102 | 订单不存在 |
|
||||
| 581119 | 出行人证件号重复(同批) |
|
||||
| 581101 | 12301 必报字段缺失(国籍/民族为空字符串) |
|
||||
| 581112/581113/581114 | 证件号/手机号格式非法、出生日期晚于今天 |
|
||||
| 581103/581104 | 性别编码不合法、同住分组号超出上限 |
|
||||
| 581118 | 新增项缺少必填信息(姓名/证件类型/证件号至少一项) |
|
||||
| 581110 | 更新项不属于该订单 |
|
||||
| 581111 | 已签电子合同后禁止修改证件号 |
|
||||
| 581136 | 成人手机号硬门禁(至少一名成人须填手机号) |
|
||||
| 100502 | 3 秒幂等窗口内重复提交(键=订单 ID) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §5 订单调整提交、§6 mp 出行人补全共用同一把 `traveler:batch-edit` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。
|
||||
- 抢锁失败(100503)时零写入——所有校验(配额、去重、必填矩阵)都在事务内完成,锁竞争失败不会产生任何部分写入。
|
||||
- 校验顺序:批量大小 → 订单存在 → 身份证去重 → 姓名硬拦截 → 12301 字段 → 格式类字段 → 新增/更新分支各自的必填与归属校验 → 成人手机号硬门禁(基于内存合并的最终生效集合),任一失败整事务回滚。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 5. 调整订单统一提交(含出行人 tab) `POST /v3/admin/order/{id}/adjustment/submit`
|
||||
|
||||
**VO**: `AdjustmentSubmitReqVO → AdjustmentSubmitRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
⚠️ 本接口是订单调整弹窗的统一提交入口,覆盖人数/行程/住宿/用车等多个子领域,**本节只描述与本次改动相关的「出行人 tab」部分**,其余子领域(改期/行程编辑/房需求/车需求)字段与行为均**不在本次改动范围内、未变化**,完整入参见既有产品/接口文档。当请求体 `updates.people.travelers`(或兼容旧契约的 `updates.travelers`)含新增/更新/删除任一项时,后端内部会调用与 §4 admin 批量编辑**同名**的锁(`traveler:batch-edit`),本次改动前该调用完全不受这把锁保护——补 `name` 之后,本接口在处理出行人变更时会真的把 §4/§6 挡在门外。
|
||||
|
||||
#### 入参(仅出行人相关部分,其余字段见既有文档)
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 订单 ID |
|
||||
| updates.people.travelers.add | Body | List\<TravelerEditVO\> | - | - | 新增出行人列表 |
|
||||
| updates.people.travelers.update | Body | List\<TravelerEditVO\> | - | 含 id 字段 | 更新出行人列表 |
|
||||
| updates.people.travelers.remove | Body | List\<Long\> | - | - | 删除出行人 id 列表 |
|
||||
| updates.people.travelers.add[].name | Body | String | - | - | 姓名 |
|
||||
| updates.people.travelers.add[].idType | Body | String | - | - | 证件类型 |
|
||||
| updates.people.travelers.add[].idNo | Body | String | - | - | 证件号(脱敏回显,编辑回传按未修改处理规则见 §4 同款字段) |
|
||||
| updates.people.travelers.add[].birthday | Body | LocalDate | 条件必填 | 新增必填 | 出生日期,用于派生出行人类型 |
|
||||
| updates.people.travelers.add[].travelerType | Body | String | - | ADULT/CHILD | 出行人类型 |
|
||||
| updates.people.travelers.add[].remark | Body | String | - | - | 备注 |
|
||||
| updates(其余字段:schedule/itinerary/hotelRequirement/vehicleRequirement/transferRequirement) | Body | - | - | 见既有文档 | 与本次改动无关,未变化 |
|
||||
|
||||
#### 出参 `Result<AdjustmentSubmitRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| success | Boolean | 提交是否成功(极简响应,失败统一走全局异常处理器返回 `Result{code,message}`) |
|
||||
|
||||
#### 请求示例(仅出行人 tab 片段)
|
||||
|
||||
```json
|
||||
{
|
||||
"updates": {
|
||||
"people": {
|
||||
"travelers": {
|
||||
"add": [
|
||||
{ "name": "李四", "idType": "ID_CARD", "idNo": "110101198801011234", "birthday": "1988-01-01" }
|
||||
],
|
||||
"remove": [70123456789013]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": { "success": true }, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
`updates` 各子域均可为 null(按需填),但不能全 null(否则 `AdjustmentErrorCode.ADJUST_NO_EFFECTIVE_CHANGE`)。不含出行人变更时本次改动完全不生效,走原有逻辑。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| (出行人相关)581101/581103/581104/581110-581114/581118/581119/581136 | 同 §4 校验矩阵,按调整后目标人数校验(区别于 §4 按数据库当前声明人数校验) |
|
||||
| **100503(本次新增,仅当本次提交含出行人变更时可能触发)** | 抢锁等待超过 3 秒(与 §4 admin 批量编辑、§6 mp 出行人补全共用同一把 `traveler:batch-edit` 锁),**可重试** |
|
||||
| (其余子领域错误码) | 与本次改动无关,未变化,不在本文档范围 |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`(本端点覆盖多子领域,幂等策略由前端弹窗层面控制,不在本次改动范围)。
|
||||
- **配额校验口径与 §4 不同**:本入口按"本次调整的最终人数"校验(`targetDeclaration`),并在校验时排除同一事务中待删除的旧出行人;§4 普通编辑入口按数据库中的当前声明人数校验。
|
||||
- 抢锁失败(100503)**只影响本次请求整体**——本接口是单一事务原子应用所有子领域改动,出行人锁竞争失败会导致整个 `adjustment/submit` 请求失败回滚,不会出现"其他子域改完、出行人没改完"的部分成功。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 6. 客户出行人补全(mp internal) `POST /v3/internal/mp/order/{id}/traveler/batch-edit`
|
||||
|
||||
**VO**: `TravelerBatchEditReqVO → TravelerBatchEditRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
小程序 C 端用户自助补全/修改出行人信息,仅订单确认前(`PENDING_PAY`/`CUSTOMIZING`)放行,且只能改自己名下订单。**本端点是 order-v3 的 internal 路径**,C 端经 hl-mp-service 的三个公网入口间接调用:`POST /mp/order/{orderId}/traveler`(新增单个出行人)、`PUT /mp/order/{orderId}/traveler/{travelerId}`(修改单个出行人)、`POST /mp/v3/order/{id}/traveler/batch-edit`(批量补全)。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
同 §4(`TravelerBatchEditReqVO`,字段表见上节,不重复列出)。`userId` 从 JWT 解析,前端不传。
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| id | Path | Long | ✅ | - | 订单 ID |
|
||||
| travelers | Body | List\<Item\> | ✅ | `@NotEmpty`,≤30 | 字段结构同 §4 |
|
||||
|
||||
#### 出参 `Result<TravelerBatchEditRespVO>`
|
||||
|
||||
字段结构同 §4:
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| createdCount | Integer | 本次新增的出行人数(id=null 行) |
|
||||
| updatedCount | Integer | 本次更新的出行人数(id 非 null 行) |
|
||||
| completedCount | Integer | 本次操作后变为 COMPLETED 的行数 |
|
||||
| pendingCount | Integer | 仍为 PENDING 的行数 |
|
||||
| allCompleted | Boolean | 该订单所有出行人是否已完善 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"travelers": [
|
||||
{ "id": null, "name": "王五", "idType": "ID_CARD", "idNo": "310101199001011234", "birthday": "1990-01-01" }
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": { "createdCount": 1, "updatedCount": 0, "completedCount": 1, "pendingCount": 0, "allCompleted": true },
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(`travelers` 要求非空),无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 581122 | 订单不属于当前用户(越权) |
|
||||
| 581145 | 订单已确认,出行人信息不可再经小程序修改 |
|
||||
| 581101/581105/581110-581114/581118/581119/581136 | 同 §4 校验矩阵 |
|
||||
| 100502 | 3 秒幂等窗口内重复提交 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §4 admin 批量编辑、§5 订单调整提交共用同一把 `traveler:batch-edit` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒窗口(键=订单 ID)。
|
||||
- 越权与状态门禁同 §2。
|
||||
- 🔴 **本 PR 尤其重要的一处**:`mpBatchEditTravelers` 的方法体最终委托 `this.batchEditTravelers(...)`(自调用),此前该调用同样有绕过代理丢锁的历史问题,本次连同 §4/§5 一并补齐同一 `name`,三个入口现在才真正互斥——补锁前,客户在小程序自助编辑与定制师在 admin 后台同时编辑同一订单的出行人可能互不阻塞(内存计算的配额各按各的旧快照算,静默丢更新)。
|
||||
- 抢锁失败(100503)时零写入。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 7. 定制师代客申请发票(admin) `POST /v3/admin/order/{orderId}/invoice/apply`
|
||||
|
||||
**VO**: `AdminInvoiceApplyReqVO → AdminInvoiceApplyRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
管理后台「发票管理」,定制师代客户申请开票(订单状态须 ∈ {CUSTOMIZING, PENDING_DEPARTURE, TRAVELLING, COMPLETED},或已取消且净实收>0;一单一票)。本次改动不涉及入参/出参字段,只新增一条「被 §8 mp 申请占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| orderId | Path | Long | ✅ | - | 订单 ID |
|
||||
| invoiceType | Body | String | ✅ | `@NotBlank`,VAT_NORMAL/VAT_SPECIAL | 发票类型 |
|
||||
| titleType | Body | String | ✅ | `@NotBlank`,COMPANY/PERSONAL | 抬头类型,专票只能开给单位 |
|
||||
| titleName | Body | String | ✅ | `@NotBlank`,≤200 字符 | 抬头名称 |
|
||||
| taxNo | Body | String | 条件必填 | ≤64 字符 | 税号(titleType=COMPANY 或 invoiceType=VAT_SPECIAL 必填) |
|
||||
| bankName | Body | String | 条件必填 | ≤128 字符 | 开户行(invoiceType=VAT_SPECIAL 必填) |
|
||||
| bankAccount | Body | String | 条件必填 | ≤64 字符 | 开户账号(同上) |
|
||||
| registAddress | Body | String | 条件必填 | ≤500 字符 | 注册地址(同上) |
|
||||
| registPhone | Body | String | 条件必填 | ≤32 字符 | 注册电话(同上) |
|
||||
| email | Body | String | ✅ | `@Email`,≤128 字符 | 收件邮箱 |
|
||||
| remark | Body | String | - | ≤500 字符 | 备注/货物或应税劳务名称 |
|
||||
|
||||
#### 出参 `Result<AdminInvoiceApplyRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | String(雪花,序列化为字符串) | 发票 ID |
|
||||
| orderId | String | 订单 ID |
|
||||
| status | String | 发票状态(REQUESTED=待开票) |
|
||||
| amount | BigDecimal | 开票金额(申请阶段为 null,开票时才计算写入) |
|
||||
| requestedAt | LocalDateTime | 申请时间 |
|
||||
| requestedBy | String | 申请人(定制师真实姓名) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"invoiceType": "VAT_NORMAL",
|
||||
"titleType": "COMPANY",
|
||||
"titleName": "上海呼籁旅行科技有限公司",
|
||||
"taxNo": "91310115MA1K48XXXX",
|
||||
"email": "finance@hulalv.com"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": "2100889866400104450",
|
||||
"orderId": "1234567890123456789",
|
||||
"status": "REQUESTED",
|
||||
"amount": null,
|
||||
"requestedAt": "2026-09-20T14:30:25",
|
||||
"requestedBy": "李定制"
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回完整响应),无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 581511 | 该订单已存在有效发票(一单一票) |
|
||||
| 581524 | 订单当前状态不可申请开票 |
|
||||
| 581513 | 发票类型非法 |
|
||||
| 581523 | 增值税专用发票只能开给单位 |
|
||||
| 581514 | 公司抬头或专票必须填写税号 |
|
||||
| 581515 | 增值税专用发票须填写银行及注册信息 |
|
||||
| 581516 | 收件邮箱不能为空 |
|
||||
| 100502 | 5 秒幂等窗口内重复提交(键=订单+发票类型+抬头名称,抬头或类型不同即不拦) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §8 mp 申请共用同一把 `order-invoice:apply` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 5 秒窗口,键含订单+发票类型+抬头名称——**抬头或类型不同即不拦**,替代不了这把锁(两个不同抬头的并发申请仍会分别通过幂等但撞上一单一票)。
|
||||
- 抢锁失败(100503)时该次请求零写入,`existsActive` 检查与 INSERT 之间不会被并发穿透。
|
||||
- 开票金额本次申请不写,由后续「完成开票」(另一 PR #8043 的 `issueInvoice`)自动计算写入,与本次改动无关。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 8. 用户申请开票(mp internal) `POST /v3/internal/mp/order/{orderId}/invoice/apply`
|
||||
|
||||
**VO**: `MpInvoiceApplyReqVO → MpInvoiceApplyRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
小程序 C 端用户自助申请开票(仅本人订单,状态门槛与一单一票规则与 §7 相同)。**本端点是 order-v3 的 internal 路径**,C 端经 hl-mp-service 的公网入口 `POST /mp/v3/order/{orderId}/invoice/apply`(`MpV3CollabInvoiceApplyController`)透传调用。本次改动不涉及入参/出参字段,只新增一条「被 §7 admin 申请占用同一把锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| orderId | Path | Long | ✅ | - | 订单 ID |
|
||||
| invoiceType | Body | String | ✅ | `@NotBlank`,VAT_NORMAL/VAT_SPECIAL | 发票类型 |
|
||||
| titleType | Body | String | ✅ | `@NotBlank`,COMPANY/PERSONAL | 抬头类型 |
|
||||
| titleName | Body | String | ✅ | `@NotBlank`,≤200 字符 | 抬头名称 |
|
||||
| taxNo | Body | String | 条件必填 | ≤64 字符 | 税号 |
|
||||
| bankName | Body | String | 条件必填 | ≤128 字符 | 开户行 |
|
||||
| bankAccount | Body | String | 条件必填 | ≤64 字符 | 开户账号 |
|
||||
| registAddress | Body | String | 条件必填 | ≤500 字符 | 注册地址 |
|
||||
| registPhone | Body | String | 条件必填 | ≤32 字符 | 注册电话 |
|
||||
| email | Body | String | ✅ | `@Email`,≤128 字符 | 收件邮箱 |
|
||||
| applyReason | Body | String | - | ≤500 字符 | 申请说明 |
|
||||
|
||||
(`userId` 从 JWT 解析,前端不传。)
|
||||
|
||||
#### 出参 `Result<MpInvoiceApplyRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| id | Long | 发票主键 |
|
||||
| orderId | Long | 订单 ID |
|
||||
| status | String | 状态:实际写入值为 `REQUESTED`(与 hl-mp-service 侧 VO 注释所写"固定 APPLIED"不一致,以 order-v3 源码 `InvoiceMpService.mpApplyInvoice` 实际赋值为准) |
|
||||
| amount | BigDecimal | 申请金额(申请阶段为 null) |
|
||||
| applyAt | LocalDateTime | 申请时间 |
|
||||
| triggerPushLogIds | List\<Long\> | 触发的通知推送日志 ID 列表(当前恒为空数组) |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"invoiceType": "VAT_NORMAL",
|
||||
"titleType": "PERSONAL",
|
||||
"titleName": "张三",
|
||||
"email": "zhangsan@example.com"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"id": 2100889866400104460,
|
||||
"orderId": 1234567890123456789,
|
||||
"status": "REQUESTED",
|
||||
"amount": null,
|
||||
"applyAt": "2026-09-20T14:35:10",
|
||||
"triggerPushLogIds": []
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念,无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 581470 | 订单不存在或不属于当前用户 |
|
||||
| 581511/581524/581513/581523/581514/581515/581516 | 同 §7 校验矩阵(复用 `InvoiceAdminService.validateApplyEligibility` / `validateInvoiceFields`) |
|
||||
| 100502 | 5 秒幂等窗口内重复提交 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与 §7 admin 申请共用同一把 `order-invoice:apply` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 5 秒窗口,口径同 §7。
|
||||
- 抢锁失败(100503)时零写入。
|
||||
- `requestedBy` 落库为用户 ID 字符串(区别于 admin 端落真实姓名),字段本身不在本次改动范围。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式(接口类必写)
|
||||
|
||||
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
|
||||
|
||||
### 正确 / 错误的客户端处理方式
|
||||
|
||||
本次不涉及请求体字段的互斥/联动规则变化,契约约束集中在"如何处理新失败码":
|
||||
|
||||
| 场景 | 处理方式 |
|
||||
|------|----------|
|
||||
| ✅ 收到 `code=100503` | 提示"另一个操作正在进行,请稍后重试",**允许用户重试**,不当系统错误上报、不静默吞掉 |
|
||||
| ✅ 判断成功/失败 | 一律读响应体 `code`(`===200` 才算成功),不能只看 HTTP 状态码 |
|
||||
| ❌ 只看 HTTP 状态码 | `100503` 场景下 HTTP 仍是 200,只看状态码会把失败误判为成功,界面会"假装成功"但后端实际零写入 |
|
||||
| ❌ 把 `100503` 当致命错误弹全屏报错 | 应视为可重试的低频并发退避,不要引导用户联系客服或上报崩溃监控 |
|
||||
|
||||
### 三组锁各自的互斥范围(不要混淆)
|
||||
|
||||
| 锁 name | 互斥范围 | 不在范围内 |
|
||||
|---------|----------|------------|
|
||||
| `traveler:delete:order` | §1 admin 删除 / §2 mp 删除 / §3 internal Feign 删除,三者对**同一 orderId** | 出行人的新增/编辑(属于 `traveler:batch-edit` 组) |
|
||||
| `traveler:batch-edit` | §4 admin 批量编辑 / §5 订单调整提交(仅含出行人变更时)/ §6 mp 出行人补全,三者对**同一 orderId** | 出行人的删除(属于 `traveler:delete:order` 组);§5 的非出行人子域(改期/行程/住宿/用车)与本锁无关 |
|
||||
| `order-invoice:apply` | §7 admin 申请 / §8 mp 申请,两者对**同一 orderId** | 「完成开票」`issueInvoice`、「重新上传」`reuploadInvoice`、「申请重开」`reissueInvoice`(那三者共用**另一把**锁 `order-invoice:issue`,已在 PR #8043 / changelog `20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md` 中处理,与本文档不是同一组) |
|
||||
|
||||
### 幂等与并发冲突的区别
|
||||
|
||||
- 请求体**逐字节相同**且在幂等窗口内重复提交 → 幂等拦截,返回 `100502`(请勿重复提交),这是**另一个已存在的错误码**,本次不变。
|
||||
- 请求体**不同**或已过幂等窗口,但撞上组内另一写口正在执行的同订单请求 → 本次新增的 `100503`。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为(涉及写操作时必写)
|
||||
|
||||
本次不改变任何一次**成功**写操作实际写入的行数或内容——8 个端点各自原有的写入逻辑(软删/桥接表级联/批量 upsert/发票 INSERT)逐字节不变。变化的只是"谁能在同一时刻对同一 orderId 执行本组写入":
|
||||
|
||||
| 组 | 改动前 | 改动后 |
|
||||
|----|--------|--------|
|
||||
| `traveler:delete:order` | admin/mp/internal 各持一把锁(internal 因自调用问题实际从未持锁),可并发互相踩踏「最后 1 位成人」内存计数 | 三者共享同一把锁,串行执行 |
|
||||
| `traveler:batch-edit` | admin/adjustment/mp 各持一把锁,可并发各读旧快照后各自写库 | 三者共享同一把锁,串行执行 |
|
||||
| `order-invoice:apply` | admin/mp 各持一把锁,可并发各读 `existsActive` 旧快照后各插一条申请 | 两者共享同一把锁,串行执行,一单一票才真正生效 |
|
||||
| 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常) |
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 未登录 → 401(网关拦截)
|
||||
- 订单不存在/不属于当前用户 → 581102/581110/581122/581470(各端点适用情况见上)
|
||||
- 出行人删除相关业务闸口(已签合同/最后成人)→ 581106/581107,不受本次改动影响
|
||||
- 出行人批量编辑校验矩阵(12301/格式/配额)→ 581101/581103/581104/581112-581114/581118/581119/581136,不受本次改动影响
|
||||
- mp 端状态门禁(订单确认后禁止自助改动)→ 581145,不受本次改动影响
|
||||
- 发票一单一票/状态门槛 → 581511/581524,不受本次改动影响
|
||||
- **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入**
|
||||
- 幂等窗口内重复提交(请求体相同)→ 100502,与本次改动无关,行为不变
|
||||
- 老数据兼容:本次不涉及字段增删,存量数据无需迁移
|
||||
|
||||
---
|
||||
|
||||
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
|
||||
|
||||
### 出行人性别(gender,出现于 §4/§6)
|
||||
|
||||
**所属字段**: `travelers[].gender` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `1` | 男 | - |
|
||||
| `2` | 女 | - |
|
||||
| `0` | 未知 | - |
|
||||
|
||||
### 发票类型(invoiceType,出现于 §7/§8)
|
||||
|
||||
**所属字段**: `invoiceType` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `VAT_NORMAL` | 增值税普通发票 | 默认场景 |
|
||||
| `VAT_SPECIAL` | 增值税专用发票 | 要求 `titleType=COMPANY`,且税号/银行/注册信息 5 字段全必填 |
|
||||
|
||||
### 发票抬头类型(titleType,出现于 §7/§8)
|
||||
|
||||
**所属字段**: `titleType` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `COMPANY` | 单位 | 抬头为公司名,`taxNo` 必填 |
|
||||
| `PERSONAL` | 个人 | 抬头为个人姓名,`taxNo` 非必填 |
|
||||
|
||||
### 发票状态(status,出现于 §7/§8 出参)
|
||||
|
||||
**所属字段**: `AdminInvoiceApplyRespVO.status` / `MpInvoiceApplyRespVO.status` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `REQUESTED` | 待开票 | 申请成功后的初始状态(admin/mp 两端申请落库值一致,均为 `REQUESTED`) |
|
||||
|
||||
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| (无)| 8 个接口的请求字段、响应字段逐字节不变 | 同左 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| `traveler:delete:order` 组三入口并发 | 各持一把锁(internal 一支因自调用问题实际从未持锁),可并发破坏「最后 1 位成人」计数 | 三者共用同一把锁,串行执行 |
|
||||
| `traveler:batch-edit` 组三入口并发 | 各持一把锁,可并发各按旧快照写库 | 三者共用同一把锁,串行执行 |
|
||||
| `order-invoice:apply` 组两入口并发 | 各持一把锁,一单一票检查可被并发穿透 | 两者共用同一把锁,串行执行 |
|
||||
| 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 |
|
||||
| 请求/响应字段、既有业务错误码 | 不变 | 不变 |
|
||||
|
||||
## 六.7、影响评估(修改/删除类必写)
|
||||
|
||||
- **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;但**新增了一条此前不存在的失败路径**(100503),且该路径会传导至小程序 C 端。
|
||||
- **前端是否必须同步上线**: 建议同步——尤其是小程序 C 端出行人补全/删除、发票申请三个入口,命中该分支时若前端无兜底会把失败误判为成功(HTTP 200)。**触发频率本文档未做任何测试服实测**,不可援引本工单其他 changelog(fleet 价格日历 #8035、发票签发 #8043)测试服实测出的"频率极低"结论——那是各自独立测量的结果,本组三把锁粒度均为单订单,与被测的价格日历/发票签发粒度不同,机制相同不代表频率相同。
|
||||
- **前端 workaround 清理点**: 无(本次是新增失败路径,不是清理旧 workaround)。
|
||||
|
||||
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
|
||||
|
||||
- **仅影响**: 上述 8 个端点,抢锁失败时的响应体
|
||||
- **零影响**:
|
||||
- 出行人列表查询(`GET /v3/admin/order/{id}/traveler/list`)、出行人校验(`GET .../traveler/validate`)
|
||||
- 单个出行人新增(`POST /v3/admin/order/{id}/traveler/add`,走另一把独立锁 `order:traveler:add:{id}`,本次未涉及)
|
||||
- 大交通批次相关接口
|
||||
- 发票的「完成开票」「重新上传」「申请重开」三个写口(属于另一把锁 `order-invoice:issue`,已在 #8043 处理,非本文档范围)
|
||||
- 发票详情/列表/下载/企业信息自动回填等只读接口
|
||||
- `order-ins:`(保险)/ `material:tag:name:`(resource)/ `fleet:msg-tpl:default:`(fleet)三组,本轮判定不修,行为未变
|
||||
- 8 个端点的请求字段、响应字段结构
|
||||
- 既有业务错误码的触发条件与文案
|
||||
- 幂等拦截(100502)的行为
|
||||
- 单个请求在**无并发**时的行为(延迟、返回值、写入内容全部不变)
|
||||
- hl-gateway 路由配置——8 个端点路径/方法零变化
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
**部署读数(2026-09-20 23:24)**:hl-order-service-v3 滚动更新两实例,部署前 `c35251b07` → 部署后 `ba8aab3ab`,8186 与 8086 各 12 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor 43c53c4c9 ba8aab3ab` 返回 **ANCESTOR_YES**。
|
||||
|
||||
🔴 本次**未在测试服做功能调用取证,这是有意为之而不是遗漏**:本组端点是删除出行人与批量改出行人,调用它们会**真实删改订单下的出行人行**;测试服订单数据被多个会话共用,删除**撤不回来**,副作用会落在一个不知情的人头上。
|
||||
|
||||
互斥本身由**落库级并发 IT** `Lock4jSharedNameConcurrencyIT` 覆盖(3 条用例,含专为自调用入口 `deleteByInternal` 单写的一条),不变量是「订单至少留 1 位成人」;另有 `Lock4jSharedNameContractTest` 按 SpEL 反查全类、钉死三组的成员集合,**能抓住日后新加的第 N 个方法**。
|
||||
|
||||
⚠️ **`100503` 在测试服上一次都没有触发过**——不是「测过、不会触发」,是**没测**。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-7)
|
||||
- 关联 PR: [wx/HL#8049](https://git.1814.love:8443/wx/HL/pulls/8049)
|
||||
- 相关(另一组锁,非本文档范围): `changelogs-v2/2026-09/20_7980_发票签发重传重开三写口补同一把锁新增100503冲突码触达C端-修改接口-管理后台.md`(PR #8043,`order-invoice:issue` 锁)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8049](https://git.1814.love:8443/wx/HL/pulls/8049)
|
||||
- **Merge commit**: [43c53c4c9](https://git.1814.love:8443/wx/HL/commit/43c53c4c9)(合并后回填,backend_status 待部署后翻转)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
@@ -0,0 +1,762 @@
|
||||
---
|
||||
schema: "hl-changelog/v2"
|
||||
ticket: "7980"
|
||||
title: "配房工作台七个写口(提交/单日确认/位置调整/清空全部/清空当天/转单/释放)补同一把 @Lock4j name,新增可重试冲突码 100503(接口路径/字段零增删)"
|
||||
consumer: "admin"
|
||||
author: "wx(GIT)"
|
||||
change_type: "修改接口"
|
||||
backend_status: "deployed"
|
||||
gateway_status: "not_required"
|
||||
frontend_status: "pending"
|
||||
frontend_owner: "mmg"
|
||||
frontend_ref: ""
|
||||
target_release: ""
|
||||
verified_at: ""
|
||||
status_note: "PR #8069 已 squash 合并 dev-v3(合并提交 b9738a20f)。2026-09-20 23:24 已部署测试服 hl-order-service-v3:滚动更新两实例,部署前 COMMIT=c35251b07、部署后 COMMIT=ba8aab3ab,8186 与 8086 各 12 秒内起监听。`git merge-base --is-ancestor b9738a20f ba8aab3ab` 返回 ANCESTOR_YES。🔴 并且部署前 `b9738a20f` 确实不在 `c35251b07` 内(同一判据返回 NO),即本次部署是真正的「从不生效到生效」,不是重跑一遍确认。gateway_status=not_required:端点路径/方法零变化,本次与网关路由无关,不是漏验证。🔴 本次未在测试服做功能调用取证,这是有意为之而不是遗漏:这 7 个端点全是写口(`submit` / `confirmDay` / `updatePlacement` / `clearAssignments` / `clearAssignmentsByDay` / `transfer` / `release`),调用它们会真实改动配房行与库存记账,而测试服的房务数据被多个会话共用,`clear` 与 `release` 造成的后果撤不回来——副作用会落在一个不知情的人头上。互斥本身由落库级并发 IT `HouseRequirementWriteLockConcurrencyIT` 覆盖:绿轮 `house_hotel_assignment(active)=[]`、`house_dual_deduction_log(持有中)=[]`,submit 挂起 1109ms、锁键只有一把;变异轮(拆掉 7 处 `name`)留下 1 行孤儿 + 库存未释放,submit 只挂 213ms、两个键且其中一个带方法名。这比任何测试服抓包都更直接。⚠️ `100503` 在测试服上一次都没有触发过——不是「测过、不会触发」,是没测;临界区短不等于永不超时,更长事务/更大载荷/生产数据量下仍可能出现。"
|
||||
updated_at: "2026-09-20"
|
||||
base: "dev-v3"
|
||||
---
|
||||
|
||||
# order-v3: 配房工作台七个写口补同一把 @Lock4j name,新增可重试冲突码 100503
|
||||
|
||||
> **存放目录**:
|
||||
> - 一期(v2,无 `order-v3` 标签的工单)→ `changelogs/{YYYY-MM}/`
|
||||
> - 二期(v3,`order-v3` 标签的工单)→ `changelogs-v2/{YYYY-MM}/`
|
||||
>
|
||||
> **服务**: hl-order-service-v3
|
||||
> **PR**: #8069
|
||||
> **Issue**: #7980(AC-5)
|
||||
> **日期**: 2026-09-20
|
||||
> **影响范围**: 管理后台「房务配房工作台」§2.2 提交配房、单日确认、§2.3b 位置调整、§2.4b/§2.4c 清空配房、§1.3 转单、§1.4 释放共 7 个写操作,在**并发命中同一需求**时的失败分支
|
||||
|
||||
---
|
||||
|
||||
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
|
||||
|
||||
- 这 7 个写口分散在 `HouseAssignmentAdminController`(5 个)与 `HouseGrabAdminController`(2 个)两个类上,`@Lock4j` 的 `keys` 逐字相同(`'house:req:write:' + #requirementId`),代码注释里一直写着「统一需求级写锁 house:req:write:reqId」,但**补 `name` 之前这句话是假的**:lock4j 2.2.7 缺省 `name` 时用「类全限定名 + 方法名」当默认值拼进 Redis 键,7 个方法名不同 ⇒ 拿到的是 **7 把互不相干的锁**,可以互相踩踏(比如 `submit` 还没插完候选,`clearAssignments` 就把它软删了)。
|
||||
- 本次给 7 处补上同一个显式 `name = HouseRequirementLockConstants.REQUIREMENT_WRITE_LOCK_NAME`(值 `"house:req:write"`),互斥才真正生效。**没有新增接口、没有增删字段**,唯一契约变化是新增一个可能返回的失败码 **`100503`**,**HTTP 状态码仍是 200**。
|
||||
- 🔴 **本 PR 覆盖边界(避免误读成「房务写已全部串行」)**:同一张 `house_hotel_assignment` 表上仍有三类写口**不在这把锁内**,本次未处理:① `HouseAssignmentAdminController` 的 `update`(`PUT /v3/admin/order/assignments/{id}`)与 `delete`(`DELETE /v3/admin/order/assignments/{id}`)完全无锁;② `HouseGroupBatchAssignmentService`(团期整团重配)走另一个键空间;③ 订单域直写路径。这三类不在本文档「二、变更接口清单」内,行为未变。
|
||||
- 🔴 **本组已确认无 mp / C 端**:7 个 Service 方法各只有一个调用方(对应各自的 admin Controller 方法),路径全部落在 `/v3/admin/order/...`,新增的 `100503` 只会到达 hl-ui 房务工作台,不经过小程序。
|
||||
|
||||
---
|
||||
|
||||
## 一、背景(选填)
|
||||
|
||||
`HouseHotelAssignmentDO` **确有** `@Version` 字段(`:116`),项目也**确实注册了** `OptimisticLockerInnerInterceptor`(`SlowSqlConfiguration:40`),但对这 7 个写口的并发场景乐观锁**不生效**,原因有三层:① `submit`/`clear*` 走的是 `insert`/`deleteById`,两者都绕过 `@Version`(乐观锁只拦 `updateById`);② `confirmDay` 的最小补丁 SQL 不携带版本字段;③ `submit` 内部的 `updateById` 调用**从不检查返回值**,版本冲突会静默退化成空操作。三层叠加下唯一能兜住这组写口互斥的就是这把分布式锁,而它在补 `name` 之前并不生效。
|
||||
|
||||
变异证明(管理者第一手复跑):拆掉 7 处 `name` 后,集成测试 `submitParallelClearAssignments_leavesNoOrphanRowNorHeldInventory` 转红——`submit` 还没插完候选,`clearAssignments` 已把该需求下的旧配房行软删,两者没有被同一把锁挡住。落库读数对照:
|
||||
|
||||
| 维度 | 变异轮(缺 name,坏形态) | 复绿轮(补齐 name) |
|
||||
|------|------|------|
|
||||
| `house_hotel_assignment(active)` 孤儿行 | 1 行孤儿 | `[]`(无孤儿) |
|
||||
| `house_dual_deduction_log(持有中)` | 库存未释放 | `[]`(已释放) |
|
||||
| `submit` 实际挂起耗时 | 213ms | 1109ms(真正排队等锁) |
|
||||
| 在途可见锁键数 | 2 把(其中一把带方法名) | 1 把 |
|
||||
|
||||
---
|
||||
|
||||
## 二、变更接口清单
|
||||
|
||||
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|
||||
|---|------|------|------|----------|------|
|
||||
| 1 | 提交配房方案 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/assignments` | 新增可能返回的错误码 | 抢锁超时返 100503(原接口/字段不变) |
|
||||
| 2 | 单日确认配房 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/assignments/days/{dayNumber}/confirm` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
| 3 | 调整单条配房位置与资源 | PUT | `/v3/admin/order/hotel-requirements/{requirementId}/assignments/{id}/placement` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
| 4 | 清空当前需求全部配房 | DELETE | `/v3/admin/order/hotel-requirements/{requirementId}/assignments` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
| 5 | 清空当前需求某一天配房 | DELETE | `/v3/admin/order/hotel-requirements/{requirementId}/assignments/days/{dayNumber}` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
| 6 | 转单 / 超管强制指派 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/transfer` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
| 7 | 释放回抢单池 | POST | `/v3/admin/order/hotel-requirements/{requirementId}/release` | 新增可能返回的错误码 | 与 §1 共用同一把需求级锁 |
|
||||
|
||||
---
|
||||
|
||||
## 三、接口详情
|
||||
|
||||
### 1. 提交配房方案 `POST /v3/admin/order/hotel-requirements/{requirementId}/assignments`
|
||||
|
||||
**VO**: `AssignmentSubmitReqVO → AssignmentSubmitRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务在配房工作台对某个已抢到手的住宿需求批量提交候选方案(一次可提交多晚 × 多家庭,每项对应一晚一组房间)。本次改动不涉及本接口的入参/出参字段,只新增一条「被本组另外 6 个写口占用同一把需求级锁」的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 `requirementId`(住宿需求 ID)+ Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID(逻辑 FK → order_hotel_requirement.id) |
|
||||
| items | Body | List\<Item\> | ✅ | `@NotEmpty` | 配房项列表(批量),每项一晚一组房间 |
|
||||
| items[].dayNumber | Body | Integer | ✅ | ≥1 | 第几天(1=Day1) |
|
||||
| items[].hotelId | Body | Long | ✅ | - | 酒店 ID |
|
||||
| items[].roomTypeId | Body | Long | ✅ | - | 房型 ID,须归属该 hotelId(否则 808112) |
|
||||
| items[].roomCategory | Body | String | ✅ | `@NotBlank` | 房型字典 code |
|
||||
| items[].roomCount | Body | Integer | ✅ | ≥1 | 间数 |
|
||||
| items[].protoPrice | Body | BigDecimal | - | ≥0 | 协议价快照,不传按当日资源协议价兜底 |
|
||||
| items[].settlementPrice | Body | BigDecimal | - | ≥0 | 结算价快照,不传按当日资源结算价兜底,再兜底协议价 |
|
||||
| items[].settleType | Body | String | - | `cash\|sign\|company` | 支付方式快照,不传取酒店资源配置 |
|
||||
| items[].deductInventory | Body | Boolean | ✅ | - | 是否扣减资源酒店房型库存 |
|
||||
| items[].syncProtocolPrice | Body | Boolean | - | 默认 false | 是否把协议价同步写回 resource 价格日历 |
|
||||
| items[].syncSettlementPrice | Body | Boolean | - | 默认 false | 是否把结算价同步写回 resource 价格日历 |
|
||||
| items[].syncSettleType | Body | Boolean | - | 默认 false | 是否把支付方式同步写回酒店资源 |
|
||||
| items[].remark | Body | String | - | - | 备注 |
|
||||
| items[].replaceReason | Body | String | - | ≤256 字符 | 替换原因(仅该天旧行被本项替换时落库) |
|
||||
|
||||
#### 出参 `Result<AssignmentSubmitRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| successCount | Integer | 成功条数 |
|
||||
| failCount | Integer | 失败条数 |
|
||||
| items | List\<Item\> | 每条配房结果 |
|
||||
| items[].dayNumber | Integer | 第几天 |
|
||||
| items[].assignmentId | String(雪花,序列化为字符串) | 配房 ID |
|
||||
| items[].arrange | String | 配房状态:pending / waiting / confirmed / problem |
|
||||
| items[].deductInventory | Boolean | 本次配房是否扣减了资源库存 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"items": [
|
||||
{
|
||||
"dayNumber": 1,
|
||||
"hotelId": 200001,
|
||||
"roomTypeId": 300001,
|
||||
"roomCategory": "STANDARD",
|
||||
"roomCount": 2,
|
||||
"protoPrice": 320.00,
|
||||
"settlementPrice": 300.00,
|
||||
"settleType": "cash",
|
||||
"deductInventory": true,
|
||||
"syncProtocolPrice": false,
|
||||
"syncSettlementPrice": false,
|
||||
"syncSettleType": false,
|
||||
"remark": "已电话确认"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{
|
||||
"code": 200,
|
||||
"message": "成功",
|
||||
"data": {
|
||||
"successCount": 1,
|
||||
"failCount": 0,
|
||||
"items": [
|
||||
{ "dayNumber": 1, "assignmentId": "70200", "arrange": "waiting", "deductInventory": true }
|
||||
]
|
||||
},
|
||||
"success": true
|
||||
}
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回统计 + 明细数组,items 不会为空数组,因为入参 items 本身要求非空)。无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 400 | 参数校验失败(items 为空等) |
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 未登录或非房务角色 / 房务组长只读监督,无权提交配房 |
|
||||
| 808100 | 需求不存在 |
|
||||
| 808113 | 需求已作废(is_active=0),不可提交配房 |
|
||||
| 808110 | 需求不属于当前用户(被他人抢到) |
|
||||
| 808116 | 需求未被任何人抢单,须先抢单再配房 |
|
||||
| 808119 | 订单已取消或异常处置中,不可新增配房 |
|
||||
| 808102 | 任一 item 的 dayNumber 超出该需求应配晚数 |
|
||||
| 808111 | items 为空(禁止保存空方案清空已有配置) |
|
||||
| 808112 | 房型不属于所选酒店 |
|
||||
| 808117 | 同一次提交内同天同酒店同房型重复 |
|
||||
| 808124 | 是否扣减资源库存(deductInventory)必选 |
|
||||
| 589552/589553 | 团期子订单:团期尚未成团 / 班期尚未建团(未成团不得占用资源) |
|
||||
| 100502 | 3 秒幂等窗口内重复提交(键=requirementId) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(本组另外 6 个写口之一正持有 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒窗口(键=requirementId),窗口内重复提交直接拒绝,不会走到锁竞争这一步。
|
||||
- 抢锁失败(100503)时该次请求**未进入方法体,零写入**——`@HouseWriteGuarded` 拦截器先于 `@Lock4j`/`@Idempotent` 执行角色门(先拦非房务角色),角色门通过后 `@Lock4j` 拿不到锁直接抛异常,业务代码一行不会执行,不产生任何 SQL。
|
||||
- `deductInventory=true` 的项在提交阶段即占用库存(防超卖),但计入「已配房」仍以 `CONFIRMED` 为准(`INQUIRING` 不计入 finalize 闸口判定)。
|
||||
- 房型归属越权校验在任何插入/扣减之前执行:`roomTypeId` 不属于所传 `hotelId` 时整个 submit 拒绝,不部分写入。
|
||||
- 老数据兼容:本次不涉及字段增删,存量配房记录无需迁移。
|
||||
|
||||
---
|
||||
|
||||
### 2. 单日确认配房 `POST /v3/admin/order/hotel-requirements/{requirementId}/assignments/days/{dayNumber}/confirm`
|
||||
|
||||
**VO**: `AssignmentDayConfirmReqVO(请求体可选,可为 null) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务对某一天点「确认」:从该天询房中(INQUIRING)候选行里挑选要保留的(可多家),被挑中的翻 `CONFIRMED` 并此刻扣减库存,落选的软删并按需还原库存。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + 可选 Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID |
|
||||
| dayNumber | Path | Integer | ✅ | ≥1 | 第几天(从 1 开始) |
|
||||
| keepAssignmentIds | Body | List\<Long\>(JSON 传字符串数组) | - | 省略/空=该天全部候选都保留确认 | 该天要保留并确认的配房行 ID 列表(可多家) |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据,`Result.data` 恒为 `null` |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{ "keepAssignmentIds": ["1234567890"] }
|
||||
```
|
||||
|
||||
(`keepAssignmentIds` 省略或不传 body 时按「该天全部候选都保留」处理。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念(成功恒返回 `data: null`),无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 未登录或非房务角色 / 房务组长只读监督,无权确认 |
|
||||
| 808100 | 需求不存在 |
|
||||
| 808113 | 需求已作废 |
|
||||
| 808110 | 需求不属于当前用户 |
|
||||
| 808116 | 需求未被任何人抢单 |
|
||||
| 808119 | 订单已取消或异常处置中 |
|
||||
| 808102 | dayNumber 超出该需求应配晚数 |
|
||||
| 808118 | 该天无询房中(INQUIRING)候选可确认 |
|
||||
| 808123 | `keepAssignmentIds` 中存在不属于该天候选集的 ID(可能配房已更新,需刷新后重试) |
|
||||
| 589552/589553 | 团期子订单未成团 / 班期未建团 |
|
||||
| 100502 | 3 秒幂等窗口内重复确认(键=requirementId+dayNumber) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 幂等:`@Idempotent` 3 秒窗口(键=requirementId+dayNumber)。
|
||||
- 抢锁失败(100503)时零写入,逻辑同 §1;候选行状态不会被部分翻转。
|
||||
- 单日确认不再重复扣库存——扣减动作已在 submit 阶段发生,本接口只做状态翻转(保留→CONFIRMED)与落选软删/还原。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 3. 调整单条配房位置与资源 `PUT /v3/admin/order/hotel-requirements/{requirementId}/assignments/{id}/placement`
|
||||
|
||||
**VO**: `AssignmentPlacementUpdateReqVO → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务对已存在的单条配房行原子调整目标晚次、酒店、房型和房间数(前端只传目标 dayNumber,入住日期由后端按当前订单行程推导)。涉及扣库存时先预占目标库存,本地事务提交后再释放原库存。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 当前生效住宿需求 ID |
|
||||
| id | Path | Long | ✅ | - | house_hotel_assignment 主键 |
|
||||
| dayNumber | Body | Integer | ✅ | ≥1 | 目标晚次(1=第1晚) |
|
||||
| hotelId | Body | Long | ✅ | - | 目标酒店 ID |
|
||||
| roomTypeId | Body | Long | ✅ | - | 目标房型 ID,须归属该 hotelId |
|
||||
| roomCategory | Body | String | ✅ | `@NotBlank` | 目标房型字典 code |
|
||||
| roomCount | Body | Integer | ✅ | ≥1 | 目标房间数 |
|
||||
| protoPrice | Body | BigDecimal | - | ≥0 | 协议价快照,不传按目标房型和目标入住日读取资源价格日历 |
|
||||
| settlementPrice | Body | BigDecimal | - | ≥0 | 结算价快照,不传同上兜底 |
|
||||
| settleType | Body | String | - | `cash\|sign\|company` | 支付方式快照 |
|
||||
| deductInventory | Body | Boolean | ✅ | - | 是否扣减资源库存 |
|
||||
| syncProtocolPrice | Body | Boolean | - | 默认 false | 是否同步协议价到目标房型目标日期价格日历 |
|
||||
| syncSettlementPrice | Body | Boolean | - | 默认 false | 是否同步结算价 |
|
||||
| syncSettleType | Body | Boolean | - | 默认 false | 是否同步支付方式到目标酒店资源 |
|
||||
| remark | Body | String | - | - | 备注,不传保留原备注 |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{
|
||||
"dayNumber": 2,
|
||||
"hotelId": 200001,
|
||||
"roomTypeId": 300001,
|
||||
"roomCategory": "STANDARD",
|
||||
"roomCount": 2,
|
||||
"protoPrice": 320.00,
|
||||
"settlementPrice": 280.00,
|
||||
"settleType": "cash",
|
||||
"deductInventory": true,
|
||||
"syncProtocolPrice": false,
|
||||
"syncSettlementPrice": false,
|
||||
"syncSettleType": false,
|
||||
"remark": "改期后重新询房"
|
||||
}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念,无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 角色门 |
|
||||
| 808124 | deductInventory 未传 |
|
||||
| 808120 | 配房行不存在,或 requirementId 与该行实际归属需求不一致 |
|
||||
| 808100/808113 | 需求不存在 / 已作废 |
|
||||
| 808110/808116 | 需求不属于当前用户 / 未被抢单 |
|
||||
| 808119 | 订单已取消或异常处置中 |
|
||||
| 808102 | 目标 dayNumber 超出应配晚数 |
|
||||
| 808112 | 目标房型不属于目标酒店 |
|
||||
| 808126 | 涉及库存迁移但原配房缺少可释放的持有日志(禁止继续迁移造成双扣) |
|
||||
| 589552/589553 | 团期子订单未成团 / 班期未建团 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 本接口无 `@Idempotent`,仅靠 `@Lock4j` 串行化 + 库存迁移前置校验去重。
|
||||
- 事务外编排:目标库存先预占并标 PENDING,本地事务内原子更新配房与扣减日志;任一阶段失败精确释放新预占,原配房与原库存保持不变。抢锁失败(100503)属于最早期失败,同样零写入。
|
||||
- 改期前旧日期的只读配房行不可经本接口调整(会抛只读相关错误码,非本次新增范围)。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 4. 清空当前需求全部配房 `DELETE /v3/admin/order/hotel-requirements/{requirementId}/assignments`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → AssignmentClearRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务在已配房后先清空当前需求下全部 active 配房行(软删 + 按需释放库存),用于驳回需求或重新配房。不释放抢单人、不回抢单池。本次改动不涉及出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID |
|
||||
|
||||
#### 出参 `Result<AssignmentClearRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| clearedCount | Integer | 本次清空的 active 配房行数 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /v3/admin/order/hotel-requirements/1234567890123456789/assignments
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": { "clearedCount": 2 }, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
该需求下没有任何 active 配房行时同样返回 200 成功,`clearedCount=0`,不报错。无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 角色门 |
|
||||
| 808100/808113 | 需求不存在 / 已作废 |
|
||||
| 808110/808116 | 需求不属于当前用户 / 未被抢单 |
|
||||
| 589552/589553 | 团期子订单未成团 / 班期未建团 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`,仅靠 `@Lock4j` 串行化。
|
||||
- 抢锁失败(100503)时零写入,`clearedCount` 不会出现"清了一半"的中间态。
|
||||
- 只清空当前生效需求下 active 配房行,不影响历史已作废需求下的行。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 5. 清空当前需求某一天配房 `DELETE /v3/admin/order/hotel-requirements/{requirementId}/assignments/days/{dayNumber}`
|
||||
|
||||
**VO**: `无 ReqVO(纯路径参数,无请求体) → AssignmentClearRespVO`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务在每个行程日行内点「清空」,只清空该天配房,不影响其他天、不释放抢单人、不回抢单池。本次改动不涉及出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID |
|
||||
| dayNumber | Path | Integer | ✅ | ≥1 | 第几天 |
|
||||
|
||||
#### 出参 `Result<AssignmentClearRespVO>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| clearedCount | Integer | 本次清空的 active 配房行数 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```http
|
||||
DELETE /v3/admin/order/hotel-requirements/1234567890123456789/assignments/days/2
|
||||
Authorization: Bearer {token}
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": { "clearedCount": 1 }, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
该天没有 active 配房行时同样返回 200 成功,`clearedCount=0`。无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 角色门 |
|
||||
| 808102 | dayNumber < 1 |
|
||||
| 808100/808113 | 需求不存在 / 已作废 |
|
||||
| 808110/808116 | 需求不属于当前用户 / 未被抢单 |
|
||||
| 589552/589553 | 团期子订单未成团 / 班期未建团 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`,仅靠 `@Lock4j` 串行化。
|
||||
- 抢锁失败零写入,逻辑同 §4;仅影响本 dayNumber 行,不波及其他天。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 6. 转单 / 超管强制指派 `POST /v3/admin/order/hotel-requirements/{requirementId}/transfer`
|
||||
|
||||
**VO**: `HouseTransferReqVO → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
普通房务把手上的需求转给另一位房务,或超管强制指派。后端按 JWT 身份分流:普通房务必须是当前 claimer,`reason` 可选;超管不要求是当前 claimer,`reason` 须 ≥10 字。**只能转「已经被某个房务抢到」的需求**(`status=PROCESSING`),未认领的需求恒返 808001;团期子订单永远到不了 `PROCESSING`(逐户抢单对团单无条件拒),归属改由整团认领端点建立,本接口对团单不适用。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID |
|
||||
| toUserId | Body | Long(JSON 传字符串) | ✅ | `@NotNull` | 接收人房务 ID |
|
||||
| reason | Body | String | - | ≤200 字符;超管指派须 ≥10 字(Service 层校验) | 转单/指派原因 |
|
||||
| skipUpperLimit | Body | Boolean | - | 默认 false,历史字段(单量上限已下线) | 仅审计留痕用,不影响结果 |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{ "toUserId": "1003", "reason": "我今天临时请假,转给小图接手" }
|
||||
```
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念,无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 角色门 |
|
||||
| 808001 | 需求未认领(status≠PROCESSING),普通转单/超管指派均不适用 |
|
||||
| 808002 | 需求已不存在(已取消/已配完) |
|
||||
| 808010 | 普通房务调用且非当前 claimer |
|
||||
| 808011 | 接收人不存在或已离职 |
|
||||
| 808012 | 转单/指派原因不能为空(普通转单在需要时) |
|
||||
| 808013 | 一单转单次数达上限(3 次,仅普通房务触发) |
|
||||
| 808014 | 接收人就是当前归属人 |
|
||||
| 808016 | 超管指派原因长度不足 10 字 |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`,仅靠 `@Lock4j` 串行化,与 submit/confirmDay/release 互斥(防转单改 claimer 与配房写口并发,授权过期仍写)。
|
||||
- 抢锁失败零写入,claimer 不会被部分改写。
|
||||
- 底层 CAS 前置是 `status='PROCESSING'`,该状态只由抢单端点写入;团期子订单永远到不了这个状态,需改用团级整团认领入口。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
### 7. 释放回抢单池 `POST /v3/admin/order/hotel-requirements/{requirementId}/release`
|
||||
|
||||
**VO**: `HouseReleaseReqVO(请求体可选) → Void`
|
||||
|
||||
#### 使用场景
|
||||
|
||||
房务把手上的需求释放回抢单池(例如客人改行程暂时无法配房)。已有 `CONFIRMED` 配房时禁止释放(须先删除配房)。本次改动不涉及入参/出参字段,只新增一条抢锁超时的失败分支。
|
||||
|
||||
#### 入参
|
||||
|
||||
路径参数 + 可选 Body:
|
||||
|
||||
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|
||||
|------|------|------|------|------|------|
|
||||
| requirementId | Path | Long | ✅ | - | 需求 ID |
|
||||
| reason | Body | String | - | ≤200 字符 | 释放原因,前端「确认释放」弹窗可能整个不传 body |
|
||||
|
||||
#### 出参 `Result<Void>`
|
||||
|
||||
| 字段 | 类型 | 说明 |
|
||||
|------|------|------|
|
||||
| data | null | 无返回数据 |
|
||||
|
||||
#### 请求示例
|
||||
|
||||
```json
|
||||
{ "reason": "客人改行程,暂时无法配房" }
|
||||
```
|
||||
|
||||
(`reason` 及整个请求体均可省略。)
|
||||
|
||||
#### 响应示例
|
||||
|
||||
```json
|
||||
{ "code": 200, "message": "成功", "data": null, "success": true }
|
||||
```
|
||||
|
||||
#### 空数据 / 降级响应
|
||||
|
||||
本接口无「空数据」概念,无降级路径。
|
||||
|
||||
#### 错误响应
|
||||
|
||||
```json
|
||||
{ "code": 100503, "message": "资源被占用,请稍后重试", "data": null, "success": false }
|
||||
```
|
||||
|
||||
| code | 触发条件 |
|
||||
|------|----------|
|
||||
| 401 | 未登录(网关拦截) |
|
||||
| 808090/808091 | 角色门 |
|
||||
| 808020 | 需求不属于当前用户 |
|
||||
| 808021 | 已有 CONFIRMED 配房,无法释放(须先删除配房) |
|
||||
| **100503(本次新增)** | 抢锁等待超过 3 秒(与本组另外 6 个写口共用同一把 `house:req:write` 锁),**可重试** |
|
||||
|
||||
#### 业务边界
|
||||
|
||||
- 无 `@Idempotent`,仅靠 `@Lock4j` 串行化,与 confirmDay/submit/transfer 互斥(根治 release 的 countConfirmed 检查与 confirmDay 插 CONFIRMED 行的竞态)。
|
||||
- 抢锁失败零写入,claimer 字段不会被部分清空。
|
||||
- 释放后写 `op_type=RELEASE` 留痕(不受本次改动影响)。
|
||||
- 老数据兼容:无字段变化。
|
||||
|
||||
---
|
||||
|
||||
## 四、契约约束与正确调用方式(接口类必写)
|
||||
|
||||
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
|
||||
|
||||
### 并发冲突(100503)的正确处理方式
|
||||
|
||||
7 个写口本次开始**真正共用同一把需求级锁**:同一 `requirementId` 上任一时刻只有一个能执行,其余排队等待,等待超过 3 秒(`acquireTimeout`,本组 `@Lock4j` 均未显式设置,取 lock4j-core 注解默认值 3000ms)直接返回失败,不会无限排队。
|
||||
|
||||
| 场景 | 响应 |
|
||||
|------|------|
|
||||
| ✅ 单个请求,无并发 | 200 + 正常业务结果 |
|
||||
| ✅ 同需求两个请求先后到达,第二个在 3 秒内轮到锁 | 两个都 200(第二个排队等待,非立即失败) |
|
||||
| ❌ 同需求两个请求并发,第二个等待超过 3 秒未抢到锁 | `{ "code": 100503, "message": "资源被占用,请稍后重试", "success": false }`,**HTTP 状态码仍是 200** |
|
||||
| ✅ 不同 requirementId 并发 | 互不影响,各自独立加锁 |
|
||||
|
||||
**前端必须做的事**:判断响应体 `code === 100503`(不是判 HTTP 状态码),命中时提示"另一个配房操作正在进行,请稍后重试"并允许用户重新提交;**不要**当作系统异常、**不要**静默吞掉不提示。
|
||||
|
||||
### 角色门与锁/幂等的执行顺序
|
||||
|
||||
`@HouseWriteGuarded` 拦截器(`HandlerInterceptor.preHandle`)执行在所有 Controller 方法级 AOP(`@Idempotent`/`@Lock4j`)**之前**——非房务角色(如组长)会先被 808090/808091 拒绝,不会消耗幂等令牌或进入锁排队;只有通过角色门的合法请求才会走到本次新增的 100503 分支。
|
||||
|
||||
---
|
||||
|
||||
## 五、数据库行为(涉及写操作时必写)
|
||||
|
||||
本次不改变任何一次**成功**写操作实际写入的行数或内容——7 个端点各自原有的写入逻辑(insert 候选行 / 状态翻转 / 软删 / 库存记账)逐字节不变。变化的只是"谁能在同一时刻对同一需求执行写入":
|
||||
|
||||
| 维度 | 改动前 | 改动后 |
|
||||
|------|--------|--------|
|
||||
| 7 个写口是否互斥(同 requirementId) | 否(各自持独立锁,可并发) | 是(同一把锁,串行执行) |
|
||||
| 抢锁失败时是否有部分写入 | N/A(此前不会因锁而失败) | 否,零写入(`@Lock4j` 方法级环绕拦截,拿不到锁直接抛异常,业务方法体不会被调用,不产生任何 SQL) |
|
||||
| `submit ∥ clearAssignments` 并发结果(变异证明实测) | 可能留 1 行孤儿配房 + 库存持有日志不释放 | 干净排队,无孤儿行、无未释放库存 |
|
||||
|
||||
---
|
||||
|
||||
## 六、边界行为
|
||||
|
||||
- 未登录 → 401(网关拦截)
|
||||
- 非房务角色 / 房务组长只读监督 → 808090/808091(`@HouseWriteGuarded` 拦截器先于锁/幂等执行)
|
||||
- 需求不存在/已作废/未认领/不属于当前用户 → 808100/808113/808116/808110(各端点适用情况见上)
|
||||
- 团期子订单未成团 → 589552/589553(未成团不得占用资源)
|
||||
- **抢锁超时(本次新增)→ 100503,HTTP 200,可重试,零写入**
|
||||
- 幂等窗口内重复提交(submit/confirmDay,请求相同)→ 100502,与本次改动无关,行为不变
|
||||
- 老数据兼容:本次不涉及字段增删,存量数据无需迁移
|
||||
|
||||
---
|
||||
|
||||
## 六.5、枚举 / 数据字典(接口出现枚举时必写)
|
||||
|
||||
以下枚举在本组接口中均**未变化**,仅为方便前端自包含联调随本次改动一并列出。
|
||||
|
||||
### settleType(支付方式,出现于 §1/§3)
|
||||
|
||||
**所属字段**: `items[].settleType` / `settleType` | **类型**: `String`(正则约束 `cash|sign|company`)
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `cash` | 现付 | 结算方式,不传取酒店资源配置 |
|
||||
| `sign` | 签单 | 结算方式 |
|
||||
| `company` | 公司结 | 结算方式 |
|
||||
|
||||
### arrange(配房状态,出现于 §1 出参 `items[].arrange`)
|
||||
|
||||
**所属字段**: `AssignmentSubmitRespVO.Item.arrange` | **类型**: `String`
|
||||
|
||||
| 值 | 中文 | 说明 |
|
||||
|----|------|------|
|
||||
| `pending` | 待处理 | 配房行初始态 |
|
||||
| `waiting` | 询房中 | 对应库内 `confirm_status=INQUIRING` |
|
||||
| `confirmed` | 已确认 | 对应库内 `confirm_status=CONFIRMED` |
|
||||
| `problem` | 异常 | 配房异常 |
|
||||
|
||||
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
|
||||
|
||||
### 字段级对比
|
||||
|
||||
| 字段 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| (无)| 7 个接口的请求字段、响应字段逐字节不变 | 同左 |
|
||||
|
||||
### 行为级对比
|
||||
|
||||
| 行为 | 改前 | 改后 |
|
||||
|------|------|------|
|
||||
| 7 个写口同需求并发时 | 各自持独立锁,可同时执行(注释宣称"统一需求级写锁",实际不成立) | 同一把锁,串行执行,互斥真正生效 |
|
||||
| `submit ∥ clearAssignments`(变异证明实测) | 可留 1 行孤儿配房 + 库存持有日志不释放 | 干净排队,无孤儿行 |
|
||||
| 抢锁失败的响应 | 不存在这条路径 | 路径真实存在(返回 100503,HTTP 200,业务失败,可重试);本次改动未部署测试服,无实测触发频率数据 |
|
||||
| 接口文档"7 个写口全互斥"这句话 | 写在代码注释里但**不成立** | 写在代码注释里且**成立** |
|
||||
| 请求/响应字段、既有业务错误码 | 不变 | 不变 |
|
||||
|
||||
## 六.7、影响评估(修改/删除类必写)
|
||||
|
||||
- **是否破坏向后兼容**: 否——不改字段、不改既有错误码语义;但**新增了一条此前不存在的失败路径**(100503),前端若没有处理未知业务码的兜底逻辑,可能会把这次失败展示成不友好的提示(而不是"请重试")。
|
||||
- **前端是否必须同步上线**: 建议同步(识别 `code=100503` 并提示可重试),但**触发频率本文档未做任何测试服实测**,无法给出"高频/低频"的判断依据,不可援引本工单另外两份(fleet 价格日历、发票签发)测试服实测的"频率极低"结论——那两份是各自独立测量的结果,机制相同不代表频率相同(本组是需求级锁,锁粒度 = 单个住宿需求,与价格日历/发票的锁粒度不同)。
|
||||
- **前端 workaround 清理点**: 无(本次是新增失败路径,不是清理旧 workaround)。
|
||||
|
||||
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
|
||||
|
||||
- **仅影响**: 房务配房工作台上述 7 个写操作,抢锁失败时的响应体
|
||||
- **零影响**:
|
||||
- `update`(`PUT /v3/admin/order/assignments/{id}`)/ `delete`(`DELETE /v3/admin/order/assignments/{id}`)——本次未处理,仍无锁
|
||||
- §2.1 候选源查询(`GET .../candidates`)、§2.5 房间分配查询/写入、§1.1/§1.2/§1.5 抢单池列表/抢单/我的接单
|
||||
- §2.7 最终确认、回执上传/查询
|
||||
- 团期整团重配(`HouseGroupBatchAssignmentService`,走另一个键空间)
|
||||
- 7 个写口的请求字段、响应字段结构
|
||||
- 既有业务错误码(808xxx / 589552 / 589553)的触发条件与文案
|
||||
- 幂等拦截(100502)的行为
|
||||
- 单个请求在**无并发**时的行为(延迟、返回值、写入内容全部不变)
|
||||
- hl-gateway 路由配置——7 个端点路径/方法零变化
|
||||
|
||||
---
|
||||
|
||||
## 八、测试环境已验证
|
||||
|
||||
**部署读数(2026-09-20 23:24)**:hl-order-service-v3 滚动更新两实例,部署前 `c35251b07` → 部署后 `ba8aab3ab`,8186 与 8086 各 12 秒内起监听,`deploy-backend.sh` 报 `rolling deploy complete`。`git merge-base --is-ancestor b9738a20f ba8aab3ab` 返回 **ANCESTOR_YES**。🔴 **并且部署前 `b9738a20f` 确实不在 `c35251b07` 内**(同一判据返回 NO),即本次部署是真正的「从不生效到生效」,不是重跑一遍确认。
|
||||
|
||||
🔴 本次**未在测试服做功能调用取证,这是有意为之而不是遗漏**:这 7 个端点全是写口(`submit` / `confirmDay` / `updatePlacement` / `clearAssignments` / `clearAssignmentsByDay` / `transfer` / `release`),调用它们会**真实改动配房行与库存记账**,而测试服的房务数据被多个会话共用,`clear` 与 `release` 造成的后果**撤不回来**——副作用会落在一个不知情的人头上。
|
||||
|
||||
互斥本身由**落库级并发 IT** `HouseRequirementWriteLockConcurrencyIT` 覆盖:绿轮 `house_hotel_assignment(active)=[]`、`house_dual_deduction_log(持有中)=[]`,submit 挂起 1109ms、锁键只有一把;**变异轮**(拆掉 7 处 `name`)留下 1 行孤儿 + 库存未释放,submit 只挂 213ms、**两个键且其中一个带方法名**。这比任何测试服抓包都更直接。
|
||||
|
||||
⚠️ **`100503` 在测试服上一次都没有触发过**——不是「测过、不会触发」,是**没测**。
|
||||
|
||||
---
|
||||
|
||||
## 十、相关文档
|
||||
|
||||
- 关联 Issue: [wx/HL#7980](https://git.1814.love:8443/wx/HL/issues/7980)(AC-5)
|
||||
- 关联 PR: [wx/HL#8069](https://git.1814.love:8443/wx/HL/pulls/8069)
|
||||
|
||||
## 关联 / 联系人
|
||||
|
||||
### 链接
|
||||
|
||||
- **Issue**: [#7980](https://git.1814.love:8443/wx/HL/issues/7980)
|
||||
- **PR**: [#8069](https://git.1814.love:8443/wx/HL/pulls/8069)
|
||||
- **Merge commit**: [b9738a20f](https://git.1814.love:8443/wx/HL/commit/b9738a20f)(合并后回填,backend_status 待部署后翻转)
|
||||
|
||||
### 联系人
|
||||
|
||||
- **后端负责人**: @wx
|
||||
在新工单中引用
屏蔽一个用户