docs(changelog): #8226 车型大类中文名空白校验收紧 + 空名大类不再被挤出字典白名单
changelog-filename-gate / validate (push) Failing after 2s

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
这个提交包含在:
jw
2026-09-23 14:35:35 +08:00
共同撰写人 Claude Opus 5.5
父节点 c494ccccdd
当前提交 dd72fd6a09
@@ -0,0 +1,348 @@
---
schema: "hl-changelog/v2"
ticket: "8226"
title: "车型大类中文名空白校验收紧(全角空格等 Unicode 空白改为拒绝)+ 空名大类不再被挤出车型字典白名单"
consumer: "admin"
author: "jw(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: "mmg"
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #8232 已合并 dev-v3(9231b8d8c),2026-09-23 14:30 滚动部署 TEST 双实例(检出 7a876ec57)。真实网关实测:新增/编辑车型大类 typeName 传全角空格、半角空格、空串、null 均返回 400「大类中文名不能为空」,库内零写入;读侧临时把 mpv 名称置空后,order-v3 车型白名单仍含 mpv(展示名回落为 mpv),验毕已原样还原。前端待办:车型大类表单的 typeName 必填校验需同样把全角空格视为空。"
updated_at: "2026-09-23"
base: "dev-v3"
---
# 车队车型管理: 车型大类中文名空白校验收紧 + 空名大类不再被挤出字典白名单
> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/`
>
> **服务**: hl-fleet-service
> **PR**: #8232
> **Issue**: #8226
> **日期**: 2026-09-23
> **影响范围**: 管理后台「车型管理」新增/编辑车型大类表单的 typeName 校验;order-v3 用车需求车型字典白名单(582032)的数据源
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- 本次变化:新增/编辑车型大类时,`typeName` 如果全部由**全角空格(U+3000)等 Unicode 空白**组成,现在返回 `400「大类中文名不能为空」`。
- 前端以前以为的:`typeName` 只要不是空串、不全是半角空格,后端就会接受。
- 实际新行为:以前后端用 `@NotBlank` 校验,它按 `String.trim()` 判空,只去掉半角空白,所以全角空格能写进库。可车队给 order-v3 的车型白名单按 `isBlank()` 判空名,会把这种名字当成空的、把整个大类从白名单里丢掉,结果这个大类下的用车需求被 `582032「不存在或已下线」` 误拒。现在两处同时收口:写侧拒绝这种名字;读侧即使库里出现空名,也不再把该大类挤出白名单。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 新增车型大类 | POST | `/admin/fleet/vehicle-types` | 入参校验收紧 | `typeName` 全为 Unicode 空白(含全角空格)改为 400 |
| 2 | 编辑车型大类 | PUT | `/admin/fleet/vehicle-types/{typeId}` | 入参校验收紧 | 同上 |
---
## 三、接口详情
### 1. 新增车型大类 `POST /admin/fleet/vehicle-types`
**VO**: `VehicleTypeSaveReqVO → VehicleTypeRespVO`
#### 使用场景
管理后台「车队 → 车型管理」新增一个车型大类(如 SUV系列 / 商务车)时调用。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| typeKey | Body | String | ✅ | 非空白,≤16,全局唯一(600101) | 大类标识 key,如 `suv` |
| typeName | Body | String | ✅ | **至少含一个非空白字符(空白按 Unicode 判定,全角空格也算空白,本次收紧)**,≤64 | 大类中文名 |
| icon | Body | String | ❌ | ≤64 | 图标组件名或 emoji |
| description | Body | String | ❌ | ≤256 | 大类描述 |
| sortOrder | Body | Integer | ✅ | 非 null | 排序权重,升序 |
#### 出参 `Result<VehicleTypeRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Long | 大类 ID |
| typeKey | String | 大类 key |
| typeName | String | 大类中文名 |
| icon | String | 图标 |
| description | String | 描述 |
| sortOrder | Integer | 排序权重 |
| seatOptions | Array\<Integer\> | 座位数选项,新建恒为 `[]` |
| modelCount | Integer | 型号数,新建恒为 0 |
| inUseCount | Integer | 在役车辆数,新建恒为 0 |
#### 请求示例
```json
{
"typeKey": "mpv",
"typeName": "商务车",
"icon": "",
"description": "",
"sortOrder": 2
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"id": "2064609312168083458",
"typeKey": "mpv",
"typeName": "商务车",
"icon": "",
"description": "",
"sortOrder": 2,
"seatOptions": [],
"modelCount": 0,
"inUseCount": 0
},
"success": true
}
```
#### 空数据 / 降级响应
写接口没有空数据场景,也没有降级分支;校验不通过时零写入。
```json
{ "code": 400, "message": "大类中文名不能为空", "data": null, "success": false }
```
#### 错误响应
`typeName` 为 null、空串、纯半角空格、纯全角空格,一律返回同一条错误,且只报一条:
```json
{ "code": 400, "message": "大类中文名不能为空", "data": null, "traceId": null, "success": false }
```
```json
{ "code": 600101, "message": "车型大类标识已存在", "data": null, "success": false }
```
#### 业务边界
- 鉴权:未登录 → 业务码 `401`(「缺少有效的 Authorization 头」)。
- 只拒绝**全部**由空白组成的名字;首尾带空格的正常名(如 `" 商务车 "`)照常放行,后端不做 trim。
- 以前只有全角空格这一类会被放行,现在也拒绝;null、空串、半角空格在改前就被拒,行为不变。
---
### 2. 编辑车型大类 `PUT /admin/fleet/vehicle-types/{typeId}`
**VO**: `VehicleTypeSaveReqVO → VehicleTypeRespVO`
#### 使用场景
管理后台「车型管理」编辑已有大类的名称、图标、描述或排序。`typeKey` 不可改,后端忽略入参中的 `typeKey`。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| typeId | Path | Long | ✅ | 大类须存在(600102) | 大类 ID |
| typeName | Body | String | ✅ | **至少含一个非空白字符(空白按 Unicode 判定,全角空格也算空白,本次收紧)**,≤64 | 大类中文名 |
| icon | Body | String | ❌ | ≤64;不传则不改 | 图标 |
| description | Body | String | ❌ | ≤256;不传则不改 | 描述 |
| sortOrder | Body | Integer | ✅ | 非 null | 排序权重 |
#### 出参 `Result<VehicleTypeRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| id | Long | 大类 ID |
| typeKey | String | 大类 key(不可改) |
| typeName | String | 大类中文名 |
| icon | String | 图标 |
| description | String | 描述 |
| sortOrder | Integer | 排序权重 |
| seatOptions | Array\<Integer\> | 该大类下型号座位数选项,去重升序 |
| modelCount | Integer | 型号数 |
| inUseCount | Integer | 在役车辆数 |
#### 请求示例
```json
{
"typeName": "商务车",
"icon": "",
"description": "",
"sortOrder": 2
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"id": "2064609312168083458",
"typeKey": "mpv",
"typeName": "商务车",
"sortOrder": 2,
"seatOptions": [7],
"modelCount": 1,
"inUseCount": 0
},
"success": true
}
```
#### 空数据 / 降级响应
写接口没有空数据场景,也没有降级分支;校验不通过时零写入,`update_time` 也不变。
```json
{ "code": 400, "message": "大类中文名不能为空", "data": null, "success": false }
```
#### 错误响应
```json
{ "code": 400, "message": "大类中文名不能为空", "data": null, "traceId": null, "success": false }
```
```json
{ "code": 600102, "message": "车型大类不存在", "data": null, "success": false }
```
#### 业务边界
- 鉴权:未登录 → 业务码 `401`。
- 参数校验先于「大类是否存在」:`typeName` 为空白时,即使 `typeId` 不存在也返回 400,而不是 600102。
- 编辑时把名字清空是被拒绝的操作,不会把一个在营大类变成空名大类。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload | 结果 |
|------|---------|------|
| ✅ 正常中文名 | `typeName: "商务车"` | 通过 |
| ✅ 首尾带空格 | `typeName: " 商务车 "` | 通过,原样落库 |
| ❌ 纯全角空格 | `typeName: "  "` | 400(**本次新增拒绝**) |
| ❌ 纯半角空格 / 空串 / null | `typeName: " "` / `""` / 不传 | 400(改前已拒) |
- 前端表单的必填校验应与后端一致,按「去掉**全部 Unicode 空白**后是否为空」判定。JS 的 `String.prototype.trim()` 会去掉全角空格(U+3000),可以直接用 `value.trim() === ''` 判空。
---
## 五、数据库行为(涉及写操作时必写)
- 无 Flyway migration、无 DDL、无表结构变更。`fleet_vehicle_type.type_name` 原本就是 `VARCHAR(64) NOT NULL`。
- 校验失败的请求零写入:TEST 实测 4 次 POST、2 次 PUT 被拒后,`fleet_vehicle_type` 全表逐字段(含 `update_time`)与请求前一致,也没有生成测试 key 的行。
- 存量处理成本为 0:TEST 上在架大类 4 行,`type_name` 都非空白。
---
## 六、边界行为
- 未登录 → 业务码 `401`。
- `typeName` 只要含一个非空白字符就放行;是否是「像中文的名字」后端不判断。
- 读侧兜底(内部接口,前端不直接调用,写在这里方便排查 582032):如果库里仍出现空名大类(例如被直接改库),车队给 order-v3 的车型白名单(`GET /internal/fleet/vehicle-types/category-names`)**照样包含这个大类**,只是展示名回落为规范 key(如 `mpv`),同时车队服务打一条 WARN「车型大类名称为空,展示名回落到规范 key」。改前这个大类会从白名单里整个消失,导致用车需求报 `582032「车型 mpv 不在车型字典内(不存在或已下线)」`,而座位数校验照常通过。
- 同一规范 key 下有多行时,展示名取排序最前、且名称非空白的那一行。
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| `typeName` 校验 | `@NotBlank`:按 `trim()` 判空,全角空格放行 | `@NotNull` + Unicode 空白判定:全角空格等一律拒 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 新增/编辑时 `typeName` 传全角空格 | 200,落库 | 400「大类中文名不能为空」,零写入 |
| `typeName` 传 null / 空串 / 半角空格 | 400 | 400(同一条文案,只报一条) |
| 库里存在空名在营大类时,order-v3 提交该大类的用车需求 | 582032 误拒(座位校验却通过) | 正常通过字典校验 |
---
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**:只收紧了一种此前被接受的取值(`typeName` 全为全角空格等 Unicode 空白),这种名字本来就没有业务意义;请求/响应结构不变。
- **前端是否必须同步上线**:否。前端不改的话,用户输入全角空格会在提交后收到后端 400 文案,不会写坏数据。建议前端表单必填校验同步用 `trim()` 判空,在提交前就提示。
- **前端 workaround 清理点**:无。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**:新增/编辑车型大类的 `typeName` 校验;内部接口 `category-names` 对空名大类的取舍。
- **零影响**:
- `typeKey`、`icon`、`description`、`sortOrder` 的校验规则。
- `GET /admin/fleet/vehicle-types`(树)、`/page`、`/list` 的响应结构与内容。
- 车型型号(`/admin/fleet/vehicle-types/{typeId}/models` 等)相关接口。
- order-v3 的 582032 / 582033 错误码与文案本身;名称正常的大类在白名单里的名称与顺序不变(TEST 实测 4 个在架大类改前改后一致)。
- 座位数选项接口 `seat-options` 的行为(本来就不看名称)。
---
## 八、测试环境已验证
部署:PR #8232 合并 `dev-v3`(`9231b8d8c`),2026-09-23 14:30 滚动部署 TEST 双实例(检出 `7a876ec57`,含本单)。
构建身份(零写入判据):直连 8087 / 8187 两个实例,`PUT /admin/fleet/vehicle-types/999999999`(不存在的 id):`typeName` 传全角空格返回 400(旧代码会放行到 600102);同一 id 传正常名返回 600102(阴性对照)。
真实网关(`https://api.test.1814.love`,管理端 token):
```
POST /admin/fleet/vehicle-types 无 token → 401 缺少有效的 Authorization 头 ✓
POST /admin/fleet/vehicle-types typeName="  " → 400 大类中文名不能为空 ✓
POST /admin/fleet/vehicle-types typeName=" " → 400 大类中文名不能为空 ✓
POST /admin/fleet/vehicle-types typeName="" → 400 大类中文名不能为空 ✓
POST /admin/fleet/vehicle-types typeName=null → 400 大类中文名不能为空 ✓
PUT /admin/fleet/vehicle-types/{suv2 的 id} typeName=" " → 400 ✓
PUT /admin/fleet/vehicle-types/{suv2 的 id} typeName="" → 400 ✓
fleet_vehicle_type 全表前后逐字段一致(含 update_time)✓
```
读侧(内部接口,两个实例):把 `mpv` 的 `type_name` 临时依次改为空串、半角空格、全角空格,`category-names` 均返回 `mpv → "mpv"`,其余 3 个大类名称与顺序不变,`seat-options?vehicleType=mpv` 仍返回 `[7]`;验毕 `type_name` 与 `update_time` 均恢复原值(逐字段一致)。
单元测试:`VehicleTypeServiceTest` 28、`VehicleTypeSaveReqVOValidationTest` 5、`VehicleTypeControllerTest` 17、`VehicleTypeControllerIdempotentTest` 6、`FleetRedLineArchTest` 18,共 74 例 0 失败。
---
## 十、相关文档
- 关联 Issue: [wx/HL#8226](https://git.1814.love/wx/HL/issues/8226)
- 关联 PR: [wx/HL#8232](https://git.1814.love/wx/HL/pulls/8232)
- 上游来源:#8202(子订单行程用车车型大类接入车队字典校验,582032)
## 关联 / 联系人
### 链接
- **Issue**: [#8226](https://git.1814.love/wx/HL/issues/8226)
- **PR**: [#8232](https://git.1814.love/wx/HL/pulls/8232)
- **Merge commit**: [9231b8d8c](https://git.1814.love/wx/HL/commit/9231b8d8c)
### 联系人
- **后端负责人**: @jw
- **前端负责人**: @mmg