docs(changelog): #8123 #8148 团期 staff 保存新增 100503 与 582117 两个错误码
changelog-filename-gate / validate (push) Failing after 2s

端点 PUT /v3/admin/group-batch/{productBatchId}/staff 的路径、入参结构、
成功响应结构零变化,多出两个此前不会返回的业务码:

- 100503「资源被占用,请稍后重试」:#8123 给保存口加订单级 @Lock4j
  (key = batch-staff:save:{productBatchId},expire 30s,acquireTimeout 走
  lock4j 默认 3s)后,同一团期并发保存的后到者会收到它。HTTP 200 不是 5xx,
  锁维度是单个团期、跨团期不互斥。
- 582117「角色 {0} 未归属任何人员配置位」:#8148 把角色与人员类型的判据
  改读配置位字典后,必须把「DRIVER/OTHER 结构性无位」与「本该有位却被从
  字典里删掉」分开,后者不拒绝会让该角色的 582114 当场静默失效。

正文写了三件前端会撞上的事:端点名 group-batch 吃的却是 productBatchId、
成功码是 200 不是 0、5821xx 全族在 hl-ui v2.1 无专门分支而靠统一拦截器
按 code 非成功值 toast,故两个新码零改动即有基本行为,待办只有 100503
的可重试语气。100503 与 582117 在测试服活体不可构造的理由已按契约边界
点名环境(前者需临界区 >3s,后者需删全站共享的配置位字典),不是让前端等。

Refs #8123
Refs #8148

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-23 14:25:31 +08:00
共同撰写人 Claude Opus 5
父节点 0d44436def
当前提交 c494ccccdd
@@ -0,0 +1,282 @@
---
schema: "hl-changelog/v2"
ticket: "8123"
title: "团期 staff 保存新增两个错误码:并发抢锁 100503 与配置位字典缺失 582117"
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: "2026-09-23"
status_note: "工单 #8123 修的是团期 staff 扇出并发丢配置:两个人几乎同时保存同一团期时,两条扇出各自读到自己那一刻的整期配置、互相覆盖,结果是子订单侧留下重复行而接口两次都返 200。修法是在 saveConfig 上加订单级 @Lock4j(锁名 batch-staff:save:{productBatchId},expire 30s,acquireTimeout 不显式设 = lock4j 默认 3s)并在锁内复读整期最新配置。由此对外多出一个此前这个端点不会返回的码 100503「资源被占用,请稍后重试」——它由 hl-common/hl-starter-protection 的 lockFailureStrategy 抛出,走 HTTP 200 不是 5xx。工单 #8148 把角色↔人员类型的判据从写死角色表改为读配置位字典(GroupBatchStaffSlotResolver#slotTypesOf),随之必须区分两种「查不到归属位」:DRIVER / OTHER 这类结构性不参与配置位的照旧放行,而本该有位却查不到的(运维把 GUIDE / LEADER / PHOTOGRAPHER 从某个仍非空的配置位字典里删掉)必须拒绝,否则该角色的 582114 会当场静默失效、任意人员类型都能落库并返 200,一直到结算对账才发现人岗对不上。这一支落新码 582117。生产代码由 PR #8177 合入 dev-v3(提交 bfc10966e),后续订正 PR #8186 / #8197 / #8199,取证载体 PR #8243。2026-09-23 测试服活体已确认字节就位:order-v3 双实例(8086 / 8186,jar 与磁盘同 inode)的进程字节里 STAFF_ROLE_SLOT_MISSING、RESOURCE_LOCKED、LockFailureExceptionHandler、LockFailureStrategy、slotTypesOf、GroupBatchStaffLockNames、participatesInSlotsByFallback 全部命中。acquireTimeout 的运行时值为默认 3000ms,判据是四个配置来源全零命中且各自带阳性对照:Nacos hl-order-service-v3-dev.yml(1256 字节,阳性对照 spring / datasource 各命中)、jar 内 BOOT-INF/classes/application.yml(6064 字节,阳性对照 spring 命中 4)、仓库 src/main/resources、两个进程的 JVM 启动参数与环境变量(阳性对照:每个进程 16 条启动参数);lock4j jar 版本 2.2.7,与源码 Lock4jProperties#acquireTimeout 默认值 3000L 同版本。前端侧:5821xx 这一族在 hl-ui v2.1 上没有专门分支,统一由 src/utils/request.js 的响应拦截器按 code 非成功值 toast 后端 message,故 100503 与 582117 前端零改动即有基本行为;记 pending 的是 100503 的语义——它是「稍后重试」而不是「操作失败」,用通用错误样式提示会把一次可重试的排队说成失败。"
updated_at: "2026-09-23"
base: "dev-v3"
---
# 团期人员配置: 保存口新增并发抢锁 100503 与配置位字典缺失 582117(管理后台)
> **服务**: hl-order-service-v3(端口 8086/8186)
> **PR**: #8177(主体)、#8186 / #8197 / #8199(订正)、#8243(取证载体)
> **Issue**: #8123、#8148
> **日期**: 2026-09-23
> **影响范围**: 管理后台团期详情页的「配导游 / 配摄影」弹窗保存动作
---
## ⚠️ 关键变化
这个端点**多了两个此前不会返回的业务码**,路径、入参结构、成功响应结构一律不变。
1. **`100503`「资源被占用,请稍后重试」** —— 同一团期被两个人几乎同时保存时,后到的那一方等满 3 秒仍抢不到锁就收到它。**它是 HTTP 200,不是 5xx**,语义是「排队没排上,重来一次就好」,不是「你这次提交有问题」。
2. **`582117`「角色 {0} 未归属任何人员配置位……」** —— 配置位字典被误删成员时的守卫。修复动作在**字典页面**,不在这个弹窗里,所以文案要原样透出给运营看。
两个码都会被 `src/utils/request.js` 的现有拦截器按「code 非成功值」自动 toast 后端 message,**前端零改动即有基本行为**;需要动手的只有 100503 的提示语气(详见「四、契约约束与正确调用方式」)。
---
## 一、背景
### #8123:并发保存互相覆盖
保存团期 staff 的动作分两段——先写团期侧配置(`order_batch_staff`),再扇出到各活跃子订单(`order_staff_assignment`)。改前这两段都没有锁,于是两个人几乎同时保存同一团期时,两条扇出各自读到**自己那一刻**的整期配置并写下去,后写的不知道前面那份已经变了。子订单侧的结果是同一个人留下重复行,而两次请求**都返回 200**——调用方拿不到任何异常信号。
修法是在保存口加订单级分布式锁,并在锁内**复读整期最新配置**再扇出:
| 锁 | 锁名 | key 维度 | expire | acquireTimeout |
|---|---|---|---|---|
| 保存口 | `GroupBatchStaffLockNames.SAVE` | `batch-staff:save:{productBatchId}` | 30s | **默认 3s**(不显式设) |
| 扇出 | `GroupBatchStaffLockNames.FAN_OUT` | 同团期 | — | 显式拉长(异步后台任务,宁可等久也不能丢单) |
两者的等待上限**刻意不一致**:保存口背后有个人在等页面响应,让他 3 秒后重试是对的(他重新打开还能看见别人刚存的最新配置);扇出没有人在等返回,放弃的代价是某张子订单**永久**停在上一轮配置。
### #8148:角色判据从写死表改为读字典
角色↔人员类型的校验(`582114`)改前查的是代码里写死的角色表,改后读配置位字典(`GroupBatchStaffSlotResolver#slotTypesOf`)。这让业务给导游位字典加一行新角色时当天就能生效、不必发版,但也带来一个新情形:
| 「查不到归属位」的成因 | 改前含义 | 改后处置 |
|---|---|---|
| `DRIVER` / `OTHER` 等结构性不参与配置位的角色 | 唯一含义,不校验是对的 | **照旧放行** |
| 运维把 `GUIDE` / `LEADER` / `PHOTOGRAPHER` 从某个仍非空的配置位字典里删掉 | 不存在这种情形 | **拒绝,返 582117** |
两种情形若继续共用「不校验」这一条出路,那一刻该角色的 `582114` 会**当场静默失效**:任意人员类型都能配成这个角色落库、接口返回 200,一直到结算对账才发现人岗对不上。两支由 `participatesInSlotsByFallback` 分开。
**它不是「字典读挂」的兜底**:Feign 失败或字典整表为空时 `typesOf` 回落内置默认值,归属位照样查得到,走不到这个码。能触发它的只有「字典非空、但该角色被从里面删掉」。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 保存团期人员配置 | PUT | `/v3/admin/group-batch/{productBatchId}/staff` | 修改接口 | 新增可能返回的业务码 `100503`、`582117`;路径 / 入参 / 成功响应结构不变 |
路径、入参结构、权限码、成功响应结构均零变化;网关无改动。
---
## 三、接口详情
### 1. 保存团期人员配置 `PUT /v3/admin/group-batch/{productBatchId}/staff`
**VO**: `BatchStaffConfigReqVO`
#### 使用场景
团期详情页「配导游 / 配摄影」弹窗点保存。整期全量覆盖:按 `scopeRoles`(不传即全量)软删旧配置、写入新配置、
异步扇出到各活跃子订单,并按配置结果回填 `guide_ready` / `photographer_ready`。
🔴 **路径参数是产品侧排期 ID,不是团期聚合主键**。端点名叫 `group-batch`,但 `{productBatchId}` 吃的是
`group_tour_batch.batch_id`(`@ApiParam` 原文:「产品侧排期 ID(`group_tour_batch.batch_id`,非运营团期主键)」)。
响应体里另有一个 `groupBatchId`,那才是订单侧 `order_group_batch` 的主键,两者是不同的值,不要互换。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|---|---|---|---|---|---|
| productBatchId | path | Long | 是 | 正整数,产品侧排期 ID | 团期所属班期;未建团返 589553 |
| scopeRoles | body | List&lt;String&gt; | 否 | 传了就不能是空数组;元素非空白且须在员工角色取值域内 | 限定本次覆盖的角色范围,不传则整期覆盖 |
| staffList | body | List&lt;Item&gt; | 否 | null 按空列表处理 | 传空列表 = 清空覆盖范围内的配置 |
| staffList[].staffId | body | Long | 是 | 须命中候选人员 | 人员 ID |
| staffList[].staffRole | body | String | 是 | 须与人员类型相符,否则 582114 | 角色(GUIDE / LEADER / PHOTOGRAPHER …) |
| staffList[].sortOrder | body | Integer | 否 | — | 展示排序 |
| staffList[].remark | body | String | 否 | — | 备注 |
#### 出参
| 字段 | 类型 | 说明 |
|---|---|---|
| productBatchId | Long | 回显路径参数 |
| groupBatchId | Long | 团期聚合主键,与路径参数不是同一个值 |
| staffList | List | 保存后的整期最终状态 |
| affectedOrderCount | Integer | 本次扇出触及的活跃子订单数 |
#### 请求示例
```http
PUT /v3/admin/group-batch/2102640646949105666/staff HTTP/1.1
Host: api.test.1814.love:9443
Authorization: Bearer <admin token>
Content-Type: application/json
{"staffList":[{"staffId":1002,"staffRole":"GUIDE","sortOrder":0,"remark":"hl8123-8148live-normal"}]}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"productBatchId": "2102640646949105666",
"groupBatchId": "2102640736900116481",
"affectedOrderCount": 1,
"staffList": [
{"id": "2102640914386284545", "staffId": 1002, "staffRole": "GUIDE", "staffName": "李雪梅", "remark": "hl8123-8148live-normal"}
]
}
}
```
#### 空数据 / 降级响应
`staffList` 传空列表即清空覆盖范围内的配置,返回 200,`staffList` 为空数组、`affectedOrderCount` 为实际扇出订单数。
被任何一个业务码拒绝时都是**零写入**——校验闸全部在写库之前,回读会看到与提交前逐字相同的配置。
#### 错误响应
同一团期并发保存,后到的一方等满 `acquireTimeout`(默认 3000ms)仍未抢到锁:
```json
{"code": 100503, "message": "资源被占用,请稍后重试", "data": null}
```
配置位字典里查不到某个本该有位的角色的归属位(`{0}` 由运行期填入角色名):
```json
{"code": 582117, "message": "角色 GUIDE 未归属任何人员配置位,无法校验人员类型;请检查配置位字典是否被误删", "data": null}
```
本端点此前已有、本次不变的两个高频码,一并列出便于对照:
```json
{"code": 582116, "message": "本次保存声明的角色范围(GUIDE)只覆盖了配置位的一部分,还缺少 LEADER;同一配置位的角色必须一起声明,否则位内其余人员会被留在库里", "data": null}
```
#### 业务边界
- `100503` 的锁维度是 **`productBatchId`**:并发窗口只存在于**同一个团期**的两次保存之间,不同团期互不阻塞,整体串行化的担心不成立
- `100503` 走 **HTTP 200**,不是 5xx;它有两条产生路径(`lockFailureStrategy` 直接抛 `BusinessException`,或异常以 `LockException` 形态漏出时由 `LockFailureExceptionHandler` 兜底),**终点是同一个码**,调用方不必区分
- `582117` 的修复动作在**配置位字典页面**,不在本弹窗;文案点名了角色并指向字典,适合原样透出给运营
- `582117` **不会**在「字典读挂 / 字典整表为空」时出现——那种情形回落内置默认值,归属位照样查得到
- `582115`(提交的人越界)与 `582116`(声明的范围本身不完整)分工不同:前者改 `staffList` 或扩范围,后者只有一条改法——把缺的同位角色补进 `scopeRoles`。命中 582116 的请求可以**一个越界的人都没有**(`staffList` 甚至可以是空数组,那正是「清空导游位」这条最危险的路径)
- 导游位并收 `GUIDE` 与 `LEADER`:只传 `["GUIDE"]` 却选了领队会命中 582115,只传 `["GUIDE"]` 而位内还有 LEADER 会命中 582116
---
## 四、契约约束与正确调用方式
- **`100503` 要单独做提示,不要并进通用错误 toast**。现有拦截器按 `code` 非成功值统一 toast 后端 message,
行为上没有错,但通用错误样式会把一次「排队没排上、再点一次就好」说成「操作失败」。建议:命中 100503 时
保留用户已填的表单、给一句可重试的提示(后端文案「资源被占用,请稍后重试」本身已是可直接展示的完整句子)。
- **判成败一律看 `code`,不要看 HTTP status**。本端点的业务失败(含 100503)全是 HTTP 200,
`src/utils/request.js:413` 现有的 `code === BIZ_CODE.SUCCESS` 判据是对的,沿用即可。
⚠️ fleet `h5-onboard` 那条独立 axios 实例上有一处 100503 的处理走的是 HTTP 409 语境,**两条链路不同源,不要互相复用**。
- **`productBatchId` 与响应里的 `groupBatchId` 不可互换**(见「使用场景」的红字)。
- 保存成功后若调用方缓存了 `guide_ready` / `photographer_ready`,需重新拉取团期详情。
---
## 五、数据库行为
- 保存:按 `scopeRoles` 范围(不传即整期)软删旧配置行 → 写入新配置行 → 异步扇出到各活跃子订单 → 回填团期行的两个 ready 标志
- 本次新增的锁不改变上述任何一步的写内容,只保证同一团期的两次保存不会交叠执行,且扇出读到的是锁内复读的最新配置
- 被 `100503` / `582114` / `582115` / `582116` / `582117` 任一码拒绝时**零写入**
---
## 六、边界行为
- `100503` 的等待上限 3000ms 来自 lock4j 默认值(运行时无任何显式覆盖),不是代码里写死的常量;改动保存口的锁参数等于改这条对外契约,已由单测 `GroupBatchStaffFanOutLockRetryTest#lockParameters_saveUsesLock4jDefaultAcquireTimeout_whileFanOutOverridesItToOutliveExpire` 钉成可执行断言
- `582117` 与 `582114` 是互补而非替代:字典里查得到归属位、但人岗不符 → `582114`;本该有位却查不到归属位 → `582117`
- `DRIVER` / `OTHER` 这类结构性不参与配置位的角色在两个码上都放行,行为与改前一致
---
## 六.6、修改前后对比
| 维度 | 改前 | 改后 |
|---|---|---|
| 同一团期并发保存 | 两条扇出互相覆盖,子订单侧留重复行,**两次都返 200** | 后到者等满 3s 返 `100503`;抢到锁者在锁内复读最新配置再扇出 |
| 角色↔人员类型判据 | 代码里写死的角色表 | 读配置位字典(字典加角色当天生效、不发版) |
| 配置位字典被删成员 | 该角色的 `582114` **静默失效**,任意人员类型都能落库并返 200 | 返 `582117`,拒绝落库 |
| `DRIVER` / `OTHER` | 放行 | **不变**,仍放行 |
| 路径 / 入参 / 成功响应结构 | — | **零变化** |
---
## 六.7、影响评估
| 面 | 评估 |
|---|---|
| 管理后台配导摄弹窗 | 现有拦截器已能 toast 两个新码的后端文案,**零改动即有基本行为**;需要动的只有 100503 的提示语气与表单保留 |
| 已有错误文案分支 | 5821xx 在 hl-ui v2.1 上无专门分支(全族零命中),统一走拦截器,无需逐码适配 |
| 小程序端 | 不受影响,团期人员配置无小程序写口 |
| 并发体验 | 只在同一团期被两人同时编辑时才可能出现 100503;不同团期互不阻塞 |
| 运维 | 配置位字典删成员会立刻让该位保存被 582117 拒——这是有意的,文案指向字典页面 |
---
## 七、不影响范围
- 路径、入参结构、权限码、成功响应结构、网关配置
- 候选人查询(`/staff/candidates`、`/staff/candidates/page`)与配置列表查询(`GET /staff`)
- 报账人等级设置口 `PUT /v3/admin/group-batch/{productBatchId}/staff/{staffId}/reporter-rank`:本次未取证,不在本条目覆盖范围内
- 子订单自己单独派的人员(`order_staff_assignment` 中 source 非 `GROUP_BATCH` 的行)
- `582114` / `582115` / `582116` 三个既有码的触发条件与文案
---
## 八、测试环境已验证
2026-09-23,`api.test.1814.love:9443`,分支 `dev-v3`,order-v3 活体 `7738668b8`(双实例 8086 / 8186,进程 jar 与磁盘 jar 同 inode)。
| 项 | 请求 | 结果 |
|---|---|---|
| 正常路径回归 | `PUT /v3/admin/group-batch/2102640646949105666/staff`,body 不传 `scopeRoles`(整期全量覆盖),一条 `{staffId:1002, staffRole:"GUIDE"}` | HTTP 200,`code=200`,`message=成功`;`data.staffList` 回显该行,`affectedOrderCount=1` |
| 落库回读 | `GET /v3/admin/group-batch/2102640646949105666/staff` | HTTP 200,返回同一行(`id=2102640914386284545`),**确认真落库、不是空操作假成功** |
| 582116 | 同端点,`{"scopeRoles":["GUIDE"], staffList:[GUIDE 一人]}`(漏声明同位的 LEADER) | HTTP 200,`code=582116`,文案完整返回;回读确认**零写入**(记录仍是上一步那行) |
| 582114 | 同端点,`staffId=1002`(真实 `staffType=GUIDE`)提交 `staffRole=PHOTOGRAPHER` | HTTP 200,`code=582114`,`message=所选人员的角色与其人员类型不符,请重新选择`;回读确认**零写入** |
**这一轮没有取到 `100503` 与 `582117` 的活体样本,两者在测试环境上无法构造**,原因是环境性的、与实现无关:
- `100503` 要求持锁方占用临界区超过 `acquireTimeout`=3000ms 才能让后到者抢不到。真锁双臂单测里实测的等锁只有 **373ms**,
而把临界区人为拉长到 3 秒以上需要改代码或插延迟——那会改变被测对象本身。
- `582117` 要求把 `GUIDE` / `LEADER` / `PHOTOGRAPHER` 从某个**仍非空**的配置位字典里删掉。字典是全站共享数据,
这个动作的副作用会落到所有人的角色下拉框上。
因此这两个码本条目交付的是**契约**(码值、HTTP 状态、文案、触发条件、锁维度),依据是源码定义与测试服活体字节确认,
不是端到端样本。它们的行为另有可执行断言背书:`100503` 由真 lock4j AOP + 真 Redis 的双臂互斥测试覆盖(无锁臂 4 行重复
vs 真锁臂恰好 2 行,后进者等锁 373ms,且断言链最后一条是**两臂之差**,「这一轮恰好没撞上竞争」过不了关);
`582117` 由源码变异实验覆盖(把读字典改回写死表、把 582117 分流短路掉,两轮各有用例转红,方向互补)。
---
## 十、相关文档
- Issue #8123(团期 staff 扇出并发写重复行)、Issue #8148(配置位成员集合改读字典)
- PR #8177(主体,提交 `bfc10966e`)、PR #8186 / #8197 / #8199(订正)、PR #8243(取证载体)
- 错误码定义:`hl-order-service-v3/.../assignment/errorcode/AssignmentErrorCode.java`(582115 / 582116 / 582117)、
`hl-common/hl-common-core/.../errorcode/CommonErrorCode.java:67-68`(100503)
- 后续工单 #8242:三个合法 staffRole 继承了 DRIVER 的类型校验豁免(与 582117 是不同的洞)
---
## 关联 / 联系人
- 后端:wx
- 前端:mmg(100503 的可重试提示语气与表单保留)