--- schema: "hl-changelog/v2" ticket: "8292" title: "订单标签四个写端点补订单归属守卫,与读面口径对齐" consumer: "admin" author: "wx(GIT)" change_type: "修改接口" backend_status: "deployed" gateway_status: "not_required" frontend_status: "not_required" frontend_owner: "" frontend_ref: "" target_release: "" verified_at: "" 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 即可,无需改码。 前端 2026-09-24 核验 not_required:四写端点前端只消费 PUT /tags(replaceOrderTags 全量替换,POST/DELETE/PATCH 增删改无封装调用),TagPickerModal catch 纯透 err.message 无按码分支;581064/581410/581411/581401/581433 前端零引用,新增 581064 走通用 toast 透后端原文即达标;581008/581045 是 orderAccess 路由隔离角色守卫与标签业务无关;581410/581411 变形只影响前端无调用的 DELETE/PATCH。零改动。" 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