文件
hl-api-changelog/changelogs-v2/2026-09/20_7980_配房工作台七个写口补齐需求级互斥新增100503可重试冲突码-修改接口-管理后台.md
T
2026-09-20 23:35:03 +08:00

763 行
38 KiB
Markdown
原始文件 Blame 文件历史

此文件含有模棱两可的 Unicode 字符
此文件含有可能会与其他字符混淆的 Unicode 字符。 如果您是想特意这样的,可以安全地忽略该警告。 使用 Escape 按钮显示他们。
---
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: "not_required"
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` 在测试服上一次都没有触发过——不是「测过、不会触发」,是没测;临界区短不等于永不超时,更长事务/更大载荷/生产数据量下仍可能出现。mmg 前端实证 2026-09-20:七写口封装于 api/housekeeper/assignment.js(submit/update/placement/delete/clearAll/clearDay/confirmDay)与 api/housekeeper/grab-pool.js(transfer/release);调用方 OrderDetailModal.vue(catch{}+finally:1116 复位)与 housekeeper/orders/index.vue(:683-684 注释「业务错误已由 request.js 拦截器统一弹 message」、:829-830「808021 等由拦截器弹出;保持弹窗供重试」)均为拦截器透 message 模式。100503(HTTP 200+业务码)命中 request.js 拦截器统一 toast「资源被占用,请稍后重试」原样展示——可重试/不静默/不当系统异常三条全满足,不建前端业务码字典(同发票 AC-4 先例),故前端零改动,翻 not_required。"
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