32 KiB
schema, ticket, title, consumer, author, change_type, backend_status, gateway_status, frontend_status, frontend_owner, frontend_ref, target_release, verified_at, status_note, updated_at, base
| schema | ticket | title | consumer | author | change_type | backend_status | gateway_status | frontend_status | frontend_owner | frontend_ref | target_release | verified_at | status_note | updated_at | base |
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| hl-changelog/v2 | 8170 | 订单协作域三只读端点补订单归属守卫(新增返回 581008/581045);行程价格字段与团期批量打回契约补充说明 | admin | wx(GIT) | 修改接口 | deployed | verified | not_required | 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 两处仅补充字段说明文案,接口行为、请求/响应结构均未变。;前端判 not_required:GET /tags 与 itinerary-document 前端零调用;tag-picker 唯一调用点 TagPickerModal catch 透 err.message 不吞(581008/581045 走通用 toast);价格字段/批量打回两文档补充零行为变化 | 2026-09-23 | 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 | 当前订单已挂标签列表(扁平,不分类型) |
| 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 |
请求示例
GET /v3/admin/order/2100542584106496001/tags
Authorization: Bearer {token}
响应示例
{
"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:
{ "code": 200, "message": "成功", "data": { "attached": [] }, "traceId": null, "success": true }
错误响应
本次新增(TEST 实测,夹具订单 2100542584106496001,归属定制师为他人;CUSTOMIZER 非归属角色实测返回):
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
房务管理员/房务组长(ROOM_MANAGER/house_keeper_lead)先于归属判定被拒绝:
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
订单不存在(既有行为,未变):
{ "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 | 弹窗选择项列表;先按标签库排序(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 中) |
请求示例
GET /v3/admin/order/2100542584106496001/tag-picker
Authorization: Bearer {token}
响应示例
{
"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
}
空数据 / 降级响应
标签库为空且订单无游离标签时返回空数组:
{ "code": 200, "message": "成功", "data": [], "traceId": null, "success": true }
错误响应
本次新增(TEST 实测,CUSTOMIZER 非归属角色实测返回):
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
订单不存在(既有行为,未变):
{ "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 | 出行人列表 |
| days | List | 按天行程(含节点、场景/餐厅详情) |
| hotels | List | 酒店段列表 |
| feeNotes | List | 费用说明 |
| supplies | List | 随行物资 |
| transports | List | 大交通 |
| summary | Object | 汇总信息 |
| resourceDetailHealth | Object | 资源详情 Feign 健康度报告(降级不阻断) |
| generatedAt | String | 生成时间,格式 yyyy-MM-dd HH:mm:ss |
(完整字段树见既有 Swagger OrderItineraryDocumentVO;本次变更不涉及该 VO 任何字段,此表只列顶层结构定位用)
请求示例
GET /v3/admin/order/2100542584106496001/itinerary-document?documentType=CUSTOMER&includeResourceDetail=true
Authorization: Bearer {token}
响应示例
{
"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,不阻断整份文档:
{ "code": 200, "message": "成功", "data": { "resourceDetailHealth": { "feignOk": false, "feignError": "resource-service 调用超时" } }, "traceId": null, "success": true }
错误响应
本次新增(TEST 实测,CUSTOMIZER 非归属角色实测返回):
{ "code": 581008, "message": "无权查看此订单", "data": null, "traceId": null, "success": false }
{ "code": 581045, "message": "房务角色无权查看订单详情,房务仅可配房", "data": null, "traceId": null, "success": false }
documentType 缺失(既有行为,未变;TEST 实测确认发生在归属守卫之前,源码为通用 Spring 参数绑定异常处理 GlobalExceptionHandler#handleMissingParam):
{ "code": 400, "message": "缺少必要参数: documentType", "data": null, "traceId": null, "success": false }
订单不存在(既有行为,未变):
{ "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)。本次补充说明:这是供应商采购价,不是对客报价 |
请求示例
GET /v3/admin/order/2100542584106496001/itinerary
Authorization: Bearer {token}
响应示例
{
"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 为空数组,各价格字段随之不出现:
{ "code": 200, "message": "成功", "data": { "hotelGroup": { "assignments": [] }, "vehicleGroup": { "assignments": [] } }, "traceId": null, "success": true }
错误响应
既有行为,未变:
{ "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 | ✅ | 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 | 本次被打回的需求条目(每户每资源类型一条) |
| rejected[].orderId | String | 子订单 ID |
| rejected[].resourceType | String | 资源类型:HOTEL / VEHICLE |
| rejected[].requirementId | String | 被打回的需求行 ID |
| rejected[].sourceStatus | String | 打回前的需求状态:PENDING_REVIEW / PENDING |
请求示例
{
"orderIds": [60123456789001, 60123456789002],
"reason": "房间需求与套餐不匹配,请重新填写",
"resourceType": "ALL"
}
响应示例
{
"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 且勾选户只有接送机需求):
{ "code": 589534, "message": "子订单 {0} 不属于本团期、已退出或无可打回的需求", "data": null, "traceId": null, "success": false }
其余既有错误码未变:
{ "code": 589501, "message": "团期状态不允许当前操作", "data": null, "traceId": null, "success": false }
{ "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
- 关联 PR: wx/HL#8290