docs(changelog): #8122 团期 staff 保存 scopeRoles 必须整位覆盖(582116)
changelog-filename-gate / validate (push) Failing after 2s

PUT /v3/admin/group-batch/{productBatchId}/staff 行为收紧:
scopeRoles 触及某配置位即须覆盖该位全部成员角色,半位声明拒绝且零写入。
配置位成员由字典决定,不是代码常量。

Refs #8122, PR #8147, 后续单 #8148 / #8150

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-22 09:36:13 +08:00
共同撰写人 Claude Opus 5
父节点 785c6ed84a
当前提交 478a9c58a4
@@ -0,0 +1,409 @@
---
schema: "hl-changelog/v2"
ticket: "8122"
title: "团期 staff 保存:scopeRoles 必须整位覆盖,半位声明一律拒绝且零写入(新错误码 582116)"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: ""
target_release: "v2.1"
verified_at: "2026-09-22"
status_note: "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 的源码读数,请前端按自己分支的实际实现复核。"
updated_at: "2026-09-22"
base: "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 | 扇出影响的活跃订单数 |
🔴 本单对入参、出参的**字段集合与类型零改动**,上两表与改前逐字一致,列在此处只为本文件自包含。
#### 请求示例
```http
PUT /v3/admin/group-batch/80001/staff
Content-Type: application/json
```
```json
{
"scopeRoles": ["GUIDE", "LEADER"],
"staffList": [
{ "staffId": 40001, "staffRole": "GUIDE", "sortOrder": 0, "remark": "首席导游" }
]
}
```
#### 响应示例
```json
{
"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` 返回的是**整期最终状态**
(不是本次提交的空数组)——范围外角色的既有行仍在其中。整期一个人都没有时才是空数组:
```json
{
"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` 里,别按状态码判成败)
```json
{
"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 拼错了。
---
## 五、数据库行为
| 场景 | `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