23 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 | 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 引入,语义是「本次保存只覆盖这些角色」。它同时承担两件事:
- 插入白名单——提交的人只能落在声明的角色里(越界抛 582115);
- 删除窗口——软删只删声明的那些角色的旧行。
此前只有 (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)
从另一条连接读取,若软删跑过就会当场提交、读不到「原样」——该断言对两种情形有分辨力。
六、边界行为
- 不传 / 传空数组
scopeRoles⇒ 守卫整体短路,整期全量覆盖语义与改前逐字一致。 scopeRoles全是不属于任何配置位的角色(如["DRIVER"])⇒ 一个位都没 touched,放行。- 守卫顺序:582114(人员类型不符)→ 582115(提交的人越界)→ 582116(范围半位)→ 事务。 ⇒ 一个请求若同时满足 582115 与 582116 的条件,返回的是 582115(先到先抛)。
- 🔴 配置位成员有 5 分钟本地缓存,且服务是双实例。
GroupBatchStaffSlotResolver对字典结果做 5 分钟 TTL 的进程内缓存,测试服与生产的 order-v3 均为多实例 + 网关轮询。⇒ 业务刚改完字典的 5 分钟内,同一个 payload 可能在一个实例上通过、 在另一个实例上撞 582116。 这个窗口过后两边一致。前端遇到这种抖动不必特殊处理, 按{1}提示补齐scopeRoles即可——补齐后的 payload 在新旧两种口径下都是合法的。 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