文件
hl-api-changelog/changelogs-v2/2026-09/22_8122_团期人员保存声明角色范围时必须整位覆盖-修改接口-管理后台.md
T
2026-09-22 09:51:06 +08:00

23 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 8122 团期 staff 保存:scopeRoles 必须整位覆盖,半位声明一律拒绝且零写入(新错误码 582116) admin wx(GIT) 修改接口 deployed not_required verified mmg 4f65c4535987dab205651d84952fe152579a418d v2.1 2026-09-22 backend_status=deployed: hl-order-service-v3 测试服双实例(8086 / 8186)当前运行 sha=ef7611138,即本单合并提交(PR #8147)本身,不是它的后代——无需 merge-base 推导。三条互相独立的判据:①deploy-status.sh 登记 dev-v3 / ef7611138 / BEHIND=0;②jar /opt/hulalv/jars/hl-order-service-v3-1.0.0-SNAPSHOT.jar mtime=epoch 1790039292(2026-09-22 09:08:12),两实例 /proc/<pid> 启动时刻 epoch 1790039294 与 1790039307 均晚于 jar mtime,且两进程 fd 均指向该 jar 路径、无 (deleted);③Nacos 两实例 healthy:true & enabled:true,启动日志 Flyway 正常、ERROR/Exception/APPLICATION FAILED TO START 零命中。⚠️ 覆盖边界:HTTP 级业务健康(本接口的端到端实际返回)未在测试服实测,本单取证在 Testcontainers 真库 IT 完成(见正文第八节),两者是不同环境、不可互相替代。gateway_status=not_required: 本单改动 6 个文件全在 hl-order-service-v3 模块内,未涉及 hl-gateway 任何路由/Nacos 配置;PUT /v3/admin/group-batch/{productBatchId}/staff 是存量端点、路径本身未变,命中既有通配路由(Path=/v3/admin/**),本单没有引入任何新路径段。frontend_status=pending: 按规范后端不代填 implemented/released/verified,是否需要改动与是否完成由前端自行回写 frontend_owner 与 verified_at。本单对现有前端实现的影响评估见正文「六.7 影响评估」——结论是 mmg 在 #8006 交付的 scopeRoles=roleMeta.memberRoles(GUIDE 位含 GUIDE+LEADER 双角色)本身已是整位覆盖,不会命中新错误码,但该结论基于 hl-ui origin/v2.1 @ b5666b38 的源码读数,请前端按自己分支的实际实现复核。 前端回写(2026-09-22, mmg): 复核本分支实现——scopeRoles=roleMeta.memberRoles(GUIDE 位 GUIDE+LEADER)本就整位覆盖,不命中 582116,行为零改动;已按点名订正 orderV2GroupBatch.js 注释错码(582115→582116)并在 ROLE_META 处补字典驱动风险提示(字典化读取待 #8148 闭环后评估);交付 commit 4f65c453(注释订正,无行为变更),checkpoint 全量绿。 2026-09-22 dev-v3

order-v3: 团期 staff 保存,scopeRoles 必须整位覆盖(新错误码 582116)

存放目录: changelogs-v2/2026-09/

服务: hl-order-service-v3 (端口 8086 / 8186) PR: #8147 Issue: #8122 日期: 2026-09-22 影响范围: 团期 staff 配置保存接口 PUT /v3/admin/group-batch/{productBatchId}/staff


⚠️ 关键变化

这是一次行为收紧:一类此前返回 HTTP 200 的请求,现在会被拒绝(业务码 582116)。

具体是:scopeRoles 只要触及某个配置位,就必须覆盖该配置位的全部成员角色。 只声明半个配置位(例如导游位只传 ["GUIDE"]、不带 LEADER)会被拒绝,且零写入。

改前这类请求返回 200,但只删掉并重建了被声明的那半边,位内其余人员原样留在库里, 还会继续扇到每一张子订单——用户在回显里看见「领队怎么还在」。

✅ 对既有错误码零影响:改前已被 582114 / 582115 拒掉的请求,错误码一字不变。 本单只改变改前返回 200 的那些请求的结论。


一、背景

scopeRoles 由 #8006 引入,语义是「本次保存只覆盖这些角色」。它同时承担两件事:

  1. 插入白名单——提交的人只能落在声明的角色里(越界抛 582115);
  2. 删除窗口——软删只删声明的那些角色的旧行。

此前只有 (1) 被守卫住,(2) 从没有任何一处校验过。于是「声明了半个配置位」这种请求, 插入侧完全合法(提交的人确实都在范围内),删除侧却只擦掉了半边:

  • 导游位原有 1 名领队(LEADER)+ 1 名导游(GUIDE);
  • 用户在弹窗里删掉领队、只留导游,前端发 scopeRoles=["GUIDE"];
  • 服务端只删 GUIDE 行、插新 GUIDE 行,那条 LEADER 一行没动;
  • 接口返回 200,无任何错误码;该 LEADER 经异步扇出继续写进每一张子订单。

⚠️ #8006 的交接件里已写过「导游位必须同时传 GUIDE 与 LEADER」,但那当时只是对前端的请求, 服务端没有对应守卫——把这句话从文档里整句删掉,代码行为一字不变、没有一个用例会红。 本单把它变成服务端强制的不变量。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 保存团期 staff 配置 PUT /v3/admin/group-batch/{productBatchId}/staff 新增拒绝分支 新增业务码 582116;入参结构、出参结构、字段类型全部未变

入参、出参、字段类型全部未变。 本单只新增一条校验与一个业务错误码。


三、接口详情

1. 保存团期 staff 配置(含扇出) PUT /v3/admin/group-batch/{productBatchId}/staff

VO: BatchStaffConfigReqVO → BatchStaffConfigRespVO(两者结构均未变,见 21_8006 交接件)

使用场景

团期详情页「导游位」「摄影位」两个弹窗各自维护本位人员时调用。每个弹窗用 scopeRoles 声明自己负责的角色范围,只提交本位人员,互不影响(#8006)。 本单新增的约束落在这个场景的正中间:弹窗声明的范围必须是完整的配置位, 否则本位里没被声明的那些人会被静默留在库里。

入参

路径参数

字段 类型 必填 说明
productBatchId Long(路径) 是 产品侧班期 ID

请求体 BatchStaffConfigReqVO

字段 位置 类型 必填 约束 说明
scopeRoles body String[] 否 元素取值域 LEADER|GUIDE|DRIVER|PHOTOGRAPHER|OTHER|GUIDE_ASSISTANT|STUDY_TEACHER|LIFE_TEACHER;传了就不能是空数组(@Size(min=1),否则 code=400) 本次覆盖的角色范围。不传 = 整期全量覆盖(历史行为)。🔴 本单新增:传了则必须整位覆盖,否则 582116
staffList body Item[] 是 @NotNull;显式传 [] = 清空覆盖范围 覆盖范围内的最终状态。缺字段一律 code=400(审计 F-03,Refs #6950 / #7377:改前缺字段会被当成"传空=清空",静音软删整团配置还返成功)
staffList[].staffId body Long 是 @NotNull 用户域员工 ID
staffList[].staffRole body String 是 @NotBlank + 同上取值域 员工角色
staffList[].sortOrder body Integer 否 — 展示排序,默认 0
staffList[].remark body String 否 @Size(max=500) 备注

出参

BatchStaffConfigRespVO

字段 类型 说明
productBatchId String 回显路径参数(@JsonSerialize(ToStringSerializer),JSON 里是字符串),恒非 null
groupBatchId String 运营团期 ID(同上,字符串)。本端点入口有成团守卫,200 响应中恒非 null
staffList BatchStaffItemVO[] 保存后的配置快照
staffList[].id Number 记录 ID(batch_staff_id)
staffList[].staffId Number 用户域员工 ID
staffList[].staffRole / staffRoleName String 角色码 / 中文名(取值不在枚举内时回落原 code)
staffList[].staffName / staffPhone / avatarUrl String 姓名 / 手机(脱敏前 3 后 4)/ 头像,均为 Feign 反查后冻结的快照
staffList[].sortOrder Integer 展示排序
staffList[].remark String 备注
staffList[].reporterRank / reporterRankName String 报账人等级 PRIMARY/SECONDARY/NONE 及中文名,恒非 null(空值归一为 NONE)
affectedOrderCount int 扇出影响的活跃订单数

🔴 本单对入参、出参的字段集合与类型零改动,上两表与改前逐字一致,列在此处只为本文件自包含。

请求示例

PUT /v3/admin/group-batch/80001/staff
Content-Type: application/json
{
  "scopeRoles": ["GUIDE", "LEADER"],
  "staffList": [
    { "staffId": 40001, "staffRole": "GUIDE", "sortOrder": 0, "remark": "首席导游" }
  ]
}

响应示例

{
  "code": 0,
  "msg": "",
  "data": {
    "productBatchId": "80001",
    "groupBatchId": "90211",
    "staffList": [
      {
        "id": 770001,
        "staffId": 40001,
        "staffRole": "GUIDE",
        "staffRoleName": "导游",
        "staffName": "刘导游",
        "staffPhone": "138****6677",
        "avatarUrl": null,
        "sortOrder": 0,
        "remark": "首席导游",
        "reporterRank": "NONE",
        "reporterRankName": "非报账人"
      }
    ],
    "affectedOrderCount": 3
  }
}

空数据 / 降级响应

清空某个配置位(整位声明 + 空 staffList)后,staffList 返回的是整期最终状态 (不是本次提交的空数组)——范围外角色的既有行仍在其中。整期一个人都没有时才是空数组:

{
  "code": 0,
  "msg": "",
  "data": {
    "productBatchId": "80001",
    "groupBatchId": "90211",
    "staffList": [],
    "affectedOrderCount": 3
  }
}

字典降级:配置位成员读字典(Feign hl-system 字典)。字典读空或 Feign 失败时, 服务端回落到内置兜底成员(导游位 GUIDE+LEADER、摄影位 PHOTOGRAPHER), 不会沿用脏缓存、也不会因此放行半位声明 ⇒ 降级期间 582116 的判定按兜底成员进行。

错误响应

业务码 常量 文案({0}/{1} 运行期填充)
582116 AssignmentErrorCode.SCOPE_ROLES_SLOT_INCOMPLETE 本次保存声明的角色范围({0})只覆盖了配置位的一部分,还缺少 {1};同一配置位的角色必须一起声明,否则位内其余人员会被留在库里
  • {0} = 本次请求的 scopeRoles 原样内容,以 / 连接(例:GUIDE)
  • {1} = 缺失的同位角色,以 / 连接(例:LEADER)

⇒ 文案本身就把「该补什么」告诉了调用方,前端可直接透传 message 给用户。

被拒响应体(🔴 HTTP 状态行是 200,业务失败写在 code 里,别按状态码判成败)

{
  "code": 582116,
  "msg": "本次保存声明的角色范围(GUIDE)只覆盖了配置位的一部分,还缺少 LEADER;同一配置位的角色必须一起声明,否则位内其余人员会被留在库里",
  "data": null
}

其余错误码本单未新增也未改动:

业务码 触发
400 @Valid 参数级校验失败(staffList 缺字段、scopeRoles: []、角色不在取值域)。同样是 HTTP 200,msg 为各字段 message 用 "; " 拼接
582114 所选人员的角色与其人员类型不符
582115 提交的人员角色超出本次声明的 scopeRoles(与 582116 的分工见第四节)

业务边界

判定规则——对每一个配置位分别判定:

touched  = scopeRoles 里至少有一个元素属于该配置位
missing  = 该配置位的成员角色中,scopeRoles 没有声明的那些
若 touched 且 missing 非空 ⇒ 抛 582116

配置位当前有两个:

配置位 字典类型 成员角色(字典读不到时的兜底值)
导游位 GUIDE group_batch_staff_slot_guide GUIDE、LEADER
摄影位 PHOTOGRAPHER group_batch_staff_slot_photographer PHOTOGRAPHER

🔴 成员角色由字典决定,不是代码常量。 表中列的是「字典读不到时的兜底值」, 运行期真实成员以字典 dict_type 下的行为准——业务在字典管理页面往导游位加一个角色, 第二天起 scopeRoles 就必须带上它,服务端不需要改代码,这个过程不产生任何接口变更。 ⇒ 前端不应把成员角色写死在代码里,应按配置位动态取用; 写死 ["GUIDE","LEADER"] 的实现会在业务改字典的那一刻开始收到 582116。

其余边界:

  • scopeRoles 整个字段不传 = 整期全量覆盖,本守卫不参与判定,历史行为逐字不变。
  • 零写入:582116 在任何库写动作之前抛出(守卫排在写入序列的 1.6 位),被拒的请求不产生 任何插入 / 更新 / 软删,也不触发向订单的扇出,affectedOrderCount 不会被消耗。
  • 不属于任何配置位的角色(DRIVER、OTHER、STUDY_TEACHER、LIFE_TEACHER): 对两个配置位的 touched 都恒为 false,单独声明它们不会被本守卫拦下。
  • 字典判定带 5 分钟进程内缓存,服务多实例:改完字典后最多 5 分钟内, 两个实例对同一个 scopeRoles 可能给出不同判定(一个放行一个 582116)。重试即可收敛。
  • scopeRoles: [null] 当前绕得过本守卫(元素级 @Pattern 对 null 恒真),已立 #8150; 前端不要构造含 null 的数组——它绕过的是保护,不是限制。

四、契约约束与正确调用方式

# payload(节选) 结果 说明
✅ {"scopeRoles": ["GUIDE","LEADER"], "staffList": [...]} 200 导游位整位声明,本单的正确写法
✅ {"scopeRoles": ["GUIDE","LEADER"], "staffList": []} 200 清空导游位:整位声明 + 空人员表,位内两个角色的旧行一起被删
✅ {"scopeRoles": ["PHOTOGRAPHER"], "staffList": [...]} 200 摄影位只有一个成员,单元素即整位
✅ {"staffList": [...]}(不传 scopeRoles) 200 整期全量覆盖,守卫整体短路,行为与改前逐字一致
✅ {"scopeRoles": ["DRIVER"], "staffList": [...]} 200 DRIVER 不属于任何配置位 ⇒ 一个位都没 touched ⇒ 守卫不介入
❌ {"scopeRoles": ["GUIDE"], "staffList": [...]} 582116 触到导游位却缺 LEADER
❌ {"scopeRoles": ["LEADER"], "staffList": []} 582116 同上,缺 GUIDE;注意 staffList 为空也照样拒(见下)
❌ {"scopeRoles": ["GUIDE","PHOTOGRAPHER"], "staffList": [...]} 582116 跨两个位,导游位缺 LEADER

🔴 staffList 为空不豁免。 「清空导游位」正是本单最需要拦住的路径: 它一个越界的人都没有(582115 永远不会触发),却会把位内没声明的那些人永久留在库里。 清空某个位的正确写法是「整位声明 + 空 staffList」,见上表第 2 行。

与 582115 的分工(两码不可互换)

582115 582116
说的是 你提交的人越界了 你声明的范围本身不是一个完整的配置位
改法 改 staffList,或扩 scopeRoles 只有一条:把缺的同位角色补进 scopeRoles
staffList 可否为空 否(没有人就没有越界的人) 是

⇒ 前端两个错误码要分开处理:582115 引导用户去改人员勾选,582116 是前端自己的 payload 拼错了。

⚠️ hl-ui 现有注释把本场景的错误码写错了

src/api/orderV2GroupBatch.js:291(读取对象 origin/v2.1 17eb03ec,2026-09-22 06:27 提交)写的是:

导游位并收 GUIDE+LEADER,保存导游位 scopeRoles 必须两个都传,缺一 582115。

这个错误码不成立,两个方向都不成立:

  • 本单上线之前:缺一同位角色的请求返回 200 且无任何错误码,位内没声明的那些人被静默留在库里, 还会经扇出写进每一张子订单——这正是本单要修的缺陷,不存在任何拒绝。
  • 本单上线之后:缺一被拒,错误码是 582116,不是 582115。

⇒ 按 582115 写的错误处理分支在「缺一同位角色」这条路径上从来不会命中; 该注释相邻的另一句「582115 = staffList 角色超出 scopeRoles」(GroupBatchStaffConfigModal.vue:100)是对的, 要改的只有 orderV2GroupBatch.js:291 这一句。


五、数据库行为

场景 group_batch_staff 子订单 order_batch_staff
命中 582116 零写入 零写入
通过 与改前一致(软删声明范围内旧行 + 插入新行) 与改前一致(afterCommit 异步扇出)

🔴 「零写入」指的是软删从没有执行过,不是「执行了又回滚」。 守卫落在事务开启之前,走不到软删那一行。真库 IT 用 openSession(true)(autoCommit) 从另一条连接读取,若软删跑过就会当场提交、读不到「原样」——该断言对两种情形有分辨力。


六、边界行为

  1. 不传 / 传空数组 scopeRoles ⇒ 守卫整体短路,整期全量覆盖语义与改前逐字一致。
  2. scopeRoles 全是不属于任何配置位的角色(如 ["DRIVER"])⇒ 一个位都没 touched,放行。
  3. 守卫顺序:582114(人员类型不符)→ 582115(提交的人越界)→ 582116(范围半位)→ 事务。 ⇒ 一个请求若同时满足 582115 与 582116 的条件,返回的是 582115(先到先抛)。
  4. 🔴 配置位成员有 5 分钟本地缓存,且服务是双实例。 GroupBatchStaffSlotResolver 对字典结果做 5 分钟 TTL 的进程内缓存,测试服与生产的 order-v3 均为多实例 + 网关轮询。⇒ 业务刚改完字典的 5 分钟内,同一个 payload 可能在一个实例上通过、 在另一个实例上撞 582116。 这个窗口过后两边一致。前端遇到这种抖动不必特殊处理, 按 {1} 提示补齐 scopeRoles 即可——补齐后的 payload 在新旧两种口径下都是合法的。
  5. scopeRoles 元素为 null(["GUIDE", null] 这类):元素级校验目前对 null 恒真, 该元素不会触及任何配置位。若整个数组只有 null,请求会成为一次返回 200 的空操作。 属存量缺口,已立 #8150 跟进。正常调用方不会构造这种 payload。

六.5、枚举 / 数据字典

字典类型 含义 维护方
group_batch_staff_slot_guide 导游位收哪些人员类型 业务在字典管理页面维护
group_batch_staff_slot_photographer 摄影位收哪些人员类型 同上

服务端对字典值做白名单过滤(合法集合:GUIDE、GUIDE_ASSISTANT、PHOTOGRAPHER、LEADER、 OTHER、STUDY_TEACHER、LIFE_TEACHER),字典里的非法值会被丢弃;字典读空或 Feign 失败时 回落到上表的兜底值,不会沿用脏缓存。

六.6、修改前后对比

行为级

请求形态 改前 改后
scopeRoles 半位声明,staffList 非空 200,位内未声明的旧行残留并扇到子订单 582116,零写入
scopeRoles 半位声明,staffList 为空 200,位内未声明的旧行残留 582116,零写入
scopeRoles 整位声明 200 200(不变)
不传 scopeRoles 200,整期全量覆盖 200(不变)
命中 582114 / 582115 的请求 对应错误码 对应错误码不变

字段级:无变化(无新增/删除/改名字段,无类型变化)。

六.7、影响评估

🔴 任何当前发送「半个配置位」的调用方,都会从 200 变成 582116。 这是本单的预期效果, 但它是一次破坏性收紧,请前端按自己分支的实际实现逐处核对。

已做的评估(源码读数,非联调实测):mmg 在 #8006 交付的实现 (hl-ui origin/v2.1 @ b5666b38,GroupBatchStaffConfigModal)按配置位取 scopeRoles = roleMeta.memberRoles,导游位取到的是 GUIDE + LEADER 双角色 ⇒ 本身已是整位覆盖,不会命中 582116。该结论的效力止于那个 sha 的源码, 若前端另有分支或后续改动,请以自己的实现为准。


七、不影响范围

  • 团期 staff 查询接口 GET /v3/admin/group-batch/{productBatchId}/staff:零改动。
  • 单订单 staff 接口 /v3/admin/order/{id}/staff:零改动,不走本守卫。
  • 司机(DRIVER):由车务派车投影产生,不在任何配置位里,本守卫对它零影响。
  • 异步扇出链路、guide_ready / photographer_ready 回填、四 ready 闸门:逻辑零改动 (通过的请求扇出行为与改前完全一致)。
  • 网关路由、Nacos 配置、数据库表结构:零改动。

八、测试环境已验证

真库集成测试(Testcontainers MySQL 8.0.33,非 H2、非 Mockito) GroupBatchStaffSaveConfigSlotCompletenessMysqlTest:8/8 通过

验证项 做法与读数
缺陷确实存在 临时注释掉守卫后跑,团期表与子订单表都残留 LEADER 行;前置由真实 saveConfig + afterCommit() 产生,非 SQL 直插
零写入 SELECT * 整行逐字段比对(含 deleted_at、update_time)均与保存前相同;用 openSession(true) 从另一条连接读,对「回滚」与「从没发生」有分辨力
字典驱动确实生效 把 slotResolver.typesOf(slot) 变异成写死的兜底表,重编译确认后 7 个用例里唯一转红的正是「字典新增 GUIDE_ASSISTANT 后旧 scopeRoles 被拒」那条(变异已还原)
阴性对照 ×4 整位声明放行 / 补齐字典新成员后放行 / ["DRIVER"] 不触位放行 / 不传 scopeRoles 保持全量覆盖语义

回归:GroupBatchStaffConfigServiceTest* 77/77、Baseline 3/3、Scoped 2/2、 AdminControllerPermissionTest 6/6、ReqVOValidationTest 6/6、ConfigServiceGateTest 5/5、 RedLineArchTest 12/12。

测试服部署:双实例 8086 / 8186 运行 ef7611138(即 PR #8147 的合并提交本身), Nacos 两实例 healthy:true,启动日志无异常。判据与覆盖边界见 frontmatter 的 status_note。


九、已知缺口(不在本单范围,已立单跟进)

  • #8148:allowedStaffTypesForRole 写死 GUIDE/LEADER/PHOTOGRAPHER 三个分支、不读字典(存量)。 本单提升了它的影响:业务往导游位字典加 GUIDE_ASSISTANT 后,scopeRoles 必须带上它(否则 582116), 而真正 staffType=GUIDE_ASSISTANT 的人提交时仍会被 582114 挡死。 🔴 ⇒ 现阶段「字典加一个角色」只启用了范围判定这一半,那类人员还配不进去。 前端若正在规划「新增人员类型」相关交互,这条是必须知道的边界。
  • #8150:scopeRoles 元素级校验对 null 恒真(存量,见「六、5」)。

十、相关文档

  • 22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md —— scopeRoles 入参的引入者,入参/出参/响应结构以它为准
  • changelogs-v2/2026-09/ 下 #7079 相关条目 —— 配置位字典化

关联 / 联系人

  • Issue: #8122 | PR: #8147 | 后续单: #8148、#8150
  • 后端: wx | 管理后台前端: mmg