diff --git a/changelogs-v2/2026-09/24_8292_订单标签四个写端点补订单归属守卫,与读面口径对齐-修改接口-管理后台.md b/changelogs-v2/2026-09/24_8292_订单标签四个写端点补订单归属守卫,与读面口径对齐-修改接口-管理后台.md new file mode 100644 index 00000000..2b015f14 --- /dev/null +++ b/changelogs-v2/2026-09/24_8292_订单标签四个写端点补订单归属守卫,与读面口径对齐-修改接口-管理后台.md @@ -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` + +#### 使用场景 + +订单详情页标签区点击「打标签」后提交。creator / createdAt 由后端从 JWT 派生,前端不传。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `orderId` | Path | String | ✅ | - | 订单 ID | +| `tagName` | Body | String | ✅ | trim 后非空,同订单内不重名 | 标签名 | +| `tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传默认 `#5B8FF9` | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `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` + +#### 使用场景 + +订单详情页标签区删除某个已挂标签(软删)。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `orderId` | Path | String | ✅ | - | 订单 ID | +| `tagId` | Path | String | ✅ | - | `order_tag.tag_id` | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `data` | Boolean | true = 删除成功 | + +#### 请求示例 + +```http +DELETE /v3/admin/order/9199000000000000002/tag/2100123456789012345 +Authorization: Bearer +``` + +#### 响应示例 + +```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` + +#### 使用场景 + +订单详情页标签区编辑某个已挂标签的名称或颜色(PATCH 语义,只改传入字段)。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `orderId` | Path | String | ✅ | - | 订单 ID | +| `tagId` | Path | String | ✅ | - | `order_tag.tag_id` | +| `tagName` | Body | String | ❌ | trim 后非空 | 不传则不改 | +| `tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传则不改 | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `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` + +#### 使用场景 + +打标签弹窗点「确定」后整批提交(前端先 `GET .../tag-picker` 再提交全量列表)。 + +#### 入参 + +| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 | +|------|------|------|------|------|------| +| `orderId` | Path | String | ✅ | - | 订单 ID | +| `tags[].tagName` | Body | String | ✅ | trim 后非空 | 标签名(按名去重,保留首个) | +| `tags[].tagColor` | Body | String | ❌ | `#` + 6 位十六进制 | 不传默认 `#5B8FF9` | + +#### 出参 `Result` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `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