diff --git a/changelogs/2026-04/2026-04-18_mp-order-service-detail-and-vo-upgrade.md b/changelogs/2026-04/2026-04-18_mp-order-service-detail-and-vo-upgrade.md new file mode 100644 index 0000000..d0856e1 --- /dev/null +++ b/changelogs/2026-04/2026-04-18_mp-order-service-detail-and-vo-upgrade.md @@ -0,0 +1,574 @@ +# 小程序订单 - 服务详情弹窗接口 + MpOrderDetailVO 字段强类型化 + +- **日期**: 2026-04-18 +- **PR**: + - [#818](https://git.1814.love:8443/wx/HL/pulls/818) MpOrderDetailVO 6 字段强类型化 + - [#853](https://git.1814.love:8443/wx/HL/pulls/853) 补 6 个服务详情透传 + - [#856](https://git.1814.love:8443/wx/HL/pulls/856) 独立 Controller + Swagger 分组 +- **类型**: FEATURE + REFACTOR +- **状态**: 已合并到 dev + 测试环境部署中 +- **服务**: hl-mp-service + hl-order-service-v2 + +--- + +## 一、本次变更汇总 + +### 1. 订单详情 VO 字段强类型化(🔵 前端体验提升,无 Breaking) + +`MpOrderDetailVO`(下单 / 详情 / 编辑 三接口共用返回结构)中的 6 个字段,**字段名完全不变**,仅 JSON Schema 由 `Map` / `List>` 升级为**强类型 VO**。前端 TS 类型提示/自动补全可直接受益。 + +| 字段 | 旧类型 | 新类型 | +|------|--------|--------| +| `priceBreakdown` | `object` (Map) | `MpPriceBreakdownVO` | +| `serviceItems` | `object[]` (List) | `MpServiceItemVO[]` | +| `tripProgress` | `object` (Map) | `MpTripProgressVO` | +| `refundProgress` | `object` (Map) | `MpRefundProgressVO` | +| `travelers` | `object[]` | `MpTravelerVO[]` | +| `todos` | `object[]` | `MpOrderTodoVO[]` | + +### 2. 新增 6 个服务详情弹窗接口(🟢 新功能) + +订单详情页 `serviceItems` 列表里每个条目点击后的弹窗数据源,此前前端调不到,现已对外开放。 + +独立 **Swagger 分组**:`C端 - 订单接口 - 服务包含`(knife4j 左侧菜单)。 + +--- + +## 二、变更接口清单 + +| # | 方法 | 路径 | 说明 | 鉴权 | 缓存 | +|---|------|------|------|------|------| +| 1 | POST | `/mp/order/create` | 创建订单(返回结构升级) | REQUIRED | 否 | +| 2 | GET | `/mp/order/{orderId}` | 订单详情(返回结构升级) | REQUIRED | 否 | +| 3 | PUT | `/mp/order/{orderId}/edit` | 用户编辑订单(返回结构升级) | REQUIRED | 否 | +| 4 | GET | `/mp/order/{orderId}/tickets` | 【新增】门票清单 | REQUIRED | 否 | +| 5 | GET | `/mp/order/{orderId}/hotels` | 【新增】住宿清单 | REQUIRED | 否 | +| 6 | GET | `/mp/order/{orderId}/meals` | 【新增】餐饮清单 | REQUIRED | 否 | +| 7 | GET | `/mp/order/{orderId}/vehicle` | 【新增】用车详情 | REQUIRED | 否 | +| 8 | GET | `/mp/order/{orderId}/guide` | 【新增】领队详情 | REQUIRED | 否 | +| 9 | GET | `/mp/order/{orderId}/photographer` | 【新增】摄影师详情 | REQUIRED | 否 | + +--- + +## 三、订单创建接口(返回结构升级) + +``` +POST /mp/order/create +``` + +### 请求(未变化) + +```json +{ + "productId": "1760000000000001", + "departureDate": "2026-05-01", + "groupBatchId": "1760000000000002", + "tierSeq": 1, + "adultCount": 2, + "childCount": 0, + "youngChildCount": 0, + "babyCount": 0, + "childNeedBed": false, + "roomCount": 1, + "contactName": "张三", + "contactPhone": "13800138000", + "remark": "希望安排靠窗", + "sharerOpenid": "wx_xxxxx" +} +``` + +| 字段 | 类型 | 必填 | 说明 | +|------|------|------|------| +| `productId` | String | 是 | 产品ID | +| `departureDate` | String(yyyy-MM-dd) | 否 | 出发日期;GROUP 产品可不传(从团期取) | +| `groupBatchId` | String | 条件必填 | GROUP 产品必填 | +| `tierSeq` | Integer | 是 | 档位序号,≥1 | +| `adultCount` | Integer | 是 | 成人数,默认 1,≥1 | +| `childCount` | Integer | 否 | 儿童数,≥0 | +| `youngChildCount` | Integer | 否 | 小童数,≥0 | +| `babyCount` | Integer | 否 | 幼童数,≥0 | +| `childNeedBed` | Boolean | 否 | 儿童是否需要床位 | +| `roomCount` | Integer | 否 | 房间数(GROUP 默认 1) | +| `contactName` | String | 是 | 联系人姓名,≤50 字 | +| `contactPhone` | String | 是 | 联系人电话,≤20 字 | +| `remark` | String | 否 | 备注,≤500 字 | +| `sharerOpenid` | String | 否 | 分享人 openid,≤64 字 | + +### 返回(结构升级) + +> **⚠️ 关键**:`POST /mp/order/create`、`GET /mp/order/{id}`、`PUT /mp/order/{id}/edit` 三个接口返回结构**完全一致**,均为 `MpOrderDetailVO`。前端可共用一套类型定义 + 渲染组件。 + +```json +{ + "code": 200, + "message": "成功", + "data": { + "orderId": "2045450312813043713", + "orderNo": "HL20260418183211-6560", + "groupCode": "6560", + "productId": "2045424500610125825", + "productName": "E2E-核心版本-V1", + "productSubtitle": "去草原看日出", + "productCoverUrl": "https://cdn.example.com/cover.jpg", + "productType": "CORE", + "productTypeLabel": "核心产品", + "departureDate": "2026-05-10", + "returnDate": "2026-05-12", + "tripDays": 3, + "tripNights": 2, + "adultCount": 1, + "childCount": 0, + "youngChildCount": 0, + "babyCount": 0, + "createTime": "2026-04-18 18:32:12", + + "status": "PENDING_PAY", + "statusLabel": "待支付", + "displayStatus": "PENDING_PAY", + "displayStatusLabel": "待支付", + "expiryTime": "2026-04-18 20:32:12", + + "totalPrice": 2401280.00, + "paidAmount": 0.00, + "depositAmount": 500.00, + "balanceAmount": 2400780.00, + "paymentType": "DEPOSIT", + "paymentTypeLabel": "订金+尾款", + "payMethodLabel": "微信支付", + "balancePayMethodLabel": "落地后由领队收取", + + "priceBreakdown": { + "items": [ + { "label": "成人", "unitPrice": 2300.00, "quantity": 1, "amount": 2300.00 } + ], + "subtotal": 2300.00, + "discounts": [], + "totalDiscount": 0.00, + "totalPayable": 2300.00 + }, + + "serviceItems": [ + { "type": "HOTEL", "title": "住宿", "summary": "2 晚精选当地酒店", "clickable": true }, + { "type": "MEAL", "title": "餐饮", "summary": "含早餐 2 次 + 特色正餐 1 次", "clickable": true }, + { "type": "VEHICLE", "title": "用车", "summary": "小蒙马车 1 辆", "clickable": true } + ], + "notIncluded": "往返呼伦贝尔大交通、个人消费", + + "tripProgress": null, + "refundProgress": null, + "travelers": [], + "todos": [], + + "contractStatus": "NONE", + "contractStatusLabel": "无合同", + "insuranceStatus": "NONE", + "insuranceStatusLabel": "无保险", + "invoiceStatus": "AFTER_TRIP", + "invoiceStatusLabel": "出行后开", + "reviewed": false, + + "customizerId": "1001", + "customizerName": "admin", + "customizerAvatarUrl": null, + "customizerQrUrl": null, + + "refundPolicy": { + "policyId": 1, + "policyName": "标准退款", + "rules": [] + } + }, + "success": true +} +``` + +### `MpOrderDetailVO` 重点字段类型说明 + +#### priceBreakdown - 价格明细 + +```typescript +interface MpPriceBreakdownVO { + items: MpPriceLineItemVO[]; // 按人群类型的单价明细 + subtotal: number; // 小计(优惠前) + discounts: MpDiscountLineItemVO[];// 优惠明细列表 + totalDiscount: number; // 已优惠总额(负数) + totalPayable: number; // 应付总额 +} + +interface MpPriceLineItemVO { + label: string; // "成人" / "儿童" 等 + unitPrice: number; + quantity: number; + amount: number; +} + +interface MpDiscountLineItemVO { + tag: string; // "早鸟" + description: string; // "提前30天" + amount: number; // 负数 +} +``` + +#### serviceItems - 服务包含摘要 + +```typescript +interface MpServiceItemVO { + type: 'HOTEL' | 'MEAL' | 'VEHICLE' | 'GUIDE' | 'PHOTOGRAPHER' | 'TICKET' | 'INSURANCE'; + title: string; // "住宿" + summary: string; // "2 晚精选当地酒店" + clickable: boolean; // 是否可点击查看详情弹窗(对应下面 6 个弹窗接口) +} +``` + +> 当 `clickable === true` 时,点击该条目调用对应的 `/mp/order/{orderId}/{tickets|hotels|meals|vehicle|guide|photographer}` 接口拉取弹窗详情。映射关系: +> - HOTEL → `/hotels` +> - MEAL → `/meals` +> - VEHICLE → `/vehicle` +> - GUIDE → `/guide` +> - PHOTOGRAPHER → `/photographer` +> - TICKET → `/tickets` + +#### tripProgress - 行程进度 + +```typescript +interface MpTripProgressVO { + currentDay: number; // 当前第几天 + totalDays: number; // 总天数 + progressPercent: number; // 进度百分比(0-100) + todayDestination: string; // 今日目的地 +} +``` + +> **仅行程中状态有值**,其他状态该字段为 `null`(且因 `@JsonInclude(NON_NULL)` 过滤,可能不在 JSON 中出现)。 + +#### refundProgress - 退款进度 + +```typescript +interface MpRefundProgressVO { + steps: MpRefundStepVO[]; // 4 步时间线 + currentStep: number; // 当前活跃步骤索引(0-based) + refundAmount: number; + refundMethod: string; // 例 "原路返回·微信" + refundArrivalTime: string | null; // 已到账才有值 +} + +interface MpRefundStepVO { + title: string; // "提交申请" + description: string; + status: 'COMPLETED' | 'ACTIVE' | 'PENDING'; + time: string | null; // PENDING 时为 null +} +``` + +#### travelers - 出行人列表 + +```typescript +interface MpTravelerVO { + travelerId: number; + name: string; + travelerType: string; // ADULT/CHILD/YOUNG_CHILD/BABY + travelerTypeLabel: string; // "成人" 等中文 + idCardType: string; // ID_CARD/PASSPORT + idCardTypeLabel: string; // "身份证" 等中文 + idCardNo: string; + gender: string; + birthday: string; + phone: string; + nationality: string; + emergencyContact: string; + emergencyPhone: string; + email: string; +} +``` + +#### todos - 待办列表 + +```typescript +interface MpOrderTodoVO { + todoId: number; + todoType: string; + todoTypeLabel: string; + todoLabel: string; + sequence: number; + status: string; + assigneeRoleKey: string; + assigneeRoleLabel: string; + completedAt: string | null; + createTime: string; + blocked: boolean; + blockedReason: string | null; +} +``` + +--- + +## 四、服务详情弹窗接口(6 个新增) + +所有接口签名一致: +- **方法**: GET +- **鉴权**: `Authorization: Bearer {mp-token}`(用户登录态) +- **Path Var**: `orderId` (Long) +- **Query**: 无 +- **失败降级**: 订单服务不可用时返回 `{"code":500, "message":"订单服务不可用,请稍后重试"}` + +### 1. 门票清单 + +``` +GET /mp/order/{orderId}/tickets +``` + +```json +{ + "code": 200, + "data": { + "items": [ + { + "seq": 1, + "scenicName": "莫日格勒河", + "coverUrl": "https://cdn.example.com/scenic/1.jpg", + "dayNumber": 2, + "originalPrice": 80.00, + "included": true + } + ], + "scenicCount": 14, + "totalValue": 340.00 + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `items[]` | MpTicketItemVO[] | 门票列表 | +| `items[].seq` | Integer | 序号 | +| `items[].scenicName` | String | 景点名称 | +| `items[].coverUrl` | String | 景点封面图 | +| `items[].dayNumber` | Integer | 第几天 | +| `items[].originalPrice` | BigDecimal | 门票原价/人 | +| `items[].included` | Boolean | 是否已包含在套餐内 | +| `scenicCount` | Integer | 景点数量 | +| `totalValue` | BigDecimal | 门票总价值/人(快照有 price 才有值) | + +### 2. 住宿清单 + +``` +GET /mp/order/{orderId}/hotels +``` + +```json +{ + "code": 200, + "data": { + "items": [ + { + "hotelId": "2023714929877450753", + "hotelName": "呼伦贝尔香格里拉大酒店", + "coverUrl": "https://cdn.example.com/hotel/1.jpg", + "assignmentDate": "2026-05-10", + "roomType": "大床房" + } + ], + "nightCount": 2, + "roomTypeStats": [ + { "roomType": "大床房", "count": 1 } + ] + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `items[]` | MpHotelItemVO[] | 每晚酒店列表(按入住日期升序) | +| `items[].hotelId` | Long | 酒店ID | +| `items[].hotelName` | String | 酒店名称 | +| `items[].coverUrl` | String | 酒店封面(产品服务扩展后可用) | +| `items[].assignmentDate` | String(yyyy-MM-dd) | 入住日期 | +| `items[].roomType` | String | 房型 | +| `nightCount` | Integer | 总晚数 | +| `roomTypeStats[]` | MpRoomTypeStatVO[] | 按房型统计 | +| `roomTypeStats[].roomType` | String | 房型 | +| `roomTypeStats[].count` | Integer | 房间数 | + +### 3. 餐饮清单 + +``` +GET /mp/order/{orderId}/meals +``` + +```json +{ + "code": 200, + "data": { + "specialMeals": [ + { + "dayNumber": 2, + "mealName": "蒙古包午餐·手把肉宴", + "mealType": "午餐", + "description": "含奶茶/手把肉/烤包子" + } + ], + "stats": { + "breakfastCount": 2, + "specialDinnerCount": 1, + "regularDinnerCount": 3 + } + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `specialMeals[]` | MpMealItemVO[] | 特色正餐列表(含描述的餐) | +| `specialMeals[].dayNumber` | Integer | 第几天 | +| `specialMeals[].mealName` | String | 餐名 | +| `specialMeals[].mealType` | String | 餐型:早餐/午餐/晚餐 | +| `specialMeals[].description` | String | 菜品描述 | +| `stats.breakfastCount` | Integer | 早餐数量 | +| `stats.specialDinnerCount` | Integer | 特色正餐数量(有描述的) | +| `stats.regularDinnerCount` | Integer | 日常正餐数量 | + +### 4. 用车详情 + +``` +GET /mp/order/{orderId}/vehicle +``` + +```json +{ + "code": 200, + "data": { + "vehicleType": "小蒙马车", + "vehicleCount": 1, + "coverUrl": "https://cdn.example.com/vehicle/xiaomma.jpg", + "plateNumber": "蒙E12345", + "driverName": "张师傅", + "driverPhone": "138****5678", + "matched": true + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `vehicleType` | String | 车型名称 | +| `vehicleCount` | Integer | 车辆数 | +| `coverUrl` | String | 车型封面(产品服务扩展后可用) | +| `plateNumber` | String | 车牌号(**仅出行前后可见**,其他时间为空) | +| `driverName` | String | 司机姓名 | +| `driverPhone` | String | 司机电话(脱敏) | +| `matched` | Boolean | 是否已匹配车辆 | + +### 5. 领队详情 + +``` +GET /mp/order/{orderId}/guide +``` + +```json +{ + "code": 200, + "data": { + "staffId": "5001", + "name": "巴特尔", + "avatarUrl": "https://cdn.example.com/avatar/5001.jpg", + "role": "LEADER", + "roleLabel": "领队", + "phone": "138****5678", + "remark": "10 年草原线路经验,会蒙语", + "assigned": true + } +} +``` + +| 字段 | 类型 | 说明 | +|------|------|------| +| `staffId` | Long | 员工ID | +| `name` | String | 姓名 | +| `avatarUrl` | String | 头像(员工档案扩展后可用) | +| `role` | String | 角色代码 | +| `roleLabel` | String | 角色中文 | +| `phone` | String | 电话(脱敏) | +| `remark` | String | 备注/简介 | +| `assigned` | Boolean | 是否已分配 | + +### 6. 摄影师详情 + +``` +GET /mp/order/{orderId}/photographer +``` + +响应字段与领队详情完全一致,`role` 为 `"PHOTOGRAPHER"`、`roleLabel` 为 `"摄影师"`。 + +--- + +## 五、前端接入建议 + +### Swagger 入口 + +- **mp-service 独立**: http://localhost:8085/doc.html (本地开发) +- **网关聚合**: http://localhost:8080/doc.html → 切到 `hl-mp-service` +- **测试环境**: https://api.test.1814.love/doc.html → 切到 `hl-mp-service` + +左侧菜单新增分组:**「C端 - 订单接口 - 服务包含」**,6 个弹窗接口在该分组下。 + +### 订单详情页渲染建议 + +```javascript +async function openOrderDetail(orderId) { + const detail = await request.get(`/mp/order/${orderId}`); + + // 1. 渲染基础信息(orderNo/产品/金额/状态/时间轴) + renderHeader(detail.data); + + // 2. 价格明细 - 用 priceBreakdown.items / discounts 渲染 + renderPriceBreakdown(detail.data.priceBreakdown); + + // 3. 服务包含列表 + detail.data.serviceItems.forEach(item => { + renderServiceCard(item, { + onClick: item.clickable ? () => openServiceDetail(orderId, item.type) : null + }); + }); +} + +// 类型到接口路径的映射 +const SERVICE_DETAIL_API = { + HOTEL: 'hotels', MEAL: 'meals', VEHICLE: 'vehicle', + GUIDE: 'guide', PHOTOGRAPHER: 'photographer', TICKET: 'tickets' +}; + +async function openServiceDetail(orderId, type) { + const path = SERVICE_DETAIL_API[type]; + if (!path) return; // 非 clickable 类型(如 INSURANCE) + const data = await request.get(`/mp/order/${orderId}/${path}`); + showServiceDialog(type, data.data); +} +``` + +--- + +## 六、回归验证 + +测试环境部署完成后,用 mp token 依次调用: + +```bash +# 1. 拿个已绑定的订单详情,确认 6 个强类型字段结构 +curl "https://api.test.1814.love/mp/order/{orderId}" \ + -H "Authorization: Bearer {mp-token}" + +# 2. 6 个弹窗接口(对应 serviceItems 里 clickable=true 的条目) +for path in tickets hotels meals vehicle guide photographer; do + curl "https://api.test.1814.love/mp/order/{orderId}/$path" \ + -H "Authorization: Bearer {mp-token}" +done +``` + +**预期**: +- 订单详情返回的 `priceBreakdown` 是对象而非无结构 Map,`serviceItems` 是结构化数组 +- 6 个弹窗接口均返回 `code=200` + 对应 VO 结构,不再出现 403 或路径不存在 + +--- + +## 七、不兼容变更 + +**无**。字段名与语义保持不变,仅 JSON Schema 类型由 Map 升级为结构化对象;Jackson 反序列化对旧的"对象不定键"消费方式零影响。