# 订单打标签弹窗 — 标签选择器聚合 + 批量替换接口（管理后台）

- **日期**：2026-06-18
- **端类型**：管理后台
- **Issue**：[#3982](https://git.1814.love:8443/wx/HL/issues/3982)
- **PR**：[#3988](https://git.1814.love:8443/wx/HL/pulls/3988)
- **Commit**：[b8403032e9](https://git.1814.love:8443/wx/HL/commit/b8403032e94f3c73d9e13982619c1cacdebf98ea)
- **服务**：hl-order-service-v3（端口 8086）
- **后端负责人**：腰苏图

---

## ① 接口背景

订单打标签弹窗需要两个新接口支撑：

1. **标签选择器**（tag-picker）：打开弹窗时，聚合返回「当前管理员的标签库全集 + 每条是否已挂本订单 + 订单上已有的游离标签」，供前端渲染勾选状态。
2. **批量全量替换标签**（replace-tags）：弹窗点「确认」时，按入参列表全量替换订单标签（diff 软删/新增/改色），同步更新标签库使用计数。

原有 4 个增量操作接口（POST /tag 新增单条、DELETE /tag/:id 删单条、PATCH /tag/:id 改单条、GET /tags 查列表）**不变，不影响现有调用**。

---

## ② 变更清单

| 序号 | 变更类型 | 接口 | 说明 |
|------|---------|------|------|
| 1 | 新增接口 | `GET /v3/admin/order/{orderId}/tag-picker` | 打标签弹窗数据（标签库全集 + selected 标记 + 游离标签） |
| 2 | 新增接口 | `PUT /v3/admin/order/{orderId}/tags` | 批量全量替换订单标签 |

---

## ③ 接口详情

### 接口一：标签选择器

| 项目 | 内容 |
|------|------|
| 方法 | GET |
| 路径 | `/v3/admin/order/{orderId}/tag-picker` |
| 接口名 | 打标签弹窗数据（标签库全集 + selected 标记 + 游离标签） |
| 认证 | 需要 JWT（`Authorization: Bearer <token>`），userId 从 `X-Admin-Id` 请求头派生，前端不传 |
| 幂等性 | 只读，天然幂等 |
| 限流 | 无特殊限流 |

### 接口二：批量全量替换标签

| 项目 | 内容 |
|------|------|
| 方法 | PUT |
| 路径 | `/v3/admin/order/{orderId}/tags` |
| 接口名 | 批量全量替换订单标签 |
| 认证 | 需要 JWT（`Authorization: Bearer <token>`） |
| 幂等性 | 非幂等（diff 写入，重复调用以最后一次入参为准） |
| 限流 | 无特殊限流 |

---

## ④ 接口入参

### 接口一：GET /v3/admin/order/{orderId}/tag-picker

#### 4.1 路径参数

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| orderId | Long | 是 | 订单 ID |

无请求体。

### 接口二：PUT /v3/admin/order/{orderId}/tags

#### 4.1 路径参数

| 参数名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| orderId | Long | 是 | 订单 ID |

#### 4.2 请求体字段

| 字段名 | 类型 | 必填 | 说明 |
|--------|------|------|------|
| tags | List | 是（不可为 null） | 标签列表；传空数组 [] 表示清空该订单所有标签 |
| tags[].tagName | String | 是 | 标签名，长度 1~50 字 |
| tags[].tagColor | String | 否 | HEX 色值，格式 #RRGGBB（如 #FF6B6B）；不传则使用默认色 #5B8FF9 |

> **注意**：tags=null 会触发 400 校验失败。清空全部标签请传 {"tags":[]}。

---

## ⑤ 出参字段

### 接口一：GET /v3/admin/order/{orderId}/tag-picker

返回类型：`Result<List<TagPickerItemVO>>`

| 字段名 | 类型 | 说明 |
|--------|------|------|
| tagName | String | 标签名 |
| tagColor | String | HEX 色值，如 #5B8FF9 |
| tagScope | String | 作用域，见枚举说明；游离标签为 null |
| isPinned | Boolean | 是否置顶；游离标签为 false |
| sortOrder | Integer | 排序值；游离标签为 null |
| selected | Boolean | 是否已挂在当前订单；true 表示已挂 |
| inLibrary | Boolean | 是否来自标签库；false 表示游离标签（订单上存在但库中已删） |

列表顺序：标签库条目优先（is_pinned DESC → sort_order ASC → last_used_at DESC），游离标签追加末尾（selected=true, inLibrary=false）。

### 接口二：PUT /v3/admin/order/{orderId}/tags

返回类型：`Result<OrderTagListRespVO>`（替换后该订单的全量标签快照）

`OrderTagListRespVO` 字段：

| 字段名 | 类型 | 说明 |
|--------|------|------|
| attached | `List<OrderTagVO>` | 替换后该订单已挂标签列表 |

`OrderTagVO` 字段：

| 字段名 | 类型 | 说明 |
|--------|------|------|
| id | Long（String 序列化） | tag 主键 |
| orderId | Long（String 序列化） | 关联订单 ID |
| tagName | String | 标签名 |
| tagColor | String | 标签色值，如 #FF6B6B |
| creator | String | 创建人姓名（定制师姓名或 SYSTEM） |
| createdAt | String（ISO-8601） | 创建时间，如 2026-06-18T10:30:00 |

---

## ⑥ 枚举 / 数据字典

### tagScope（标签作用域）

| 值 | 含义 |
|----|------|
| SYSTEM | 系统标签（全公司共享） |
| PERSONAL | 个人标签（仅创建人可见） |
| null | 游离标签（订单上存在但已从标签库中删除，已无作用域归属） |

---

## ⑦ 错误码

以下为两个新接口涉及的错误码：

| 错误码 | 含义 | 触发场景 |
|--------|------|---------|
| 581401 | 订单不存在 | orderId 在数据库中不存在（两个接口均会触发） |
| 581402 | 标签名为空或超长（≤50 字） | tagName 空白或超过 50 字（替换接口） |
| 581404 | tagColor 格式非法（必须 # + 6 位十六进制） | tagColor 传了非 #RRGGBB 格式值（替换接口） |

其他已有错误码不变：581403 同名私人标签已存在（POST /tag 新增时触发，本次两个新接口不触发）。

---

## ⑧ 示例

### 8.1 典型成功

场景：打开打标签弹窗，标签库有 2 条，订单已挂其中 1 条 + 1 条游离标签。

tag-picker 请求：GET /v3/admin/order/1234567890123456789/tag-picker

响应（3 条：1 条已选库标签、1 条未选库标签、1 条游离标签）：

```json
{"code":200,"data":[
  {"tagName":"VIP客户","tagColor":"#5B8FF9","tagScope":"SYSTEM","isPinned":true,"sortOrder":1,"selected":true,"inLibrary":true},
  {"tagName":"需要接送","tagColor":"#FF6B6B","tagScope":"PERSONAL","isPinned":false,"sortOrder":10,"selected":false,"inLibrary":true},
  {"tagName":"旧标签已删","tagColor":"#AAAAAA","tagScope":null,"isPinned":false,"sortOrder":null,"selected":true,"inLibrary":false}
]}
```

批量替换标签请求：PUT /v3/admin/order/1234567890123456789/tags

请求体：
```json
{"tags":[{"tagName":"VIP客户","tagColor":"#5B8FF9"},{"tagName":"需要接送","tagColor":"#FF6B6B"}]}
```

响应：
```json
{"code":200,"data":{"attached":[
  {"id":"1881234567890000001","orderId":"1234567890123456789","tagName":"VIP客户","tagColor":"#5B8FF9","creator":"张三","createdAt":"2026-06-18T10:30:00"},
  {"id":"1881234567890000002","orderId":"1234567890123456789","tagName":"需要接送","tagColor":"#FF6B6B","creator":"张三","createdAt":"2026-06-18T10:30:01"}
]}}
```

### 8.2 边界情况

清空全部标签，请求体 {"tags":[]}, 响应 {"code":200,"data":{"attached":[]}}

tag-picker 返回空列表（标签库为空且订单未挂标签）：{"code":200,"data":[]}

### 8.3 业务失败

场景一：订单不存在（581401）
请求：GET /v3/admin/order/9999999999999999999/tag-picker
响应：{"code":581401,"msg":"订单不存在"}

场景二：标签名为空（581402）
最小复现请求体：{"tags":[{"tagName":""}]}
响应：{"code":581402,"msg":"标签名为空或超长（≤50 字）"}

场景三：tagColor 格式非法（581404）
最小复现请求体：{"tags":[{"tagName":"VIP","tagColor":"red"}]}
响应：{"code":581404,"msg":"tagColor 格式非法（必须 # + 6 位十六进制）"}

场景四：tags=null 触发 400
请求体：{"tags":null}
响应：{"code":400,"msg":"tags 不能为 null（清空请传空数组 [])"}

---

## ⑨ 业务边界

适用场景：
- GET tag-picker：打开打标签弹窗时调用。任意订单状态均可调用。
- PUT tags：弹窗点「确认」时调用，全量替换（不是追加）。任意订单状态均可调用。

不适用场景：
- 两个接口均不做订单状态限制。
- 公司隔离由后端 JWT 校验，前端不传公司 ID。

特殊边界：
- PUT tags 是全量替换语义：后端自动 diff（删除不在入参里的旧标签、新增入参里新出现的标签、更新颜色变化的标签）。
- 游离标签（inLibrary=false）在 PUT tags 入参中可通过 tagName 匹配保留，也可不传从而删除。
- 标签库使用计数（used_count / last_used_at）：PUT tags 操作后，命中标签库的标签会自动更新使用计数，无需前端额外操作。
- tagColor 不传时，新增标签默认色为 #5B8FF9；已有标签改色时以入参为准。

---

## ⑩ 修改前后对比

本次为纯新增接口，无历史接口修改，无对比项。

原有 4 个增量操作接口完全不变：

| 接口 | 状态 |
|------|------|
| POST /v3/admin/order/{orderId}/tag | 不变 |
| DELETE /v3/admin/order/{orderId}/tag/{tagId} | 不变 |
| PATCH /v3/admin/order/{orderId}/tag/{tagId} | 不变 |
| GET /v3/admin/order/{orderId}/tags | 不变 |

---

## ⑪ 影响评估 / 回滚

- **破坏兼容性**：无（纯新增，不影响现有接口）
- **前端同步上线**：无强制要求，新接口不上线不会影响现有功能
- **回滚方案**：若需回滚，仅需回滚 hl-order-service-v3 至本 PR 前版本；无 DDL 变更，数据库无需操作

---

## ⑫ 注意事项

1. PUT tags 是全量替换，不是追加：每次调用以入参为最终状态全量覆盖，请前端在弹窗「确认」时一次性传完整列表。
2. tags=null 触发 400：清空标签请传 {"tags":[]}, 不要传 {"tags":null}。
3. Long 类型 ID 用字符串序列化：OrderTagVO.id 和 OrderTagVO.orderId 均为雪花 ID，JSON 序列化为 String，前端 JS 用字符串处理，不要转 Number。
4. tag-picker 中游离标签排在末尾：inLibrary=false 的条目追加末尾，前端可据此做分组显示或特殊标注。
5. 颜色默认值：tagColor 不传时后端默认 #5B8FF9，前端如需展示颜色预览可读此默认值。
6. 零 DDL：本次无数据库结构变更，order-v3 重启后即生效。

---

## ⑬ 关联 / 联系人

- **Issue**：https://git.1814.love:8443/wx/HL/issues/3982
- **PR**：https://git.1814.love:8443/wx/HL/pulls/3988
- **Commit**：https://git.1814.love:8443/wx/HL/commit/b8403032e94f3c73d9e13982619c1cacdebf98ea
- **后端负责人**：腰苏图
