文件
hl-api-changelog/changelogs-v2/2026-09/23_8123_团期staff保存新增并发抢锁与配置位字典守卫错误码-修改接口-管理后台.md
T
2026-09-23 15:31:04 +08:00

19 KiB
原始文件 Blame 文件历史

schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
schema ticket title consumer author change_type backend_status gateway_status frontend_status frontend_owner frontend_ref target_release verified_at status_note updated_at base
hl-changelog/v2 8123 团期 staff 保存新增两个错误码:并发抢锁 100503 与配置位字典缺失 582117 admin wx(GIT) 修改接口 deployed not_required not_required 工单 #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 的语义——它是「稍后重试」而不是「操作失败」,用通用错误样式提示会把一次可重试的排队说成失败。前端结论(2026-09-23 用户拍板):100503 不做专门「可重试」提示,维持拦截器通用 toast;582117 文案后端完整直出,5821xx 全族前端无专门分支,基本行为零改动,记 not_required 闭环。 2026-09-23 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 本次扇出触及的活跃子订单数

请求示例

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"}]}

响应示例

{
  "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)仍未抢到锁:

{"code": 100503, "message": "资源被占用,请稍后重试", "data": null}

配置位字典里查不到某个本该有位的角色的归属位({0} 由运行期填入角色名):

{"code": 582117, "message": "角色 GUIDE 未归属任何人员配置位,无法校验人员类型;请检查配置位字典是否被误删", "data": null}

本端点此前已有、本次不变的两个高频码,一并列出便于对照:

{"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 的可重试提示语气与表单保留)