# 小程序端 - 新增小蒙马产品全部列表接口

- **日期**: 2026-04-18
- **PR**: [#847](https://git.1814.love:8443/wx/HL/pulls/847) (Closes #843)
- **类型**: FEATURE
- **状态**: 已合并到 dev + 测试环境部署中
- **服务**: hl-mp-service + hl-product-service-v2

---

## 一、功能说明

### 业务场景
小程序「**小蒙马专题页**」需要一次性铺满展示**全部已上架的小蒙马（GROUP）产品**：

- 不分页（瀑布流/卡片墙一屏看全）
- 每个产品卡片要直接显示**最近的可售团期**（出发日期、起步价、剩余名额）
- 支持关键词模糊搜索（产品名 / 副标题 / 标签）

### 设计要点

| 维度 | 设计 | 说明 |
|------|------|------|
| **是否分页** | 否 | hard cap 100 条（业务上小蒙马产品总数远 < 100，专题页一次性渲染） |
| **排序** | `sort_order ASC, product_id DESC` | 运营通过 `sort_order` 控制置顶；同序号按新产品在前 |
| **缓存** | 服务端 600s TTL | `@MpCache(prefix="product:group:list:", ttl=600)`，发布/下架后 10 分钟内生效 |
| **batches 注入** | 与 `/mp/product/list` **完全一致** | 复用 `injectBatchesForGroupItems` 私有方法，并行 Feign 调用，单个失败不影响整体 |
| **productId 类型** | String | `@JsonSerialize(ToStringSerializer)` 防 JS 雪花 ID 精度丢失 |
| **详情接口** | **零改动** | 直接复用 `GET /mp/product/{productId}`（详见下方"重要提示"） |
| **N+1 防护** | 4 步批量查询 | basic 列表 + paymentType 批量 + 起步价日历批量 + lineName 批量 |
| **权限** | OPTIONAL | 无需登录即可访问 |

---

## 二、变更接口清单

| # | 方法 | 路径 | 说明 | 鉴权 | 缓存 |
|---|------|------|------|------|------|
| 1 | GET | `/mp/product/group/list` | 小蒙马产品全部列表（不分页 + batches） | OPTIONAL | 600s |

---

## 三、接口详细定义

### 1. 小蒙马产品全部列表

```
GET /mp/product/group/list
```

#### 鉴权
- **OPTIONAL**：登录与未登录均可访问，逻辑无差异
- 已加入网关白名单，可直接调用

#### 请求参数

| 参数 | 位置 | 类型 | 必填 | 说明 |
|------|------|------|------|------|
| `keyword` | query | String | 否 | 模糊搜索关键词（匹配产品名/副标题/标签） |

#### 请求示例

```
GET /mp/product/group/list
GET /mp/product/group/list?keyword=亲子
```

#### 完整响应示例

```json
{
  "code": 200,
  "message": "success",
  "data": [
    {
      "productId": "120000000000001",
      "productType": "GROUP",
      "name": "草原星空亲子3日游",
      "subtitle": "8人精品小团·1对1管家",
      "coverImageUrl": "https://oss.example.com/cover.jpg",
      "tripDays": 3,
      "tripNights": 2,
      "lineName": "亲子草原",
      "tags": ["亲子", "星空"],
      "seasons": ["summer", "autumn"],
      "startPrice": 3980.00,
      "startPriceLabel": "¥3980起/人",
      "paymentType": "DEPOSIT",
      "sortOrder": 10,
      "batches": [
        {
          "batchId": "9001000000000001",
          "departureDate": "2026-05-01",
          "batchLabel": "五一团",
          "batchName": "草原星空亲子3日游 5月1日团",
          "batchStatus": "ENROLLING",
          "startingPrice": 3980.00,
          "enrolledCount": 6,
          "remainingSlots": 2,
          "isConfirmed": false
        },
        {
          "batchId": "9001000000000002",
          "departureDate": "2026-05-15",
          "batchLabel": "5月中旬团",
          "batchName": "草原星空亲子3日游 5月15日团",
          "batchStatus": "CONFIRMED",
          "startingPrice": 3980.00,
          "enrolledCount": 8,
          "remainingSlots": 0,
          "isConfirmed": true
        }
      ]
    },
    {
      "productId": "120000000000002",
      "productType": "GROUP",
      "name": "呼伦贝尔大环线4日游",
      "subtitle": "深度纯玩·全程5星住宿",
      "coverImageUrl": "https://oss.example.com/cover2.jpg",
      "tripDays": 4,
      "tripNights": 3,
      "lineName": "呼伦贝尔环线",
      "tags": ["纯玩", "5星"],
      "seasons": ["summer"],
      "startPrice": 5680.00,
      "startPriceLabel": "¥5680起/人",
      "paymentType": "FULL",
      "sortOrder": 20,
      "batches": []
    }
  ]
}
```

> **空数据**：当不存在已上架 GROUP 产品时，`data` 返回 `[]`（**不是 null**）。
> **batches 为空**：某产品当前无可售团期时，该产品的 `batches` 返回 `[]`。

#### 响应字段说明

| 字段 | 类型 | 必有 | 说明 |
|------|------|------|------|
| `data[].productId` | **String** | 是 | 产品ID（**字符串**，由后端 `ToStringSerializer` 序列化，前端**禁止用 Number 接收**） |
| `data[].productType` | String | 是 | 产品类型，固定值 `GROUP`（小蒙马） |
| `data[].name` | String | 是 | 产品名称 |
| `data[].subtitle` | String | 否 | 副标题/卖点（一行短文案） |
| `data[].coverImageUrl` | String | 否 | 封面图 URL（OSS CDN 地址） |
| `data[].tripDays` | Integer | 是 | 行程天数 |
| `data[].tripNights` | Integer | 是 | 行程晚数 |
| `data[].lineName` | String | 否 | 产品线（主题）名称 |
| `data[].tags` | List\<String\> | 否 | 标签数组（如 `["亲子","星空"]`） |
| `data[].seasons` | List\<String\> | 否 | 适合季节数组，字典 `product_season` |
| `data[].startPrice` | BigDecimal | 否 | 起步价（元，未来日期最低成人售价） |
| `data[].startPriceLabel` | String | 否 | 起步价文案（用于卡片标价，如 `¥3980起/人`） |
| `data[].paymentType` | String | 是 | 支付方式，字典 `payment_type` |
| `data[].sortOrder` | Integer | 否 | 运营排序号（值越小越靠前） |
| `data[].batches` | List\<Object\> | 是 | **可售团期列表（即使为空也返回 `[]`）**，结构见下表 |

#### `batches[]` 字段说明（与 `/mp/product/list` 完全一致）

| 字段 | 类型 | 说明 |
|------|------|------|
| `batches[].batchId` | String/Long | 团期 ID（与 v1 团期接口一致，建议用 String 接收） |
| `batches[].departureDate` | String | 出发日期 `yyyy-MM-dd`（**列表已按出发日期升序**） |
| `batches[].batchLabel` | String | 团期标签（如 `五一团` `暑假首发` 等运营自定义） |
| `batches[].batchName` | String | 团期完整名称 |
| `batches[].batchStatus` | String | 团期状态，字典 `batch_status` |
| `batches[].startingPrice` | BigDecimal | 团期起步价（元） |
| `batches[].enrolledCount` | Integer | 已报名人数 |
| `batches[].remainingSlots` | Integer | 剩余名额 |
| `batches[].isConfirmed` | Boolean | 是否已成团 |

---

## 四、重要提示给前端

### 1. 详情接口零改动 — 直接复用现有接口

> **不要再问"小蒙马是不是要专属详情接口"，没有，也不需要。**

```
GET /mp/product/{productId}
```

这个接口**已经支持 GROUP 产品**，会自动注入 `batches` 字段（详情接口 notes 已写明）。

**前端动作**：
- 列表页：调用 `GET /mp/product/group/list` 拿到 productId
- 详情页：直接 `GET /mp/product/{productId}`（productId 用 String 拼接，**不要 parseInt**）
- 详情返回结构与现有产品详情完全一致，GROUP 产品的 `batches` 字段在 detail VO 中已存在

### 2. productId 必须用 String 处理

后端 VO 字段：

```java
@JsonSerialize(using = ToStringSerializer.class)
private Long productId;
```

序列化结果：`"productId": "120000000000001"` （**带引号的字符串**）

前端**禁止**：

```javascript
// ❌ 错误：JS Number 最大安全整数 2^53，会精度丢失
const id = Number(item.productId);

// ✅ 正确：直接用 String
const id = item.productId;
fetch(`/mp/product/${id}`);
```

### 3. 与 `/mp/product/list?productType=GROUP` 的差异

| 维度 | `/mp/product/list?productType=GROUP` | `/mp/product/group/list` |
|------|--------------------------------------|--------------------------|
| 分页 | 有（page+pageSize） | **无**（hard cap 100） |
| 返回结构 | `Result<PageResult<Map>>` | `Result<List<Map>>` |
| 字段 | 通用产品字段 + batches | 同左（一致） |
| 缓存 key | `product:list:` | `product:group:list:` |
| 适用场景 | 通用列表（带筛选/分页） | **小蒙马专题页一次性铺满** |

> **建议**：专题页用 `/mp/product/group/list`；通用列表（首页、搜索）继续用 `/mp/product/list`。

---

## 五、缓存说明

- **服务端缓存**：`@MpCache(prefix="product:group:list:", ttl=600)`
- **TTL**：600 秒（10 分钟）
- **缓存粒度**：按 `keyword` 区分，无 keyword 与有 keyword 是不同的 cache key
- **失效时机**：依赖自然过期（10 分钟内运营发布/下架的产品不会立即可见）
- **前端缓存建议**：列表数据可在小程序端短期缓存（如 30 秒），下拉刷新时强制清缓存

---

## 六、字典对照

### `product_type`（产品类型）

| 值 | 中文 | 说明 |
|----|------|------|
| `CORE` | 核心产品 | 主推标准产品 |
| `ROUTE` | 自驾路书 | 自驾游线路 |
| `CUSTOM` | 私人定制 | 1对1定制行程 |
| `GROUP` | **小蒙马** | **本接口固定返回此类型** |

### `batch_status`（团期状态）

| 值 | 中文 | UI 建议 |
|----|------|---------|
| `ENROLLING` | 报名中 | 显示绿色标签 + 剩余名额 |
| `CONFIRMED` | 已成团 | 显示蓝色标签"已成团" |
| `FULL` | 已满员 | 显示灰色标签 + 禁用报名按钮 |
| `CLOSED` | 已关闭 | 显示灰色标签 + 隐藏 CTA |

### `payment_type`（支付方式）

| 值 | 中文 |
|----|------|
| `FULL` | 全额支付 |
| `DEPOSIT` | 订金 + 尾款 |

### `product_season`（适合季节）

| 值 | 中文 |
|----|------|
| `spring` | 春季 |
| `summer` | 夏季 |
| `autumn` | 秋季 |
| `winter` | 冬季 |

---

## 七、前端实现建议

### 推荐：瀑布流 / 卡片墙

```
┌──────────────────────── 小蒙马专题页 ────────────────────────┐
│  [搜索框 keyword]                                            │
│                                                              │
│  ┌────────────────┐  ┌────────────────┐  ┌────────────────┐ │
│  │ [封面图]       │  │ [封面图]       │  │ [封面图]       │ │
│  │ 草原星空亲子3日 │  │ 呼伦贝尔大环线 │  │ 阿尔山雪国5日   │ │
│  │ 8人精品小团     │  │ 深度纯玩       │  │ 冬季限定       │ │
│  │ #亲子 #星空     │  │ #纯玩 #5星     │  │ #雪景          │ │
│  │ ¥3980起/人      │  │ ¥5680起/人     │  │ ¥6280起/人     │ │
│  │ ──────────────  │  │ ──────────────  │  │ ──────────────  │ │
│  │ 最近团期：       │  │ 最近团期：       │  │ 最近团期：       │ │
│  │ • 5/1 报名中(2)  │  │ • 5/8 报名中(5)  │  │ • 12/20 报名中  │ │
│  │ • 5/15 已成团    │  │                  │  │                  │ │
│  └────────────────┘  └────────────────┘  └────────────────┘ │
└──────────────────────────────────────────────────────────────┘
```

### 卡片渲染要点

1. **顶部图区**：`coverImageUrl`，建议固定宽高比（如 16:10）
2. **标题区**：`name`（粗体）+ `subtitle`（小字灰色）
3. **标签区**：`tags` 取前 2-3 个，超出折叠
4. **价格区**：用 `startPriceLabel` 直接渲染（已带 `¥` 和 `起/人` 后缀）
5. **团期区**：`batches` 取前 2 条展示（按出发日期升序），显示 `departureDate` + 状态标签 + 剩余名额
6. **空团期**：`batches=[]` 时显示"暂无可售团期"或灰显

### 跳转详情

```javascript
// 卡片点击
function onCardTap(item) {
  // productId 必须用 String 拼接
  wx.navigateTo({ url: `/pages/product/detail?id=${item.productId}` });
}

// 详情页加载
function loadDetail(id) {
  // 直接复用现有详情接口，无需任何改动
  return request.get(`/mp/product/${id}`);
}
```

---

## 八、回归验证

测试环境部署完成后，前端可通过以下方式验证：

```
GET https://api.test.1814.love/mp/product/group/list
```

**预期**：
- HTTP 200
- `data` 为数组（即使为空也是 `[]`）
- 每个 item 的 `productType === "GROUP"`
- 每个 item 的 `productId` 是带引号的 String
- GROUP 产品有可售团期时 `batches.length > 0`，否则 `batches === []`
