changelog(8292): 订单标签四个写端点补订单归属守卫,新增写面码 581064
changelog-filename-gate / validate (push) Failing after 2s

接口:POST /v3/admin/order/{orderId}/tag、DELETE .../tag/{tagId}、
PATCH .../tag/{tagId}、PUT .../tags
- 四个写端点补订单归属校验,与读面(#8290)同一放行集合;
- 非归属人失败新增 581064「无权修改该订单」(不复用 581008);
- 581433 文案由「非创建人且非主管」订正为「非创建人」(实现里无主管分支);
- deleteTag/patchTag 的 orderId 不存在时由 581410/581411 改为 581401;
- 测试服实测:非归属 581064 且无残留、本人与超管 200。

Refs #8292
这个提交包含在:
API Changelog Bot
2026-09-24 10:12:20 +08:00
父节点 b6672e9d99
当前提交 cda5a7adea
@@ -0,0 +1,438 @@
---
schema: "hl-changelog/v2"
ticket: "8292"
title: "订单标签四个写端点补订单归属守卫,与读面口径对齐"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "not_required"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: "2026-09-24"
status_note: "接口路径、请求体、成功响应结构全未变,变的是失败分支:新增 581064 无权修改该订单。无新增路由,故 gateway_status=not_required。测试服实测(网关 https://api.test.1814.love,部署 ab630a3f8):test_admin(CUSTOMIZER,非归属) POST 他人订单标签 → 581064 且复查无残留;test_admin 对自己订单读/原样全量回写 → 200;wx(SUPER_ADMIN) 对他人订单读/原样回写 → 200;admin(当前角色 GROUP_BATCH_MANAGER)写他人订单 → 581008(#8154 的 Controller 角色守卫先拒)。边缘变化:deleteTag/patchTag 传不存在的 orderId 时错误码由 581410/581411 变为 581401。frontend_status=pending:hl-ui 现按 err.message 透传,581064 走通用 toast 即可,无需改码。"
updated_at: "2026-09-24"
base: "dev-v3"
---
# 订单标签: 四个写端点补订单归属守卫,与读面口径对齐
> **存放目录**: 二期(order-v3)→ `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3(端口 8086)
> **PR**: [#8307](https://git.1814.love:8443/wx/HL/pulls/8307)
> **Issue**: [#8292](https://git.1814.love:8443/wx/HL/issues/8292)
> **日期**: 2026-09-24
> **影响范围**: 订单详情页「标签」区四个写操作的**权限口径**;接口路径、请求体、成功响应结构均未变
---
## ⚠️ 关键变化(本版与上版行为不同,必读)
**这四个写接口现在会拒绝「非本单归属人」的调用,失败时返回新错误码 `581064`(无权修改该订单)。**
- 本次变了什么:四个写端点补上了**订单归属**校验。
- 前端以前以为的:除团期管理员外谁都能写(确实是 #8290 之前的行为)。
- 实际现在是什么:与**读面**(#8290 已收紧的 `GET .../tags`、`GET .../tag-picker`)**同一套放行集合**——读得到的单才写得动。
前端以前只会从这四个接口收到 `581008`(团期管理员)与 `581045`(房务),**现在会新增收到 581064**。
---
## 一、背景
#8170 AC-18(PR #8290)给 `GET /v3/admin/order/{orderId}/tags`、`GET .../tag-picker` 与 `GET .../itinerary-document` 补上了 `OrderViewGuard.assertOrderReadable`,读面收紧后同一批数据上的四个**写**端点仍零归属校验,形成「**不能看,但能改**」——任意后台角色拿到 `orderId` 就能增、能改、能删他人订单的标签,其中 `PUT .../tags` 是全量替换,会清掉不在入参里的标签。
口径由 wx 于 2026-09-24 定案:**写面与读面读写对称**。本单是 #8170 挂账的「存量治理」单。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 增订单标签 | POST | `/v3/admin/order/{orderId}/tag` | 失败分支新增 | 非归属人 → 581064 |
| 2 | 删订单标签 | DELETE | `/v3/admin/order/{orderId}/tag/{tagId}` | 失败分支新增 | 非归属人 → 581064;orderId 不存在时由 581410/581411 改为 581401 |
| 3 | 改订单标签 | PATCH | `/v3/admin/order/{orderId}/tag/{tagId}` | 失败分支新增 | 非归属人 → 581064;533 原有「非标签创建人 → 581433」不变 |
| 4 | 全量替换订单标签 | PUT | `/v3/admin/order/{orderId}/tags` | 失败分支新增 | 非归属人 → 581064 |
**四个接口的请求体、成功响应体、路径参数全部未变。**
---
## 三、接口详情
### 1. 增订单标签 `POST /v3/admin/order/{orderId}/tag`
**VO**: `TagAddReqVO → Result<OrderTagVO>`
#### 使用场景
订单详情页标签区点击「打标签」后提交。creator / createdAt 由后端从 JWT 派生,前端不传。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderId` | Path | String | ✅ | - | 订单 ID |
| `tagName` | Body | String | ✅ | trim 后非空,同订单内不重名 | 标签名 |
| `tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传默认 `#5B8FF9` |
#### 出参 `Result<OrderTagVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `tagId` | String | 雪花 ID,字符串返回防精度丢失 |
| `orderId` | String | 订单 ID |
| `tagName` | String | 标签名 |
| `tagColor` | String | 色值 |
#### 请求示例
```json
{ "tagName": "重点客户", "tagColor": "#FF6B6B" }
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": { "tagId": "2100...", "orderId": "9199...", "tagName": "重点客户", "tagColor": "#FF6B6B" }, "success": true }
```
#### 空数据 / 降级响应
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
写接口无列表与降级语义:成功恒返 200 + 业务结果,失败恒返业务码,不存在「空集合」形态;
无下游 Feign/消息依赖,故也没有降级分支。前端按 `code == 200` 判成功、否则 toast `message` 即可。
#### 错误响应
```json
{ "code": 581064, "message": "无权修改该订单", "success": false, "data": null }
```
#### 业务边界
- **鉴权**:必须是本单定制师,或 ADMIN / SUPER_ADMIN / 车务管理员;团期管理员被 581008 拒(只读承诺 #8154);房务管理员与房务组长 581045。
- **空值**:`tagName` trim 后为空 → 581402;颜色格式非法 → 581404。
- **幂等/并发**:同订单同名标签 → 581403(不做幂等 upsert)。
- **失败零写入**:归属守卫排在插入之前,被拒时一行都不会写。
### 2. 删订单标签 `DELETE /v3/admin/order/{orderId}/tag/{tagId}`
**VO**: `无请求体 → Result<Boolean>`
#### 使用场景
订单详情页标签区删除某个已挂标签(软删)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderId` | Path | String | ✅ | - | 订单 ID |
| `tagId` | Path | String | ✅ | - | `order_tag.tag_id` |
#### 出参 `Result<Boolean>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data` | Boolean | true = 删除成功 |
#### 请求示例
```http
DELETE /v3/admin/order/9199000000000000002/tag/2100123456789012345
Authorization: Bearer <admin-token>
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": true, "success": true }
```
#### 空数据 / 降级响应
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
写接口无列表与降级语义:成功恒返 200 + 业务结果,失败恒返业务码,不存在「空集合」形态;
无下游 Feign/消息依赖,故也没有降级分支。前端按 `code == 200` 判成功、否则 toast `message` 即可。
#### 错误响应
```json
{ "code": 581401, "message": "订单不存在", "success": false, "data": null }
```
#### 业务边界
- **鉴权**:同接口 1。**标签级没有创建人限制**(任何有权者都可删该单的标签),权限完全由订单归属决定。
- **顺序**:先取订单 → 判空(581401)→ 归属守卫 → 才按 `tagId` 取标签。越权请求不再能从「标签不存在 581410」与「标签不属于该订单 581411」的差别推断某个 `tagId` 是否存在。
- **失败零写入**:守卫在任何标签表访问之前。
### 3. 改订单标签 `PATCH /v3/admin/order/{orderId}/tag/{tagId}`
**VO**: `TagPatchReqVO → Result<Boolean>`
#### 使用场景
订单详情页标签区编辑某个已挂标签的名称或颜色(PATCH 语义,只改传入字段)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderId` | Path | String | ✅ | - | 订单 ID |
| `tagId` | Path | String | ✅ | - | `order_tag.tag_id` |
| `tagName` | Body | String | ❌ | trim 后非空 | 不传则不改 |
| `tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传则不改 |
#### 出参 `Result<Boolean>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `data` | Boolean | true = 修改成功 |
#### 请求示例
```json
{ "tagName": "高净值", "tagColor": "#FF6B6B" }
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": true, "success": true }
```
#### 空数据 / 降级响应
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
写接口无列表与降级语义:成功恒返 200 + 业务结果,失败恒返业务码,不存在「空集合」形态;
无下游 Feign/消息依赖,故也没有降级分支。前端按 `code == 200` 判成功、否则 toast `message` 即可。
#### 错误响应
```json
{ "code": 581433, "message": "无权修改该标签(非创建人)", "success": false, "data": null }
```
#### 业务边界
- **两道互不替代的校验**:① 订单归属(581064,本单新增);② **标签级创建人**(操作人 ≠ `order_tag.created_by` → 581433,既有行为未变)。
- `581433` 的文案本单由「非创建人**且非主管**」订正为「非创建人」:实现里**从来没有**「主管」判定分支,原文案是空承诺。
- **顺序**:先取订单 → 判空(581401)→ 归属守卫 → 才按 `tagId` 取标签。
- 操作人上下文缺失时第二道校验整体放行(既有 fail-open),故它**不是**归属防线;归属防线是第一道。
### 4. 全量替换订单标签 `PUT /v3/admin/order/{orderId}/tags`
**VO**: `ReplaceOrderTagsReqVO → Result<OrderTagListRespVO>`
#### 使用场景
打标签弹窗点「确定」后整批提交(前端先 `GET .../tag-picker` 再提交全量列表)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| `orderId` | Path | String | ✅ | - | 订单 ID |
| `tags[].tagName` | Body | String | ✅ | trim 后非空 | 标签名(按名去重,保留首个) |
| `tags[].tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传默认 `#5B8FF9` |
#### 出参 `Result<OrderTagListRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| `attached` | Array | 替换后的全量标签快照(元素同 `OrderTagVO`) |
#### 请求示例
```json
{ "tags": [ { "tagName": "重点客户", "tagColor": "#FF6B6B" } ] }
```
#### 响应示例
```json
{ "code": 200, "message": "成功", "data": { "attached": [ { "tagId": "2100...", "orderId": "9199...", "tagName": "重点客户", "tagColor": "#FF6B6B" } ] }, "success": true }
```
#### 空数据 / 降级响应
```json
{ "code": 200, "message": "成功", "data": null, "success": true }
```
写接口无列表与降级语义:成功恒返 200 + 业务结果,失败恒返业务码,不存在「空集合」形态;
无下游 Feign/消息依赖,故也没有降级分支。前端按 `code == 200` 判成功、否则 toast `message` 即可。
#### 错误响应
```json
{ "code": 581064, "message": "无权修改该订单", "success": false, "data": null }
```
#### 业务边界
- **这是四个写端点里后果最重的一个**:全量替换按 diff 清掉不在入参里的标签。传空 `tags` 数组 = 一次性清空该单全部标签。
- **鉴权**:同接口 1;归属守卫排在 diff 之前(防御性,不做无谓写、不依赖事务回滚兜底)。
- **事务**:`@Transactional(rollbackFor = Exception.class)`,diff 失败整笔回滚。
- **失败零写入**:被拒时 diff 一步都不执行。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | payload |
|------|---------|
| ✅ 只改名不改色 | `{ "tagName": "高净值" }` |
| ✅ 只改色不改名 | `{ "tagColor": "#FF6B6B" }` |
| ✅ 全量替换为空标签 | `{ "tags": [] }`(有权者:清空该单标签) |
| ❌ 全量替换传 null | `{ "tags": null }` → 参数校验失败 |
| ❌ 颜色少 # 或非 6 位 | `{ "tagColor": "FF6B6B" }` → 581404 |
| ❌ 非归属人写他人订单 | 任意上述 payload → **581064** |
### 切换状态时的必要动作
无字段互斥关系;PATCH 是「只改传入字段」语义,不需要先读回再整对象提交。若要整批调整,用 `PUT .../tags` 传全量列表(空数组即清空)。
---
## 五、数据库行为(涉及写操作时必写)
表 `order_tag`(继承 `BaseDO`,含 `deleted_at` 逻辑删除列)。
| 操作 | SQL 效果 |
|------|----------|
| POST 增标签 | `INSERT INTO order_tag (tag_id, order_id, tag_name, tag_color, creator, created_by, ...)` |
| DELETE 删标签 | **软删** `UPDATE order_tag SET deleted_at = NOW() WHERE tag_id = ?`(`BaseDO.deletedAt` 带 `@TableLogic(value = "NULL", delval = "NOW()")`) |
| PATCH 改标签 | `UPDATE order_tag SET tag_name = ?, tag_color = ? ...`(仅传入字段) |
| PUT 全量替换 | 差集软删 + 新增 INSERT + 颜色变更 UPDATE;命中 `user_tag_library` 的标签会 `touchUsage` 累加 `used_count` 并刷新 `last_used_at` |
**读面一致性**:所有 `selectByOrderId` 查询被 `@TableLogic` 自动附加 `deleted_at IS NULL`,被软删的标签即刻从接口结果消失,但行仍留在库里(无业务恢复入口)。
---
## 六、边界行为
- 未登录 → 401(网关拦截)
- `orderId` 不存在 → `POST` / `PUT` 返 581401;`DELETE` / `PATCH` **本单起也返 581401**(此前会走到 581410/581411)
- `tagId` 不存在 → 581410;`tagId` 不属于该 `orderId` → 581411(DELETE)/ 581434(PATCH)
- 同订单同名标签 → 581403(新增时)
- 团期管理员 → 581008(#8154 的角色级只读,Controller 层先拒,与 `groupBatchId` 无关)
- 房务管理员 / 房务组长 → 581045
- 老数据兼容:存量标签的 `created_by` 若为空,PATCH 的第二道校验仍按操作人比对(既有行为,本单未改)
---
## 六.5、枚举 / 数据字典
本节不适用:四个接口无枚举入参或返回值(标签类型字段已由 #3941 移除)。
---
## 六.6、修改前后对比(修改/删除类接口必写)
### 字段级对比
| 字段 | 改前 | 改后 |
|------|------|------|
| 请求体字段 | — | **无变化** |
| 响应体字段 | — | **无变化** |
| 失败错误码 | 581008(团期管理员)/ 581045(房务) | 新增 **581064**(非归属人);581008 / 581045 保持 |
| DELETE/PATCH 的 `orderId` 不存在 | 581410 / 581411(先查标签) | **581401 订单不存在**(先查订单) |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 非归属角色写他人订单标签(POST / DELETE / PATCH / PUT) | **放行** | **581064** |
| 非归属角色全量替换他人订单标签(PUT 空数组) | 放行,会清空他人标签 | 581064,零写入 |
| 本单定制师写自己订单标签 | 放行 | 放行(不变) |
| ADMIN / SUPER_ADMIN / 车务管理员 | 放行 | 放行(不变) |
| 团期管理员 | 581008 | 581008(不变) |
| 房务管理员 / 房务组长 | 放行 | 581045 |
---
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**:**对合法调用方否**(有权角色行为零变化);对**越权调用**是有意的行为收紧。
- **前端是否必须同步上线**:**否**。接口路径与结构未变,581064 走通用 `err.message` toast 即可;但要**知悉**该码的语义(是写权限,不是登录态问题)。
- **前端 workaround 清理点**:无。hl-ui 现按 `err.message` 透传,无按码分支可清理。
---
## 七、不影响范围(显式声明,帮前端/QA 缩小排查面)
- **仅影响**:订单详情页「标签」区的四个写操作(增 / 删 / 改 / 全量替换)。
- **零影响**:
- 标签的**三个读接口**(`GET .../tags`、`GET .../tag-picker`、`GET .../itinerary-document`)——本单未改
- 创单链路写入标签(`OrderCreateTransactionExecutor` 同事务内 batchCreate,无「他人订单」概念)
- 订单详情 / 行程 / 财务等其它读写面
- `user_tag_library` 私人标签库的全部接口
- 存量数据(不迁移;软删语义未变)
- 其它服务(本改动不跨服务,无 Feign/事件/MQ 契约变化)
---
## 八、测试环境已验证
真实网关调用(`https://api.test.1814.love`,测试服部署 `★ab630a3f8`):
```
GET /v3/admin/order/9199000000000000002/tags as CUSTOMIZER(非归属) → 581008 无权查看此订单 ✓
POST /v3/admin/order/9199000000000000002/tag as CUSTOMIZER(非归属) → 581064 无权修改该订单 ✓
GET /v3/admin/order/9199000000000000002/tags as SUPER_ADMIN → 200 复查确认无残留标签 ✓
PUT /v3/admin/order/9199000000000000002/tags as SUPER_ADMIN(原样回写)→ 200 ✓
GET /v3/admin/order/2096701868536188930/tags as CUSTOMIZER(本单定制师)→ 200 ✓
PUT /v3/admin/order/2096701868536188930/tags as CUSTOMIZER(本单定制师)→ 200 ✓
POST /v3/admin/order/9199000000000000002/tag as GROUP_BATCH_MANAGER → 581008(#8154 角色守卫先拒) ✓
```
单元与门禁:定向 `Tests run 129 / Failures 0 / Errors 0`;ArchTest `Tests run 26 / Failures 0 / Errors 0`。
---
## 九、相关历史 PR(纠错 / 功能演进时必写)
| PR | Issue | 说明 | 是否仍有效 |
|----|-------|------|------------|
| #8290 | #8170 | 标签**读**面补 `assertOrderReadable`,本单就是它显影出的写面缺口 | ✅ 有效 |
| **本 PR #8307** | **#8292** | 标签**写**面补归属守卫 + 新写面码 581064 + 581433 文案订正 | ✅ 最新 |
---
## 十、相关文档
- 关联 Issue: [wx/HL#8292](https://git.1814.love:8443/wx/HL/issues/8292)
- 关联 PR: [wx/HL#8307](https://git.1814.love:8443/wx/HL/pulls/8307)
- 后续计划: 无
## 关联 / 联系人
### 链接
- **Issue**: [#8292](https://git.1814.love:8443/wx/HL/issues/8292)
- **PR**: [#8307](https://git.1814.love:8443/wx/HL/pulls/8307)
- **Merge commit**: [ab630a3f8](https://git.1814.love:8443/wx/HL/commit/ab630a3f8)
### 联系人
- **后端负责人**: @wx