# 小程序填写返程信息: 校验前置 mp 端 + 错误信息友好化（前端有 BUG 待排查）

> **服务**: hl-mp-service (端口 8085)
> **PR**: #1830 (已合并 dev, 待部署 prod)
> **Issue**: #1828 (P0-紧急, 正式环境)
> **日期**: 2026-05-07
> **影响范围**: 小程序「填写返程信息」页（自驾/飞机/火车 × 一起返程/分批返程 全部场景）
> **@ 前端**: mmg

---

## ⚠️ 关键信息

正式环境用户在小程序「填写返程信息」点「确认提交」后弹 toast：

> batchReplace.reqs[0].travelerIds: 出行人列表不能为空

用户 UI 上**已经勾选了「蒋桂龙」乘坐人员**，但后端收到的 `travelerIds` 是空 — 这是一个**双重 BUG**：

1. **前端 BUG（mmg 必修）**：hl-mini 小程序 `_buildTogetherPlan` 把勾选的 traveler 的 `id` 装进请求 body 时，因 ID 字段名漂移导致最终装入的是占位符 `order-N` 被过滤，致 travelerIds 空数组
2. **后端友好化（本 PR 修）**：`MpArrivalController.batchReplace` 用 `@Valid List<...>` 不递归到元素，校验穿透到 internal controller 才被 `@Validated` 触发，错误信息暴露内部参数名 `batchReplace.reqs[0]`

---

## 一、后端变更（本 PR 已合 dev）

### 1.1 接口契约

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 全量替换返程批次 | POST | `/mp/order/{orderId}/arrival/batch` | **错误信息变更（不破坏契约）** | body 形状不变；mp 端前置校验，错误信息中文友好 |

### 1.2 新错误码

| Code | Message | 触发条件 |
|------|---------|----------|
| `620001` | 请添加至少一批返程信息 | body 是空 List 或 null |
| `620002` | 第 N 批返程信息：请选择至少一位乘坐人员 | body[N-1].travelerIds 为 null 或空 List |

### 1.3 旧错误信息（部署前）vs 新错误信息（部署后）

| 场景 | 部署前 | 部署后 |
|------|--------|--------|
| body 空 | `batchReplace.reqs: 不能为空` | `请添加至少一批返程信息` |
| 首批空 travelerIds | `batchReplace.reqs[0].travelerIds: 出行人列表不能为空` | `第 1 批返程信息：请选择至少一位乘坐人员` |
| 第二批空 travelerIds | `batchReplace.reqs[1].travelerIds: 出行人列表不能为空` | `第 2 批返程信息：请选择至少一位乘坐人员` |

### 1.4 服务端日志增强

mp controller 抛出校验失败前会 log.warn：
```
mp/arrival/batch 校验失败 orderId={} userId={} bodySize={} 元素摘要={}
```
（元素摘要含 direction / transportType / travelerIds.size 协助排查前端实际发出 body）

部署后 mmg 可以让运维拉 hl-mp-service WARN 日志看真实 body 形状（每元素含 direction/transportType/travelerIds 是否为空）。

---

## 二、前端待处理（mmg 必修）

### 2.1 嫌疑代码位置

`hl-mini/packages/trip/arrival/arrival.js`：

**行 88（`_loadPassengers`）**：
```js
id: String(t.orderTravelerId || t.travelerId || t.id || `order-${idx}`)
```

**行 681-686（`_buildTogetherPlan`）**：
```js
const selected = this.data.passengers.filter(p => p.checked && !p._isLocal);
const travelerIds = selected
  .map(p => String(p.id || ''))
  .filter(id => id && !id.startsWith('order-') && !id.startsWith('local-'));
```

### 2.2 排查步骤（建议）

1. 在 `_buildTogetherPlan` 进入时打日志：
   ```js
   console.log('[arrival] passengers=', this.data.passengers, 'selected=', selected, 'travelerIds=', travelerIds);
   ```
2. 复现「蒋桂龙」场景（正式订单），观察 passenger 对象的 `id` 字段真实值：
   - **如果是 `order-0` 这种占位符** → `_loadPassengers` 三个候选字段名（`orderTravelerId / travelerId / id`）都没拿到值，对照后端 `getOrderDetail` 接口返回的 `travelers[]` 元素**真实字段名**与三候选是否对齐
   - **如果是雪花 ID** → `_buildTogetherPlan` 的 filter 逻辑是否有别的过滤条件把它排掉
3. 对照后端 VO（`hl-mp-service` 的 `MpOrderTravelerVO`）确认实际字段名 — 必要时让我贴出来

### 2.3 separate 模式（多批次返程）

`arrival.js` L711 `_buildSeparatePlans` 取 `b.travelerIds || []`，L717 已有"第 N 批缺少出行人"前置 toast 拦截 — 理论上不会发到后端。但若有路径绕过此 toast，本次后端校验是兜底。

---

## 三、数据库行为

只读校验，不写库。校验失败立即抛 BusinessException 不进入 Feign 调 order-v2。

---

## 四、边界行为

- body=null → 620001 "请添加至少一批返程信息"
- body=[] → 620001
- body=[{travelerIds:null}] → 620002 "第 1 批..."
- body=[{travelerIds:[]}] → 620002
- body=[{travelerIds:[10001]}, {travelerIds:[]}] → 620002 "第 2 批..."
- body=[{travelerIds:[10001]}, {travelerIds:[10002]}] → 走 Feign 到 internal，正常处理

---

## 五、不影响范围

- 仅 `POST /mp/order/{orderId}/arrival/batch` 一个接口
- mp create / update / delete / list 接口 0 改动
- internal controller `@Validated` 兜底仍在，admin / 其他客户端调 internal 仍受校验
- API 契约（body 形状）不变，前端无需改请求结构

---

## 六、测试

- 单测: `MpArrivalControllerTest` **9/9 全绿** (5 原有 + 4 新增反例：空 body / null body / 首批空 travelerIds / 第二批空 travelerIds)
- 测试服 round-trip: 待 dev 部署测试服后由 QA 验证
- prod 部署: 由运维管理员排期 (本 PR 不动 prod)

---

## 七、相关文档

- 关联 Issue: [wx/HL#1828](https://git.1814.love:8443/wx/HL/issues/1828)
- 关联 PR: [wx/HL#1830](https://git.1814.love:8443/wx/HL/pulls/1830)
