# feat(product-v2): 产品行程天数支持拖拽重排

> **仓库**: HL (后端 hl-product-service-v2)
> **关联 PR/Issue**: PR #2926, Closes #2925
> **日期**: 2026-05-23
> **影响范围**: 新增 admin 端点,前端需新增拖动 UI 交互
> **接收方**: mmg (前端)
> **前端**: **需要实现拖动 UI**

---

## 🎯 业务背景

产品设计页 Step2「行程」标签,目前每一天的内容只能整体保存,**没法拖动整天重排**。
本次后端新增专用 reorder 端点,前端实现拖动后调用即可。

典型场景:
- 原 D1 和 D2 整天对调 (D1↔D2)
- 原 D1 拖到第 3 天位置 (D1→D3, 原 D2/D3 自动上移)
- 6 天行程整体倒序 (D1↔D6, D2↔D5, D3↔D4)

---

## 一、新增 endpoint

### `POST /admin/product/item/{productId}/itinerary/reorder`

**请求体**:

```json
{
  "items": [
    {"dayId": 2043611284271677442, "newDayNumber": 1},
    {"dayId": 2043611284271677441, "newDayNumber": 2},
    {"dayId": 2043611284267483139, "newDayNumber": 3},
    {"dayId": 2043611284267483138, "newDayNumber": 4},
    {"dayId": 2043611284263288834, "newDayNumber": 5},
    {"dayId": 2043611284259094529, "newDayNumber": 6}
  ]
}
```

**字段约束** (前端必须遵守,否则后端返业务错码):

| 字段 | 类型 | 必填 | 说明 |
|------|------|------|------|
| `items` | array | ✅ | 重排清单,**必须覆盖该产品当前所有未删除的天**(少传/多传都拒绝) |
| `items[].dayId` | Long | ✅ | 行程天 ID (从 `GET /admin/product/item/{id}/itinerary` 接口取) |
| `items[].newDayNumber` | int | ✅ | 新天序号 (1-based),所有 newDayNumber 必须组成 1..N 连续整数无重 |

**响应**:

| 场景 | code | message |
|------|------|---------|
| 成功 | 200 | `行程天数重排成功` |
| 产品状态不允许 (如 PUBLISHED/PENDING_REVIEW) | **430110** | `当前状态不允许行程重排, 请先下架或撤回审批` |
| items 与当前行程不匹配 (size 不等 / dayId 不全) | **430111** | `重排清单与当前行程不匹配, 请刷新页面后重试` |
| newDayNumber 不是 1..N 连续整数 (跳号/重复) | **430112** | `新天序号必须为 1..N 连续无重` |
| 5 秒内同用户重复点击 | 100502 | `请勿重复提交` (`@Idempotent` 防抖) |

---

## 🚨 前端需要做的事

### 1. 拖动 UI

参考"待办列表拖动"的常见库 (Vue 端 `vuedraggable` / React 端 `react-beautiful-dnd` 等),把行程按天列出,允许整天上下拖动。

### 2. 调 reorder 端点

拖完一次就调一次后端,**不需要等用户点保存按钮**(后端已有 5s 幂等防抖)。

```ts
const items = orderedDays.map((day, idx) => ({
  dayId: day.dayId,
  newDayNumber: idx + 1  // 拖动后新序号 = 数组索引 + 1
}));

await axios.post(`/admin/product/item/${productId}/itinerary/reorder`, { items });
// 成功后刷新当前页 itinerary,展示新序号
```

### 3. 错码处理

| code | 用户提示 |
|------|---------|
| 430110 | 当前状态不允许行程重排,请先下架或撤回审批 |
| 430111 | 重排清单与当前行程不匹配,请刷新页面后重试 |
| 430112 | (理论上前端不会出这个,如果出说明前端拖动逻辑有 bug) |
| 100502 | 操作太快,请稍后再试 |

### 4. UX 建议

- **乐观更新**:用户拖完先在本地更新天序号,再调后端,失败时回滚
- **不要每次拖动都调后端**:用户连续拖几次可能撞 5s 幂等,可以本地 debounce 500ms 再调
- **状态判断**:产品状态非可编辑时禁用拖动手势 (颜色置灰/拖不动)

### 5. 哪些状态可以拖

后端通过 `EDITABLE_STATUSES = {DRAFT, REJECTED, UNPUBLISHED, COMPLETED}` 判定,**PUBLISHED 等其他状态会被 430110 拒绝**。前端最好提前判断 product.status 决定是否启用拖动手势,避免无谓 API 调用。

---

## 二、后端核心实现 (仅供 mmg 了解,前端无需关心)

### Service 主流程 `ProductItineraryService.reorderDays`

1. 状态校验 (复用 `checkEditable`)
2. `@Idempotent(timeout=5)` 5 秒防同用户重复点击
3. 三道参数校验 (items 数量 / dayId 集合 / newDayNumber 连续无重)
4. 事务两阶段更新避免索引冲突:
   - Step1: 临时把所有 `day_number` 改为负数 (`1..N → -1..-N`)
   - Step2: 按 items 映射改回正数 newDayNumber
5. **同步冗余字段** `product_day_hotel.day_number` (内部聚合接口靠它分组)
6. 写 admin 操作日志,`changed_fields="itinerary_order"`

### 子表关联

- `product_itinerary_node` / `product_route_point` 通过 `day_id` 关联,**零影响**
- `product_day_hotel` 同步 `day_number`

---

## 三、测试服真测记录

部署 product-v2 双实例 (rolling-deploy 2026-05-23 09:15) 后真测:

| # | 场景 | 实测 |
|---|------|------|
| T1 | DRAFT 6 天产品 (`2043595307274436609`) D1↔D6/D2↔D5/D3↔D4 全倒序 | ✅ 200 + DB 验证 day_id 不动 day_number 完美倒序 |
| T2 | PUBLISHED 产品 (`2043604279016443905`) 调 reorder | ✅ 430110 状态拒绝 |
| T3 | DRAFT 5 天产品 items 只传 4 个 | ✅ 430111 数量不匹配 |
| T4 | DRAFT 5 天产品 newDayNumber 跳号 (1,3,4,5,6 没 2) | ✅ 430112 新天序号必须为 1..N 连续无重 |
| T5 | DRAFT 6 天 + 5 hotel 产品 (`2044280877378134017`) 全倒序 | ✅ itinerary day_number 全反 + hotel day_number 完美同步 |

单测:`ProductItineraryServiceReorderTest` 10 个 + 既有 `ProductItineraryServiceTest` 39 个 = **49 / 49 全绿**

---

## 四、不在本期范围

- UNIQUE INDEX `(product_id, day_number)` — 等数据 audit 无重复后单独 PR (本期事务两步法已能容忍未来加这个索引)
- 快照刷新 — 拖动只影响编辑态,订单/小程序看的是订单快照,无影响
- 前端 UI 实现 — mmg 实现

---

## 五、联系人

后端: wx (呼籁旅行)
前端: mmg

如有疑问可在 [#2925](https://git.1814.love:8443/wx/HL/issues/2925) 评论区留言。
