docs(changelog): #8170 订单协作域三只读端点补归属守卫(581008/581045)+ 行程价格字段 / 批量打回 resourceType 契约说明
changelog-filename-gate / validate (push) Failing after 2s

Refs wx/HL#8170

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
这个提交包含在:
API Changelog Bot
2026-09-23 20:54:45 +08:00
共同撰写人 Claude Opus 5.5
父节点 f83c613470
当前提交 6461ab4909
@@ -0,0 +1,628 @@
---
schema: "hl-changelog/v2"
ticket: "8170"
title: "订单协作域三只读端点补订单归属守卫(新增返回 581008/581045);行程价格字段与团期批量打回契约补充说明"
consumer: "admin"
author: "wx(GIT)"
change_type: "修改接口"
backend_status: "deployed"
gateway_status: "verified"
frontend_status: "pending"
frontend_owner: ""
frontend_ref: ""
target_release: ""
verified_at: ""
status_note: "PR #8290 已合并 dev-v3(841ea7b06)。部署:hl-order-service-v3 dev-v3 @ 841ea7b06,2026-09-23 20:07:37 部署,deploy-status STATE=ok,BEHIND 0。真实网关实测(夹具订单 2100542584106496001,归属定制师为他人):CUSTOMIZER(非归属)在 tags / tag-picker / itinerary-document 三个端点均返回 581008;SUPER_ADMIN(对照)三个端点均正常返回 200;itinerary-document 不带 documentType 时先因参数校验返回业务码 400,发生在归属守卫之前。GET /{id}/itinerary 与批量打回 resourceType 两处仅补充字段说明文案,接口行为、请求/响应结构均未变。"
updated_at: "2026-09-23"
base: "dev-v3"
---
# order-v3: 协作域三只读端点补订单归属守卫 + 行程价格字段/团期批量打回契约说明
> **存放目录**: 二期(v3) → `changelogs-v2/2026-09/`
>
> **服务**: hl-order-service-v3
> **PR**: #8290
> **Issue**: #8170
> **日期**: 2026-09-23
> **影响范围**: 管理后台订单详情「标签」区、打标签弹窗、生成电子行程单(对客)、订单详情「行程安排」Tab、团期「查看需求」批量打回
---
## ⚠️ 关键变化(非必须,本版与上版行为不同 / 纠错 / 撤销时必写)
- 本次变化:`GET /v3/admin/order/{orderId}/tags`、`GET /v3/admin/order/{orderId}/tag-picker`、`GET /v3/admin/order/{orderId}/itinerary-document` 三个端点新增订单归属校验。
- 前端以前以为的:只要拿到 token、打开订单详情页,就能对任意 `orderId` 调这三个接口拿到数据——改前这三个端点方法体零权限判断,任意后台角色都能读到他人订单的标签、打标签弹窗数据、完整电子行程单(含出行人、酒店、大交通)。
- 实际新行为:非归属定制师、非 `ADMIN`/`SUPER_ADMIN`/`VEHICLE_MANAGER`、且不是在读团期子订单的团期管理员,会收到 `code=581008`;房务管理员/房务组长一律先被拒绝为 `code=581045`。请求体、响应体结构不变,只是多了这两种失败响应。
- 同一次提交里顺带把 `GET /{id}/itinerary` 的四个价格字段(协议价快照、结算价快照、计划日单价、房型行协议价)与批量打回 `resourceType` 字段的既有契约写进了字段说明(**不是行为变化,是文档补充**):这四个价格字段是供应商采购价,不是对客报价;批量打回 `resourceType=ALL` 的车侧范围只覆盖游览车(TRAVEL),不含接送机(TRANSFER)。
---
## 一、背景(选填)
#8170 AC-18 复核发现协作域三个只读端点(标签列表、打标签弹窗、电子行程单)方法体零归属校验——Controller 与 Service 都没有调用任何 `OrderViewGuard`,只要能拿到 JWT(任意后台角色)就能传任意 `orderId` 读到他人订单的标签与完整行程文档。本次在 `CollabService` 三个方法体内补 `OrderViewGuard.assertOrderReadable`。
同一份工单里还有两条**纯文档补充**(无代码行为变化):AC-2 把「团期管理员为什么能在行程 Tab 看到供应商成本价」这条 2026-09-22 已拍板的口径写进 `ItineraryVO` 字段注释;AC-11 把「批量打回 `ALL` 不含接送机」这条既有行为写进 `RejectRequirementReqVO` 与 `GroupBatchRequirementService#doReject` 的契约说明。
---
## 二、变更接口清单
| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 查订单已挂标签 | GET | `/v3/admin/order/{orderId}/tags` | 新增归属校验 | 新增 `OrderViewGuard.assertOrderReadable`;非放行角色返 581008,房务/组长返 581045 |
| 2 | 打标签弹窗数据 | GET | `/v3/admin/order/{orderId}/tag-picker` | 新增归属校验 | 同上;守卫排在 `OperatorHolder` 取当前操作人之前 |
| 3 | 生成电子行程单(对客) | GET | `/v3/admin/order/{orderId}/itinerary-document` | 新增归属校验 | 同上;`documentType` 缺失时先于守卫返回业务码 400 |
| 4 | 行程安排 Tab | GET | `/v3/admin/order/{id}/itinerary` | 字段说明变更 | 四个价格字段补注「供应商采购价,非对客报价」;接口行为、字段结构不变 |
| 5 | 按户打回需求 | POST | `/v3/admin/order/group-batch/{groupBatchId}/requirement/reject` | 字段说明变更 | `resourceType` 契约补充:`ALL`/`VEHICLE` 车侧仅覆盖 TRAVEL;接口行为不变 |
---
## 三、接口详情
### 1. 查订单已挂标签 `GET /v3/admin/order/{orderId}/tags`
**VO**: `无请求体 → OrderTagListRespVO`
#### 使用场景
订单详情页展示已挂在该订单上的标签(扁平列表,不分类型)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| orderId | Path | Long | ✅ | 订单须存在(581430);当前登录角色须对该订单具备归属读权(581008/581045,本次新增) | 订单 ID |
#### 出参 `Result<OrderTagListRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| attached | List<Object> | 当前订单已挂标签列表(扁平,不分类型) |
| attached[].id | String | 标签主键(Long,超 JS 安全整数范围时序列化为字符串) |
| attached[].orderId | String | 关联订单 ID |
| attached[].tagName | String | 标签名 |
| attached[].tagColor | String | 标签色值,如 #FF6B6B |
| attached[].creator | String | 创建人姓名(定制师 or SYSTEM) |
| attached[].createdAt | String | 创建时间,格式 yyyy-MM-dd HH:mm:ss |
#### 请求示例
```http
GET /v3/admin/order/2100542584106496001/tags
Authorization: Bearer {token}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"attached": [
{
"id": "2101000000000000001",
"orderId": "2100542584106496001",
"tagName": "VIP",
"tagColor": "#FF6B6B",
"creator": "张定制",
"createdAt": "2026-09-20 10:15:00"
}
]
},
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
无标签时 `attached` 为空数组,不是 null:
```json
{ "code": 200, "message": "成功", "data": { "attached": [] }, "traceId": null, "success": true }
```
#### 错误响应
本次新增(TEST 实测,夹具订单 2100542584106496001,归属定制师为他人;CUSTOMIZER 非归属角色实测返回):
```json
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
```
房务管理员/房务组长(`ROOM_MANAGER`/`house_keeper_lead`)先于归属判定被拒绝:
```json
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
```
订单不存在(既有行为,未变):
```json
{ "code": 581430, "message": "订单不存在", "data": null, "traceId": null, "success": false }
```
#### 业务边界
- 放行角色(TEST 实测 SUPER_ADMIN 正常返回):`ADMIN`/`SUPER_ADMIN` 恒放行;`VEHICLE_MANAGER` 恒放行;`GROUP_BATCH_MANAGER` 仅当目标订单挂在团期下(`order.groupBatchId != null`)才放行,散客单仍 581008;其余角色(定制师/客服/运营/财务等)必须是该订单的归属定制师(`adminId == order.consultantId`),否则 581008。
- 房务管理员/房务组长无论订单归属如何,一律先于其他判定被拒绝为 581045(`OrderViewGuard.assertReadable`,`hl-order-service-v3/src/main/java/com/hulalv/order/core/guard/OrderViewGuard.java:279-281`)。
- 判定顺序:先查订单是否存在(581430)→ 再判角色归属(581008/581045,`CollabService.java:172,174`);非请求上下文(MQ 回放/定时任务/内部 Feign/单测)无角色视为系统态放行。
---
### 2. 打标签弹窗数据 `GET /v3/admin/order/{orderId}/tag-picker`
**VO**: `无请求体 → List<TagPickerItemVO>`
#### 使用场景
订单详情页点「打标签」按钮弹出的选择面板:标签库全集 + 当前订单已挂标记 + 游离标签(已挂但不在库里的)。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| orderId | Path | Long | ✅ | 订单须存在(581401);当前登录角色须对该订单具备归属读权(581008/581045,本次新增) | 订单 ID |
#### 出参 `Result<List<TagPickerItemVO>>`
| 字段 | 类型 | 说明 |
|------|------|------|
| (根) | List<Object> | 弹窗选择项列表;先按标签库排序(isPinned DESC, sortOrder ASC, lastUsedAt DESC),游离标签追加末尾 |
| [].tagName | String | 标签名 |
| [].tagColor | String | HEX 色值,如 #5B8FF9 |
| [].tagScope | String | SYSTEM=系统标签 / PERSONAL=个人标签;游离标签为 null |
| [].isPinned | Boolean | 是否置顶(游离标签为 false) |
| [].sortOrder | Integer | 排序值(游离标签为 null) |
| [].selected | Boolean | 是否已挂在当前订单 |
| [].inLibrary | Boolean | 是否来自标签库(false=游离标签,仅存在于 order_tag 中) |
#### 请求示例
```http
GET /v3/admin/order/2100542584106496001/tag-picker
Authorization: Bearer {token}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": [
{ "tagName": "VIP", "tagColor": "#5B8FF9", "tagScope": "SYSTEM", "isPinned": true, "sortOrder": 1, "selected": true, "inLibrary": true },
{ "tagName": "临时标记", "tagColor": "#FF9900", "tagScope": null, "isPinned": false, "sortOrder": null, "selected": true, "inLibrary": false }
],
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
标签库为空且订单无游离标签时返回空数组:
```json
{ "code": 200, "message": "成功", "data": [], "traceId": null, "success": true }
```
#### 错误响应
本次新增(TEST 实测,CUSTOMIZER 非归属角色实测返回):
```json
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
```
```json
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
```
订单不存在(既有行为,未变):
```json
{ "code": 581401, "message": "订单不存在", "data": null, "traceId": null, "success": false }
```
#### 业务边界
- 放行/拒绝口径与「查订单已挂标签」完全一致(同一个 `OrderViewGuard.assertOrderReadable`,见该接口业务边界第 1 条的角色表)。
- 归属守卫排在 `OperatorHolder.get()` 取当前操作人之前(`CollabService.java:250` 早于 `:252`):`OperatorHolder` 判空只在「拿不到操作人」时拦截,拿得到操作人的越权请求它不管,不能替代归属校验。
---
### 3. 生成电子行程单(对客) `GET /v3/admin/order/{orderId}/itinerary-document`
**VO**: `无请求体 → OrderItineraryDocumentVO`
#### 使用场景
生成对客视角的电子行程单/打印版/核价单(`CUSTOMER`/`CUSTOMER_PRINT`/`CUSTOMER_QUOTE` 三类 `documentType` 共用本接口,前端按值渲染不同模板)。签单(供应商视角 `sign-voucher`)、司机出团单(`print-itinerary`)走另外两个独立接口,不在本次变更范围内。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| orderId | Path | Long | ✅ | 订单须存在(581401);当前登录角色须对该订单具备归属读权(581008/581045,本次新增) | 订单 ID |
| documentType | Query | String | ✅ | `CUSTOMER` / `CUSTOMER_PRINT` / `CUSTOMER_QUOTE`;缺失返回业务码 400 | 文档类型(对客视角) |
| includeResourceDetail | Query | Boolean | ❌ | 默认 true;`CUSTOMER` 场景可传 false 跳过 Feign | 是否调 Feign 拉资源详情,false 时节点 scenicDetail 等字段为 null |
#### 出参 `Result<OrderItineraryDocumentVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| documentType | String | 回显请求的文档类型 |
| header | Object | 抬头信息(订单号等;`CUSTOMER_QUOTE` 才含 totalAmount) |
| travelers | List<Object> | 出行人列表 |
| days | List<Object> | 按天行程(含节点、场景/餐厅详情) |
| hotels | List<Object> | 酒店段列表 |
| feeNotes | List<Object> | 费用说明 |
| supplies | List<Object> | 随行物资 |
| transports | List<Object> | 大交通 |
| summary | Object | 汇总信息 |
| resourceDetailHealth | Object | 资源详情 Feign 健康度报告(降级不阻断) |
| generatedAt | String | 生成时间,格式 yyyy-MM-dd HH:mm:ss |
(完整字段树见既有 Swagger `OrderItineraryDocumentVO`;本次变更不涉及该 VO 任何字段,此表只列顶层结构定位用)
#### 请求示例
```http
GET /v3/admin/order/2100542584106496001/itinerary-document?documentType=CUSTOMER&includeResourceDetail=true
Authorization: Bearer {token}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"documentType": "CUSTOMER",
"header": { "orderNo": "HL2026092012345" },
"travelers": [],
"days": [],
"hotels": [],
"feeNotes": [],
"supplies": [],
"transports": [],
"summary": {},
"resourceDetailHealth": { "feignOk": true, "feignError": null },
"generatedAt": "2026-09-23 20:10:00"
},
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
`includeResourceDetail=false` 或 Feign 调用失败时,各节点的资源详情字段(如 scenicDetail)为 null,`resourceDetailHealth.feignOk=false` 且 `feignError` 非空,接口仍返回 200,不阻断整份文档:
```json
{ "code": 200, "message": "成功", "data": { "resourceDetailHealth": { "feignOk": false, "feignError": "resource-service 调用超时" } }, "traceId": null, "success": true }
```
#### 错误响应
本次新增(TEST 实测,CUSTOMIZER 非归属角色实测返回):
```json
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
```
```json
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
```
`documentType` 缺失(既有行为,未变;TEST 实测确认发生在归属守卫之前,源码为通用 Spring 参数绑定异常处理 `GlobalExceptionHandler#handleMissingParam`):
```json
{ "code": 400, "message": "缺少必要参数: documentType", "data": null, "traceId": null, "success": false }
```
订单不存在(既有行为,未变):
```json
{ "code": 581401, "message": "订单不存在", "data": null, "traceId": null, "success": false }
```
#### 业务边界
- 放行/拒绝口径与前两个接口完全一致。
- `documentType` 缺失的 400 判定在**归属守卫之前**(Spring 参数绑定先于方法体执行),所以「非归属角色 + 不传 documentType」拿到的是 400,不是 581008;这不是本次改动,是既有行为,TEST 已实测确认。
- 归属守卫(`CollabService.java:417`)与下游第 7 步 `OrderDetailService#getServiceStandard → requireOrderById` 里的同一道守卫会被连续调用两次,是有意保留、非冗余:本处这道是本端点自己的,下游那道挂在一个可选步骤上,不能替代(改动下游步骤的人不会打开本文件)。
---
### 4. 订单详情 - 行程安排 Tab `GET /v3/admin/order/{id}/itinerary`
**VO**: `无请求体 → ItineraryVO`
#### 使用场景
订单详情页「行程安排」Tab:配房/配车的需求与实配对照、未失活配车历史等。本次只补充四个价格字段的说明文字,接口行为、归属校验、响应结构均未变——该端点的归属守卫是既有能力,不是本次新增。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| id | Path | Long | ✅ | 订单须存在;当前登录角色须对该订单具备归属读权(既有能力,未变) | 订单 ID |
#### 出参 `Result<ItineraryVO>`(仅列本次文档变更涉及的字段,完整契约不在本次变更范围内)
| 字段 | 类型 | 说明 |
|------|------|------|
| hotelGroup.assignments[].protoPrice | String | 协议价快照(元/间·晚,house protoPrice)。**本次补充说明:这是供应商采购价,不是对客报价** |
| hotelGroup.assignments[].settlementPrice | String | 结算价快照(元/间·晚,house settlementPrice)。**本次补充说明:同样是供应商采购价,不是对客报价** |
| vehicleGroup.assignments[].plannedDailyFee | String | 计划日单价(元/车·天,付给车队的采购价)。**本次补充说明:非对客报价** |
| hotelGroup.requirement.days[].segments[].candidates[].rooms[].protocolPrice | String | 本房型行协议价(元/间·晚,可为 null)。**本次补充说明:这是供应商采购价,不是对客报价** |
#### 请求示例
```http
GET /v3/admin/order/2100542584106496001/itinerary
Authorization: Bearer {token}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"hotelGroup": {
"assignments": [
{ "assignmentId": "700001", "hotelName": "云台山大酒店", "roomType": "大床房", "roomCount": 2, "protoPrice": "588.00", "settlementPrice": "688.00" }
]
},
"vehicleGroup": {
"assignments": [
{ "plannedDailyFee": "800.00", "driverName": "王师傅" }
]
}
},
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
未配房/未配车时 `hotelGroup.assignments`/`vehicleGroup.assignments` 为空数组,各价格字段随之不出现:
```json
{ "code": 200, "message": "成功", "data": { "hotelGroup": { "assignments": [] }, "vehicleGroup": { "assignments": [] } }, "traceId": null, "success": true }
```
#### 错误响应
既有行为,未变:
```json
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
```
#### 业务边界
- 该端点走 `OrderController#getItinerary → OrderDetailService.assembleItinerary → requireOrderById`,仍是 `OrderViewGuard.assertOrderReadable`(`OrderDetailService.java:794-803`),**不是**本次新增,本次改动只补了字段说明。
- 四个价格字段对**团期管理员**(`GROUP_BATCH_MANAGER`,仅限该订单挂在团期下)刻意不脱敏:这是 2026-09-22 管理者拍板的已知设计,理由是团期管理员核对「团期需求」与实配是否对得上,价格本身就是核对基准;同一条定案记在 `OrderViewGuard#assertOrderFinanceReadable` 的 javadoc(`OrderViewGuard.java:164-168`),本端点未被收窄到 `assertOrderFinanceReadable`。
- 四个字段均为供应商采购成本,不是对客报价;本次只是把这条口径写进了字段注释,不是新发现的行为变化。
---
### 5. 按户打回需求 `POST /v3/admin/order/group-batch/{groupBatchId}/requirement/reject`
**VO**: `RejectRequirementReqVO → GroupBatchRequirementRejectRespVO`
#### 使用场景
团期「查看需求」页批量打回:运营勾选若干户,把已提交需求退回定制师重填。本次只补充 `resourceType` 字段的契约说明,请求/响应结构、错误码均未变。
#### 入参
| 字段 | 位置 | 类型 | 必填 | 约束 | 说明 |
|------|------|------|------|------|------|
| Authorization | Header | String | ✅ | - | 管理端登录令牌 |
| groupBatchId | Path | Long | ✅ | 团期须存在且处于可打回阶段(589501) | 团期主订单 ID |
| orderIds | Body | List<Long> | ✅ | 1~200 户,服务端去重 | 被打回的子订单 ID 列表 |
| reason | Body | String | ✅ | ≤500 字 | 打回原因 |
| resourceType | Body | String | ❌ | `HOTEL` / `VEHICLE` / `ALL`,默认 `ALL`;**本次补充说明**:`VEHICLE`/`ALL` 车侧仅覆盖游览车(TRAVEL),不含接送机(TRANSFER) | 打回的资源类型 |
#### 出参 `Result<GroupBatchRequirementRejectRespVO>`
| 字段 | 类型 | 说明 |
|------|------|------|
| groupBatchId | String | 团期聚合主键 |
| requirementConfirmed | Boolean | 团期需求整体确认标记(本端点成功后恒 false) |
| rejected | List<Object> | 本次被打回的需求条目(每户每资源类型一条) |
| rejected[].orderId | String | 子订单 ID |
| rejected[].resourceType | String | 资源类型:HOTEL / VEHICLE |
| rejected[].requirementId | String | 被打回的需求行 ID |
| rejected[].sourceStatus | String | 打回前的需求状态:PENDING_REVIEW / PENDING |
#### 请求示例
```json
{
"orderIds": [60123456789001, 60123456789002],
"reason": "房间需求与套餐不匹配,请重新填写",
"resourceType": "ALL"
}
```
#### 响应示例
```json
{
"code": 200,
"message": "成功",
"data": {
"groupBatchId": "1867000000001",
"requirementConfirmed": false,
"rejected": [
{ "orderId": "60123456789001", "resourceType": "HOTEL", "requirementId": "90011223344", "sourceStatus": "PENDING_REVIEW" }
]
},
"traceId": null,
"success": true
}
```
#### 空数据 / 降级响应
写接口没有空数据场景;所有勾选户都不可打回时整单拒绝(见错误响应 589534),不会返回 `rejected: []` 的成功响应。
#### 错误响应
本次澄清的既有行为(`resourceType=ALL` 且勾选户只有接送机需求):
```json
{ "code": 589534, "message": "子订单 {0} 不属于本团期、已退出或无可打回的需求", "data": null, "traceId": null, "success": false }
```
其余既有错误码未变:
```json
{ "code": 589501, "message": "团期状态不允许当前操作", "data": null, "traceId": null, "success": false }
```
```json
{ "code": 589535, "message": "子订单 {0} 已分房,请先由房务调整配房后再打回", "data": null, "traceId": null, "success": false }
```
#### 业务边界
- **本次澄清、代码逻辑未变**:`resourceType=ALL` 不等于「全部资源」——车侧只走游览车(TRAVEL),接送机(TRANSFER)需求不在本入口可见范围内。一户只有接送机需求时,不是「打回了但没生效」,是该户在候选筛选阶段就没被选中,因「无可打回的需求」落 589534。
- 接送机需求要打回,走单户接口 `POST /v3/admin/order/{id}/vehicle-requirement/reject?kind=TRANSFER`,本次未变。
- 任一户不可打回则整单拒绝、所有户零副作用(含合法户在内);不存在部分成功。
---
## 四、契约约束与正确调用方式(接口类必写)
> 本节只写**后端接受/拒绝 payload 的规则**,不写 UI 渲染建议。
### ✅ 正确 / ❌ 错误 payload 对照
| 场景 | 请求 | 结果 |
|------|------|------|
| ✅ SUPER_ADMIN 读任意订单的标签/弹窗数据/行程单 | 上述三接口任一 | 200 |
| ✅ 定制师读自己归属的订单 | 上述三接口任一,orderId=自己 consultantId 名下订单 | 200 |
| ✅ 团期管理员读团期子订单(`order.groupBatchId != null`) | 上述三接口任一 | 200 |
| ❌ 定制师/客服/运营/财务读他人归属的订单 | 上述三接口任一,orderId=非本人订单 | 581008(本次新增) |
| ❌ 团期管理员读散客单(`order.groupBatchId == null`) | 上述三接口任一 | 581008(本次新增) |
| ❌ 房务管理员/房务组长读任意订单的标签/弹窗数据/行程单 | 上述三接口任一 | 581045(本次新增,前置于归属判定) |
| ❌ `itinerary-document` 不传 `documentType` | 任意角色 | 400「缺少必要参数: documentType」(既有行为,发生在归属守卫之前) |
| ❌ 批量打回 `resourceType=ALL`、勾选户只有接送机需求 | `POST .../requirement/reject` | 589534(既有行为,本次补充契约说明) |
- 三个协作域端点(tags/tag-picker/itinerary-document)的归属判定口径完全一致,判定顺序恒为:订单存在性 → 房务前置拒绝(581045)→ ADMIN/SUPER_ADMIN/VEHICLE_MANAGER 放行 → 团期管理员限团期子订单放行 → 其余角色须为归属定制师,否则 581008(`OrderViewGuard.java:267-298`)。
- `GET /{id}/itinerary` 的归属守卫本次未变(既有能力);仅字段说明变化。
- 批量打回 `resourceType` 字段行为本次未变;仅补充契约说明,防止把 `ALL` 误读为「全部资源类型」。
---
## 五、数据库行为(涉及写操作时必写)
- 无 Flyway migration、无 DDL、无表结构变更。
- 三个协作域端点新增的归属守卫是纯内存态角色/字段判定,复用 Service 层判空后已持有的 `OrderInfo` 实体,零额外 DB 查询。
- 批量打回 `doReject` 的写库范围与事务边界本次未变:仍在团级 `Lock4j` 锁 + `@Transactional(rollbackFor=Exception.class)` 内逐户跑 `rejectRequirementByGroupAdmin` + `groupBatchService.rejectRequirement`;任一户校验失败即整单回滚、零写入。
---
## 六、边界行为
- 非请求上下文(MQ 回放/定时任务/内部 Feign/单测)无角色 → 三个新增守卫的端点与既有的 `/itinerary` 端点均视为系统态直接放行(`OrderViewGuard.assertReadable` 捕获 `IllegalStateException` 后 `return`,`OrderViewGuard.java:271-276`)。
- 房务角色的 581045 判定先于归属判定,与该订单是否属于本人无关——房务管理员/组长在这三个端点上无论如何都拿不到数据,只能通过配房相关接口工作。
- 团期管理员的放行只看 `OrderInfo.getGroupBatchId() != null`(订单主表原始列),散客单一律 581008,即使该团期管理员对其他团期子订单有权限。
- `itinerary-document` 的守卫在方法体第 2 步(订单判空之后)触发,比 `documentType` 缺失的 400 校验晚——所以「非归属角色 + 不传 documentType」拿到的是 400,不是 581008。
---
## 六.6、修改前后对比(修改/删除类接口必写,新增跳过)
### 字段级对比
| 接口 | 改前 | 改后 |
|------|------|------|
| `GET .../tags` | 无归属校验,请求体/响应体结构不变 | 新增 581008/581045 两种拒绝响应,请求体/响应体结构不变 |
| `GET .../tag-picker` | 同上 | 同上 |
| `GET .../itinerary-document` | 同上 | 同上 |
| `GET .../itinerary` 四个价格字段 | `@ApiModelProperty` 文案未标注采购价/对客报价区分 | 文案补充「供应商采购价,非对客报价」;字段名、类型、序列化方式均不变 |
| `resourceType`(打回请求体) | 字段说明未提及 TRAVEL/TRANSFER 边界 | 字段说明补充「ALL/VEHICLE 车侧仅覆盖 TRAVEL,不含 TRANSFER」;`@Pattern` 校验、默认值、字段名均不变 |
### 行为级对比
| 行为 | 改前 | 改后 |
|------|------|------|
| 任意后台角色传任意 orderId 访问已挂标签/打标签弹窗/电子行程单 | 200,直接返回目标订单数据(含他人订单) | 非放行角色 581008 或 581045,读不到他人订单数据 |
| `resourceType=ALL` 批量打回、勾选户只有接送机需求 | 该户因「无可打回的需求」落 589534,行为未变 | 行为完全未变,仅补充契约说明 |
| `/itinerary` 四个价格字段的语义 | 字段名暗示价格但未明确采购价/售价 | 字段注释明确标注为供应商采购价,防止误当对客报价展示 |
---
## 六.7、影响评估(修改/删除类必写)
- **是否破坏向后兼容**:三个协作域端点对非放行角色(多数后台角色,若非订单归属定制师)是破坏性收紧——改前 200 能拿到数据,改后 581008/581045。对归属定制师、`ADMIN`/`SUPER_ADMIN`/`VEHICLE_MANAGER`,以及读团期子订单的团期管理员,行为不变仍 200。`/itinerary` 与批量打回两个纯文档改动零行为影响。
- **前端是否必须同步上线**:三个协作域端点——如果前端当前允许任意角色打开任意订单的标签/打标签弹窗/生成行程单(例如客服临时查看非本人订单),上线后这部分角色会开始收到 581008/581045,前端需要能正确展示失败提示(走通用错误提示即可,不需要特殊 UI,但不能吞掉这个错误)。
- **前端 workaround 清理点**:无——本次是后端补权限收紧,不涉及前端此前绕过某个限制的逻辑。
---
## 七、不影响范围(显式声明, 帮前端/QA 缩小排查面)
- **仅影响**:`GET /v3/admin/order/{orderId}/tags`、`GET /v3/admin/order/{orderId}/tag-picker`、`GET /v3/admin/order/{orderId}/itinerary-document` 三个端点新增归属校验;`GET /v3/admin/order/{id}/itinerary` 与 `POST /v3/admin/order/group-batch/{groupBatchId}/requirement/reject` 仅字段说明文案变化。
- **零影响**:
- `CollabAdminController` 的四个写端点(`addTag`/`deleteTag`/`patchTag`/`replaceTags`):本次未改动,仍走既有的 `OrderViewGuard.assertNotGroupBatchManagerWrite()`(只拒团期管理员写操作,不判定其余角色归属)。**已知缺口**:这四个写端点至今不判订单归属,读面收紧后形成「看不到但能改」,由 #8292 承接(口径未定)。
- `OrderViewGuard.assertOrderFinanceReadable` 覆盖的财务/成本/流水端点:本次未改动其挂载。
- 接送机相关的批量确认入口(`batchConfirmTransfer` 等)与单户打回接口 `POST .../vehicle-requirement/reject?kind=TRANSFER`:本次未变。
- `ItineraryVO`、`RejectRequirementReqVO`、`GroupBatchRequirementRejectRespVO` 的字段名、类型、序列化方式、`@Pattern`/`@Size`/`@NotEmpty` 校验规则:全部未变。
---
## 八、测试环境已验证
部署:hl-order-service-v3 dev-v3 @ `841ea7b06`,2026-09-23 20:07:37 部署,deploy-status STATE=ok,BEHIND 0(读数来源 #8170 评论 #60783)。
真实网关实测(夹具订单 `2100542584106496001`,归属定制师为他人):
```
GET /v3/admin/order/2100542584106496001/tags CUSTOMIZER(非归属) → 581008 ✓
GET /v3/admin/order/2100542584106496001/tag-picker CUSTOMIZER(非归属) → 581008 ✓
GET /v3/admin/order/2100542584106496001/itinerary-document CUSTOMIZER(非归属) → 581008 ✓
GET /v3/admin/order/2100542584106496001/tags SUPER_ADMIN(对照) → 200 ✓
GET /v3/admin/order/2100542584106496001/tag-picker SUPER_ADMIN(对照) → 200 ✓
GET /v3/admin/order/2100542584106496001/itinerary-document SUPER_ADMIN(对照) → 200 ✓
GET /v3/admin/order/2100542584106496001/itinerary-document 不带 documentType → 400,先于守卫触发 ✓
```
单元测试:本单随 PR 新增/改动测试文件 `CollabServiceTest`(+80 行,补三接口归属守卫放行/拒绝用例)、`CollabServiceItineraryDocumentTest`(+60 行,补 itinerary-document 归属守卫用例)、`GroupBatchRequirementServiceTest`(+50 行,补 resourceType 契约用例),随 PR 一并合并。
---
## 十、相关文档
- 关联 Issue: [wx/HL#8170](https://git.1814.love/wx/HL/issues/8170)
- 关联 PR: [wx/HL#8290](https://git.1814.love/wx/HL/pulls/8290)
## 关联 / 联系人
### 链接
- **Issue**: [#8170](https://git.1814.love/wx/HL/issues/8170)
- **PR**: [#8290](https://git.1814.love/wx/HL/pulls/8290)
- **Merge commit**: [841ea7b06](https://git.1814.love/wx/HL/commit/841ea7b06)
### 联系人
- **后端负责人**: @wx
- **前端负责人**: @mmg