# 小程序订单 - 服务详情弹窗接口 + 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<String, Object>` / `List<Map<String, Object>>` 升级为**强类型 VO**。前端 TS 类型提示/自动补全可直接受益。

| 字段 | 旧类型 | 新类型 |
|------|--------|--------|
| `priceBreakdown` | `object` (Map) | `MpPriceBreakdownVO` |
| `serviceItems` | `object[]` (List<Map>) | `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 反序列化对旧的"对象不定键"消费方式零影响。
