# product-v2: 小程序 + 管理端产品详情返回备品清单(含封面图)

> **服务**: hl-product-service-v2 (端口 8083) + hl-resource-service (端口 8082)
> **PR**: #2272
> **Issue**: #2266
> **日期**: 2026-05-14
> **影响范围**: 小程序产品详情新增 `supplies` 数组 / 管理端产品详情 `supplies[].coverUrl` 新增字段

---

## ⚠️ 关键变化

小程序产品详情接口此前**完全没返回备品清单**,前端无法展示。本次补齐:

1. **小程序详情 `GET /mp/product/{id}` 顶层新增 `supplies` 数组**(此前完全没有这个字段)
2. **管理端详情 `GET /admin/product/item/{id}` `supplies[]` 子对象新增 `coverUrl` 字段**
3. 数据库 `product_supplies` 表新增 `cover_url` 列,Flyway V20260514_001 已在测试服跑完一次性 JOIN 回填:**引用资源库 supplies_item 的备品 100% 命中(73/73)**

---

## 一、变更接口清单

| # | 接口 | 方法 | 路径 | 变更类型 | 说明 |
|---|------|------|------|----------|------|
| 1 | 小程序产品详情 | GET | `/mp/product/{id}` | **新增字段** | 顶层 `supplies: SuppliesVO[]` |
| 2 | 管理端产品详情 | GET | `/admin/product/item/{id}` | 字段扩展 | `supplies[]` 子对象新增 `coverUrl` |

---

## 二、接口详情

### 1. 小程序产品详情 `GET /mp/product/{id}`

**VO**: `MpProductDetailRespVO.SuppliesVO`(新内嵌静态类)

#### 新增响应字段

```json
{
  "code": 200,
  "data": {
    "productId": "2045623908178046978",
    "name": "带孩子,看一次真正的草原。",
    "...": "其他既有字段保持不变",
    "supplies": [
      {
        "id": 2045635184023285762,
        "suppliesName": "团队识别手环",
        "category": "个人装备",
        "coverUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/xxx.png",
        "sortOrder": 0
      },
      {
        "id": 2045635184027480066,
        "suppliesName": "防晒喷雾",
        "category": "个人装备",
        "coverUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/yyy.png",
        "sortOrder": 1
      }
    ]
  }
}
```

| 字段 | 类型 | 可能为 null | 说明 |
|------|------|------------|------|
| `supplies` | `List<SuppliesVO>` 或 `null` | ✅ | 产品没备品时整个字段为 `null`,前端按 null 兜底空数组即可 |
| `supplies[].id` | `Long`(雪花) | ❌ | 备品行 ID(C 端通常用不到,可忽略) |
| `supplies[].suppliesName` | `String` | ❌ | 备品名称(已是快照,展示用) |
| `supplies[].category` | `String` 或 `null` | ✅ | 分类(中文文本或字典编码均可能,示例 `个人装备`/`保暖装备`/`CAMPING` 等,**前端不要做硬枚举映射**,直接展示文本) |
| `supplies[].coverUrl` | `String` 或 `null` | ✅ | 备品封面图 URL(快照),自定义备品或历史回填失败可能为 null,**前端务必做默认占位图兜底** |
| `supplies[].sortOrder` | `Integer` | ❌ | 排序字段,后端已按 ASC 返回,前端按数组顺序渲染即可 |

#### 排序

后端 `selectByProductId` 已 `orderByAsc(sortOrder)` 排序,前端直接按数组顺序展示。

### 2. 管理端产品详情 `GET /admin/product/item/{id}`

**VO**: `ProductDetailRespVO.SuppliesVO`(既有内嵌类,仅扩展字段)

`supplies[]` 子对象新增 `coverUrl: String`,语义同上(快照,可能为 null)。其他既有字段(`id` / `productId` / `suppliesResourceId` / `suppliesName` / `category` / `hasCost` / `billingType` / `unitPrice` / `quantity` / `sortOrder`)保持不变。

---

## 三、前端兜底要求

**重要**:`coverUrl` 字段**可能为 null**,出现场景:

1. **手工添加的自定义备品**(没勾选资源库 supplies_item,只填了名字) — 后端没有源头查 cover,直接 null
2. **历史数据中 supplies_item 关联资源已删 / material 已删 / oss_url 为空** — 一次性 JOIN 回填时被防御逻辑排除

**前端必须做默认占位图**,例如:

```js
const cover = supply.coverUrl || '/static/supplies-default.png';
```

或 CSS:

```css
.supplies-cover { background: url(/static/supplies-default.png) no-repeat center; }
.supplies-cover img { object-fit: cover; }
```

测试服回填命中率参考:**174 条 product_supplies 中 73 条有 supplies_resource_id,全部命中真实 OSS URL;另外 101 条是手工/旧数据,coverUrl 为 null**。

---

## 四、已知限制(后续优化)

- **手工(非组合)单品在编辑保存时 coverUrl 暂留 null**:本次未引入新 Feign,只通过 DB 一次性回填覆盖历史,新增/编辑手工备品 coverUrl 仍是 NULL,**前端兜底是必须的**
- **从备品组合("从组合选择"按钮)添加的备品 coverUrl 在保存时自动同步**:`SuppliesComboService.expandBatch` 已接入 MaterialService 批量解析,组合展开自然带 URL

---

## 五、测试服真测证据

```bash
# 测试服 9443 网关
curl https://api.test.1814.love:9443/mp/product/2045623908178046978
```

返回顶层 `supplies` 数组 4 条全部带 coverUrl 真实 OSS URL,按 sortOrder 0→1→2→3 升序。

Admin 端用 `test_admin (admin_id=1002)` 测试 productId=2053665899663024129(用户截图同一产品):3 条备品(团队识别手环 / 防晒喷雾 / 便携矿泉水)全部带 coverUrl ✅

---

## 六、补丁(PR #2290 / Issue #2289)— 备品组合接口同步补 coverUrl

紧接本文上方主体,管理后台编辑产品「路线与备品 → 从组合选择」弹窗调的 **备品组合列表/详情** 接口也补齐 coverUrl(此前只有产品自己的 supplies 数组有,组合接口 items 缺):

### 变更接口

| # | 接口 | 方法 | 路径 | 变更 |
|---|------|------|------|------|
| 3 | 启用组合列表 | GET | `/admin/supplies-combo/enabled` | `items[]` 新增 `coverUrl` |
| 4 | 备品组合详情 | GET | `/admin/supplies-combo/{comboId}` | 同上 |

### VO 字段

`SupplyComboRespVO.ItemRespVO` 新增 `coverUrl: String`(放在 `suppliesName` 之后,与产品详情 SuppliesVO 对齐)。语义与上方主体一致:**快照,来自 supplies_item.cover_material_id 对应 material.oss_url,可能为 null,前端做占位图兜底**。

```json
{
  "code": 200,
  "data": [
    {
      "comboId": 2046122595429806082,
      "name": "4",
      "items": [
        {
          "id": 2048695562294710273,
          "suppliesId": ...,
          "suppliesName": "便携矿泉水",
          "coverUrl": "https://hlgl-test.oss-cn-beijing.aliyuncs.com/test/material/2026/03/07/xxx.jpg",
          "categoryCode": "personal_gear",
          "billingType": "...",
          "basePrice": ...,
          "quantity": 1,
          "sortOrder": 3
        }
      ]
    }
  ]
}
```

### 测试服真测证据

- `/admin/supplies-combo/enabled` 返回 2 个 combo,共 5 条 items 全部带真实 OSS URL coverUrl(命中率 100%)
- `/admin/supplies-combo/{comboId}` 同款返回 4 条 items 全部带 coverUrl

### 实现说明

- 跨所有 combo 一次性批量 `MaterialService.getMaterialsByIds` 调用,**单测含 `verify(materialService, times(1))` 防 N+1**
- 复用主体 #2272 引入的 `parseLongSafe(cover_material_id)` 数字防御 + `fetchCoverUrlsByMaterialIds`
- material 查询失败 / 不存在 / cover_material_id 非数字 → coverUrl null,**不阻断接口**

### 不变项

- `items` 数组顺序保持后端原有逻辑(目前是 sortOrder DESC,与本工单 #2289 无关,如需调整为 ASC 请单独反馈)

