文件
hl-api-changelog/changelogs-v2/2026-09/22_8006_团期人员保存支持按角色范围覆盖-修改接口-管理后台.md
2026-09-22 05:48:00 +08:00

22 KiB

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 8006 团期 staff 保存新增可选入参 scopeRoles,支持按角色范围覆盖 admin wx(GIT) 修改接口 deployed not_required verified mmg 2e8850800bc0715c35d77a9496aab39128cf8e64 v2.1 2026-09-22 backend_status=deployed: hl-order-service-v3 测试服部署 sha=7811104b4,是本单合并提交 094521f0b(PR #8117)的后代,且区间内零提交触及 GroupBatchStaffConfigService(该核验由本工单此前 AC 完成,本会话直接引用,未重新验证)。gateway_status=not_required: git show 094521f0b --stat --name-only 核对,本单改动的 9 个文件全在 hl-order-service-v3 模块内,未涉及 hl-gateway 任何路由/Nacos 配置;PUT /v3/admin/group-batch/{productBatchId}/staff 是存量端点、路径本身未变,命中的是既有通配路由 order-service-v3(predicates: Path=/v3/admin/**,hl-gateway/src/main/resources/application.yml:223,自 #3264 起生效),本单没有引入任何新路径段。frontend_status=pending: mmg/hl-ui 的 origin/v2.1 分支(sha b5666b38,2026-09-22 03:37)src/api/orderV2GroupBatch.js:298 的 saveGroupBatchStaff(groupBatchId, staffList, scopeRoles, config) 已带该参数、GroupBatchStaffConfigModal.vue:256 与 __tests__/GroupBatchStaffConfigModal.spec.js:143 已对接并有断言——这是源码事实,不等于该分支已合并进前端主干或已部署到某个可联调环境;按规范后端不代填 implemented/released/verified,是否完成需前端自行回写 frontend_owner 与 verified_at。 前端回写(2026-09-22, mmg): 正式交接件与 21_8006 已交付实现逐点一致——saveGroupBatchStaff 第三参 scopeRoles 仅非空下发防 400;GroupBatchStaffConfigModal 按配置位 scopeRoles=roleMeta.memberRoles(GUIDE 位含 GUIDE+LEADER 双角色),只提交本位行;582115 拦截链透 message;交付 commit 2e885080,弹窗 6 例+api 1 例全过。零增量代码。 2026-09-22 dev-v3

order-v3: 团期 staff 保存新增可选入参 scopeRoles,支持按角色范围覆盖

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

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


⚠️ 关键变化

PUT /v3/admin/group-batch/{productBatchId}/staff 新增可选请求体字段 scopeRoles(角色范围数组)。不传时行为与改前逐字一致(整期全量覆盖:请求体 staffList 即为该团期的最终名单,未包含的人员会被移除)。

这不是一个纯粹的新增字段——它同时改变了写入行为的语义边界:团期 staff 实际按配置位分两个弹窗维护(导游位 = GUIDE + LEADER、摄影位 = PHOTOGRAPHER)。改前只有"整期全量覆盖"一种写法,任一侧保存时若没有把另一侧的既有行原样带回来,另一侧的人就会被静默清空。传入 scopeRoles 后,保存只在声明的角色范围内覆盖,范围外的既有行一行不动。


一、背景

order_batch_staff 表原先的保存语义是"整期全删再全插":每次 PUT 都把该团期名下所有角色的既有行软删,再按请求体重建。团期 staff 页面按配置位分两个弹窗各自维护(导游位 / 摄影位),这就要求任一侧保存时都必须把另一侧的既有行原样带回请求体——漏带一次,另一侧的人就被清空且没有任何提示(工单 #8006 描述的缺陷)。

本单给保存接口加了一条可选的"范围覆盖"路径:传 scopeRoles 只软删并重建这些角色的行,其余角色的行不受影响;不传则保持改前的整期全删语义,零行为变化。


二、变更接口清单

# 接口 方法 路径 变更类型 说明
1 保存团期 staff 配置 PUT /v3/admin/group-batch/{productBatchId}/staff 请求体新增可选字段 新增 scopeRoles(角色范围覆盖),未传时行为不变

三、接口详情

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

VO: BatchStaffConfigReqVO → BatchStaffConfigRespVO

使用场景

团期详情页"导游位""摄影位"两个弹窗各自维护本位人员时调用。改前两个弹窗必须各自把对方弹窗的既有人员原样带回请求体,否则会被整期全删覆盖清空;本次新增 scopeRoles 后,每个弹窗可以只声明并提交自己负责的角色范围,互不影响。

入参

字段 位置 类型 必填 约束 说明
productBatchId Path Long ✅ 雪花 ID 产品侧排期 ID(group_tour_batch.batch_id,非运营团期主键)
scopeRoles Body List<String> ❌(新增) 传了不能是空数组(空数组 400);元素取值域同 staffList[].staffRole 本次保存覆盖的角色范围。不传 = 整期全量覆盖(历史行为,团期内所有角色的既有配置先全删再按 staffList 重建);传了则只在这些角色内覆盖,范围外角色的既有行一行不动。导游位必须同时传 GUIDE 与 LEADER——导游位并收这两个角色,只传其中一个而选了另一个会被拒(582115)。staffList 里出现范围外角色一律拒绝且零写入(582115)
staffList Body List<Item> ✅ 缺失 400;显式传 [] 即清空覆盖范围内的配置 覆盖范围内的最终状态。不传 scopeRoles 时 [] = 清空整期;传了 scopeRoles 时 [] = 只清空这些角色
staffList[].staffId Body Long ✅ - 用户域员工 ID
staffList[].staffRole Body String ✅ 取值域:LEADER/GUIDE/DRIVER/PHOTOGRAPHER/OTHER/GUIDE_ASSISTANT/STUDY_TEACHER/LIFE_TEACHER 员工角色。若传了 scopeRoles,本字段取值必须落在 scopeRoles 声明的范围内
staffList[].sortOrder Body Integer ❌ 默认 0 展示排序
staffList[].remark Body String ❌ ≤500 字符 备注

出参 Result<BatchStaffConfigRespVO>

字段 类型 说明
data.productBatchId String 产品侧排期 ID(回显路径参数,雪花号以字符串返回)
data.groupBatchId String 运营团期 ID(order_group_batch 主键,由 productBatchId 反查),本端点 200 响应中恒非 null
data.staffList Array 保存后的整期最终状态(不是本次提交的子集)。不传 scopeRoles 时它就是本次提交的名单;传了 scopeRoles 时它是覆盖后的整期全量——范围保存的调用方一次拿到完整名单,无需再补一次 GET
data.staffList[].id String 记录 ID(雪花号,字符串返回)
data.staffList[].staffId String 用户域员工 ID
data.staffList[].staffRole String 员工角色(导游位落库时会按人员真实类型在 GUIDE/LEADER 之间归一,见四节)
data.staffList[].staffRoleName String 员工角色中文名
data.staffList[].staffName String 员工姓名(快照)
data.staffList[].staffPhone String 员工手机(脱敏,前 3 后 4)
data.staffList[].avatarUrl String 头像 URL(快照,可为 null)
data.staffList[].sortOrder Integer 展示排序
data.staffList[].remark String 备注
data.staffList[].reporterRank String 报账人等级:PRIMARY/SECONDARY/NONE,历史空值归一为 NONE,恒非 null
data.staffList[].reporterRankName String 报账人等级中文名
data.affectedOrderCount Integer 扇出影响的活跃订单数

请求示例

{
  "scopeRoles": ["PHOTOGRAPHER"],
  "staffList": [
    { "staffId": 40002, "staffRole": "PHOTOGRAPHER", "sortOrder": 2, "remark": "新摄影备注" }
  ]
}

字段值取自本单已合并的真库集成测试 GroupBatchStaffSaveConfigScopedMysqlTest#scopedToPhotographer_keepsGuideAndDriverRowsAlive(该团期夹具此前已有 staffId=1(LEADER)、staffId=4(DRIVER) 两行存量配置)。

响应示例

按上述请求提交后,该团期同时存在的存量导游位(LEADER)、司机行与本次提交的摄影位行全部回显在 staffList(真库断言:aliveBatchRows(BATCH) 返回 ["1:LEADER","4:DRIVER","40002:PHOTOGRAPHER"],affectedOrderCount 取自夹具里挂在该团期下的活跃订单数=1):

{
  "code": 200,
  "message": "成功",
  "success": true,
  "data": {
    "productBatchId": "8006900",
    "groupBatchId": "80106",
    "staffList": [
      { "id": "9001", "staffId": "1", "staffRole": "LEADER", "staffRoleName": "领队", "staffName": "存量领队", "staffPhone": "138****0001", "avatarUrl": null, "sortOrder": 1, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" },
      { "id": "770002", "staffId": "40002", "staffRole": "PHOTOGRAPHER", "staffRoleName": "摄影", "staffName": "新摄影", "staffPhone": "139****0002", "avatarUrl": "https://cdn.example/avatar/40002.png", "sortOrder": 2, "remark": "新摄影备注", "reporterRank": "NONE", "reporterRankName": "非报账人" },
      { "id": "9003", "staffId": "4", "staffRole": "DRIVER", "staffRoleName": "司机", "staffName": "存量司机", "staffPhone": "138****0004", "avatarUrl": null, "sortOrder": 3, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" }
    ],
    "affectedOrderCount": 1
  }
}

⚠️ 本例按 BatchStaffConfigRespVO 字段映射规则重新组装:id="9001"/"9003" 与除 avatarUrl/remark 外的其余字段取自该 IT 真实夹具与断言,staffPhone 按 SensitiveDataUtil.maskPhone(前 3 后 4)逐字计算;新增行的 id="770002" 是服务端写入时生成的雪花号,该 IT 只断言了 staffId:staffRole 组合、未断言具体 ID 值,本例按字段真实格式(字符串化雪花号)给出示意值,不代表某次具体运行的原始输出。整份响应信封本身未经网关实测捕获(本会话未做该端点的真实网关调用,详见八节)。

空数据 / 降级响应

scopeRoles 传了但 staffList 传空数组 []:清空该角色范围内的配置,范围外行不受影响。

{
  "code": 200,
  "message": "成功",
  "success": true,
  "data": {
    "productBatchId": "8006900",
    "groupBatchId": "80106",
    "staffList": [
      { "id": "9001", "staffId": "1", "staffRole": "LEADER", "staffRoleName": "领队", "staffName": "存量领队", "staffPhone": "138****0001", "avatarUrl": null, "sortOrder": 1, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" },
      { "id": "9003", "staffId": "4", "staffRole": "DRIVER", "staffRoleName": "司机", "staffName": "存量司机", "staffPhone": "138****0004", "avatarUrl": null, "sortOrder": 3, "remark": null, "reporterRank": "NONE", "reporterRankName": "非报账人" }
    ],
    "affectedOrderCount": 1
  }
}

错误响应

scopeRoles=["GUIDE"],但 staffList 里提交了一名真实类型为 LEADER 的人员(导游位漏传 LEADER):

{
  "code": 582115,
  "message": "提交的人员角色(LEADER)超出本次保存声明的角色范围(GUIDE),请检查配置位与人员是否匹配",
  "success": false,
  "data": null
}

scopeRoles 传空数组 []:

{
  "code": 400,
  "message": "scopeRoles 传了就不能是空数组;要整期全量覆盖请整个字段不传",
  "success": false,
  "data": null
}

业务边界

  • 权限:网关 /v3/admin/** 统一鉴权;本端点额外要求 GroupBatchPermissionGuard.PERMISSION_MANAGE(#7455),无权限一律拒绝且零写入
  • 不传 scopeRoles:行为与改前逐字一致,整期全量覆盖,现有前端代码零改动即可继续工作
  • 传 scopeRoles 但为空数组:400(@Size(min=1) 校验),清空语义只能由 staffList=[] 表达,不能用空 scopeRoles 表达
  • 导游位并收 GUIDE + LEADER:scopeRoles 只传其中一个、staffList 里出现了另一个,一律 582115 且零写入(连软删都不执行)
  • staffList 里出现范围外角色:一律 582115,零写入(两道守卫:一道按请求声明的角色查,另一道按落库后解析出的真实角色再查一遍——导游位人员的 staffRole 会按其在资源域的真实类型在 GUIDE/LEADER 之间归一,只查第一道会漏掉这条)
  • 团期未成团:589552(已成团但未建团 589553);两个错误码在本次改动前就存在,未受影响
  • staffId 重复:589582,整批不写库(改前既有校验,未受影响)

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

✅ 正确 / ❌ 错误 payload 对照

场景 payload
✅ 不传 scopeRoles,整期全量覆盖(历史行为) { "staffList": [...] }
✅ 只覆盖摄影位 { "scopeRoles": ["PHOTOGRAPHER"], "staffList": [{ "staffId": 40002, "staffRole": "PHOTOGRAPHER" }] }
✅ 只覆盖导游位(必须两个角色一起声明) { "scopeRoles": ["GUIDE", "LEADER"], "staffList": [{ "staffId": 1, "staffRole": "LEADER" }] }
✅ 清空摄影位(不动导游位/司机) { "scopeRoles": ["PHOTOGRAPHER"], "staffList": [] }
❌ 导游位只传 GUIDE,人员却是 LEADER { "scopeRoles": ["GUIDE"], "staffList": [{ "staffId": 1, "staffRole": "LEADER" }] } → 582115
❌ scopeRoles 传空数组 { "scopeRoles": [], "staffList": [] } → 400
❌ staffList 缺失 { "scopeRoles": ["PHOTOGRAPHER"] } → 400

切换到范围保存时的必要动作

若前端按配置位拆分弹窗各自保存,每个弹窗提交时都必须传 scopeRoles,且导游位弹窗必须同时把 GUIDE 与 LEADER 放进 scopeRoles(哪怕本次提交的人员只有其中一种真实类型)——否则另一种类型的存量人员会在下一次同角色保存时找不到删除窗口,变成无法再被删除的幽灵行。两个弹窗各自只传自己的 scopeRoles,不需要互相携带对方的既有名单。


五、数据库行为

场景 数据库表 操作
不传 scopeRoles(整期全量覆盖) order_batch_staff 软删该团期全部角色的旧行,再批量 INSERT 新行(与改前逐字一致)
传 scopeRoles(范围覆盖) order_batch_staff 只软删 scopeRoles 命中角色的旧行,范围外角色的旧行不执行任何 UPDATE/DELETE
保存成功后 order_staff_assignment 异步扇出:对团内每个活跃订单,软删该订单 source=GROUP_BATCH 的旧行、按保存后的整期最终状态重新插入(范围保存时扇出的不是本次提交的子集,而是覆盖后的整期全量,避免把范围内的清空误传播为整期清空)
校验失败(582115 / 400 等) - 零写入:不软删、不 INSERT、不触发扇出

六、边界行为

  • 未登录 → 401(网关拦截)
  • 无 PERMISSION_MANAGE 权限 → 拒绝,零写入
  • 团期未建团 → 589553;已建团未成团 → 589552(本次改动前已有校验,未变)
  • staffId 在请求体内重复 → 589582,整批零写入(本次改动前已有校验,未变)
  • 人员角色与资源域真实类型不符(如把领队配成摄影)→ 582114(本次改动前已有校验,未变)
  • 提交角色超出 scopeRoles 声明范围 → 582115,零写入(本次新增)
  • 老数据兼容:历史保存请求(不含 scopeRoles 字段)解析后 scopeRoles=null,服务端按整期全量覆盖处理,与改前行为逐字一致

六.5、枚举 / 数据字典

scopeRoles / staffList[].staffRole(SettlementStaffRoleEnum)

所属字段: BatchStaffConfigReqVO.scopeRoles、BatchStaffConfigReqVO.staffList[].staffRole | 类型: String / List<String>

值 中文 说明
LEADER 领队 导游位成员之一
GUIDE 导游 导游位成员之一。落库时若该人员资源域真实类型为 LEADER,会归一存成 LEADER
DRIVER 司机 由车务派车投影产生,一般不由本接口写入;不属于任何配置位,不受 scopeRoles 范围校验约束
PHOTOGRAPHER 摄影 摄影位唯一成员
OTHER 其他 兜底位;不属于任何配置位,不受 scopeRoles 范围校验约束
GUIDE_ASSISTANT 导游助理 不属于导游位/摄影位任一配置位
STUDY_TEACHER 研学老师 不属于导游位/摄影位任一配置位
LIFE_TEACHER 生活老师 不属于导游位/摄影位任一配置位

六.6、修改前后对比

字段级对比

字段 改前 改后
staffList(请求体) 唯一入参,传入即为整期最终状态 不变
scopeRoles(请求体) 不存在 新增可选字段;不传行为与改前逐字一致

行为级对比

行为 改前 改后
保存范围 恒为整期全量覆盖 不传 scopeRoles=整期全量覆盖(不变);传了 scopeRoles=只覆盖声明的角色
跨配置位清空风险 任一弹窗保存漏带另一侧既有行 → 另一侧被静默清空 各弹窗传各自的 scopeRoles 即可互不影响,不再需要互相携带对方名单
提交角色超出范围时 无此校验(scopeRoles 不存在) 582115,零写入
扇出快照 用的是本次提交的 toInsert 不传 scopeRoles 时二者相同;传了 scopeRoles 时改为用保存后的整期最终状态扇出

六.7、影响评估

  • 是否破坏向后兼容:否。scopeRoles 是纯新增可选字段,不传时请求/响应结构与既有行为逐字不变,历史前端代码零改动即可继续工作。
  • 前端是否必须同步上线:否(不采用范围保存可以继续用旧写法);但若要修复"两个弹窗互相清空对方"的问题,前端必须改为按配置位分别传 scopeRoles,且导游位弹窗必须把 GUIDE 与 LEADER 一起传入。
  • 前端 workaround 清理点:若前端此前用"保存前先 GET 回另一侧既有名单、拼进本次 staffList 一起提交"的方式规避跨配置位清空问题,改用 scopeRoles 后可以去掉这个 workaround,弹窗只需提交本位人员。

七、不影响范围

  • 仅影响:PUT /v3/admin/group-batch/{productBatchId}/staff 一个端点的请求体解析与保存范围
  • 零影响:
    • GET /v3/admin/group-batch/{productBatchId}/staff(查询团期 staff 配置列表)请求/响应结构
    • GET /v3/admin/group-batch/{productBatchId}/staff/candidates、.../staff/candidates/page(候选列表/候选分页)
    • PUT /v3/admin/group-batch/{productBatchId}/staff/{staffId}/reporter-rank(设置报账人等级)
    • 订单侧 staff 增删改接口(source=ORDER 的行本次改动前后均不受团期保存的删除窗口影响)
    • 团期成团判定、四 ready 闸门的判定条件本身(仅回填时改用整期最终状态计算,判定逻辑未变)

八、测试环境已验证

后端部署:hl-order-service-v3 测试服部署 sha 7811104b4,是本单合并提交 094521f0b(PR #8117)的后代,且区间内零提交触及 GroupBatchStaffConfigService。该核验由本工单此前 AC 完成,本会话直接引用、未重新验证(如实披露)。

真库集成测试(GroupBatchStaffSaveConfigScopedMysqlTest / GroupBatchStaffSaveConfigBaselineMysqlTest,均为 PR #8131 新增,已合入 dev-v3;MySQL 8.0.33 Testcontainers + 真 MyBatis-Plus + 真 GroupBatchStaffConfigService Bean,非纯 Mockito):

  • scopedToPhotographer_keepsGuideAndDriverRowsAlive:同一份请求体只提交摄影位一人 + scopeRoles=["PHOTOGRAPHER"],导游位(LEADER)与司机的存量行在保存后仍然存活(deleted_at IS NULL),订单侧 order_staff_assignment 的 GROUP_BATCH 副本也保留了这两行;ready 回填按整期最终状态计算,触发 markGuideReady(因为导游位仍有人)。
  • 阳性对照(Baseline 类同一请求体、不传 scopeRoles):同样的存量导游位/司机行被整期全删,ready 回填触发的是 markGuideNotReady——两轮唯一差异是 scopeRoles 字段,终态却完全相反,证明这套断言组合对"范围覆盖是否生效"具备分辨力,不是恒真结论。
  • BatchStaffConfigReqVOValidationTest(Bean Validation 层,@Valid 触发路径同 Controller):scopeRoles 不传合法;传合法角色集合通过;传空数组 [] 被拒,违规信息命中 scopeRoles 字段、消息含"不能是空数组";传入取值域外的角色被拒,消息含"不在员工角色取值域内"。

受限说明:本会话本次未对该端点执行真实网关调用(无该操作所需的测试服管理员会话),三节的请求/响应示例按 BatchStaffConfigReqVO/BatchStaffConfigRespVO 字段映射规则、依据上述真库 IT 的夹具与断言重新组装,已在三节逐条标注哪些字段取自真实断言、哪些是格式示意值。


十、相关文档

关联 / 联系人

链接

联系人

  • 后端负责人: @wx