diff --git a/changelogs-v2/2026-09/23_8123_团期staff保存新增并发抢锁与配置位字典守卫错误码-修改接口-管理后台.md b/changelogs-v2/2026-09/23_8123_团期staff保存新增并发抢锁与配置位字典守卫错误码-修改接口-管理后台.md new file mode 100644 index 00000000..ad59e085 --- /dev/null +++ b/changelogs-v2/2026-09/23_8123_团期staff保存新增并发抢锁与配置位字典守卫错误码-修改接口-管理后台.md @@ -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<String> | 否 | 传了就不能是空数组;元素非空白且须在员工角色取值域内 | 限定本次覆盖的角色范围,不传则整期覆盖 | +| staffList | body | List<Item> | 否 | 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 +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 的可重试提示语气与表单保留)