# 首页第二屏主题卡片新增跳转链接字段

**日期**：2026-04-21
**PR**：#1081（Closes Issue #1080）
**合并到 dev commit**：c17d2563
**后端服务**：hl-user-service（写入/查询）+ hl-mp-service（复用 `BrandCard` 跨服务 DTO，需同步重启）
**影响**：
- 管理端「首页配置 → 品牌故事」编辑页（每张主题卡片新增跳转设置）
- 小程序「首页第二屏」（点主题卡片跳不同的列表/详情）

---

## 为什么改

历史上首页第二屏的 2 张主题卡片只是图+文，点击**没有跳转**。现在要让运营后台可以为每张卡片单独选一种跳转方式（4 种预设之一），并透传给小程序去拉不同的列表/详情页：

| 跳转类型 value | 中文 | 前端目标路由 |
|---|---|---|
| `SEASON` | 季节之旅 | `packages/product/season/list/list` |
| `CORE_LIST` | 核心产品 | `packages/product/core/list/list` |
| `MENGMA` | 亲子游学 | `packages/product/mengma/list/list` |
| `PRODUCT` | 指定产品详情 | 按 `linkTargetType` + `linkTargetId` 拼产品详情页路径 |

老数据、老小程序完全兼容——老 JSON 反序列化出来 `linkType=null`，后端读路径统一兜底为 `SEASON`，保持原先"点卡片去季节列表"的行为，老客户端可以不做任何改动。

> 命名说明：类型 code 有意叫 `CORE_LIST` / `PRODUCT` 而不是 `CORE` / `CUSTOM`，**避免与已存在的 `product_type.CORE` / `product_type.CUSTOM` 撞名**，前端按 switch 分发时不会踩坑。

---

## 管理端变化

### 1. 保存品牌故事 `PUT /admin/home-config/brand-story`

`cards[]` 每张卡片新增 **3 个字段**（在原有 title / subtitle / coverUrl 基础上）：

| 字段 | 类型 | 是否必填 | 校验 |
|---|---|---|---|
| `linkType` | string | **必填**（默认 `SEASON`） | 枚举：`SEASON` / `CORE_LIST` / `MENGMA` / `PRODUCT`；非白名单值后端 400 拒绝 |
| `linkTargetType` | string \| null | **仅 `linkType=PRODUCT` 时必填**；其他场景**后端强制置空**（即使前端传了值，后端也会清空入库） | 枚举：`CORE` / `GROUP`（对齐字典 `product_type`） |
| `linkTargetId` | number \| null | **仅 `linkType=PRODUCT` 时必填**；其他场景后端强制置空 | 对应产品 ID，后端会 Feign 回产品服务校验：存在 + productType 匹配 + `status=PUBLISHED` |

**后端校验顺序**（对应错误提示）：

1. `linkType` 缺失 → "跳转类型不能为空"
2. `linkType` 不在白名单 → "第 N 张卡片跳转类型非法: xxx"
3. `linkType=PRODUCT` 但缺 targetType 或 targetId → "第 N 张卡片跳转到产品详情时必须同时选择产品类型和目标产品"
4. `linkType=PRODUCT` 且 `linkTargetType` 不在 {CORE, GROUP} → "第 N 张卡片目标产品类型只能是 CORE 或 GROUP"
5. Feign 回产品服务校验（批量，无 N+1）：
   - 产品不存在 → "第 N 张卡片目标产品不存在或已删除: id=xxx"
   - productType 不匹配 → "第 N 张卡片目标产品类型不匹配: 期望=CORE, 实际=GROUP"
   - 产品未上架 → "第 N 张卡片目标产品未上架(当前状态=DRAFT)，请先上架"
6. Feign 本身失败（熔断 / 极端异常）→ "校验目标产品失败，请稍后重试"（保守策略：拒绝保存，不让脏数据入库）

#### 请求示例：保存两张卡片（一张 SEASON + 一张 PRODUCT）

```jsonc
PUT /admin/home-config/brand-story
Authorization: Bearer <admin token>
Content-Type: application/json

{
  "title": "我们的品牌故事",
  "subtitle": "在草原遇见你",
  "body": "……",
  "cards": [
    {
      "title": "秋色限定",
      "subtitle": "九月走一遭",
      "coverUrl": "https://cdn.example.com/cards/autumn.jpg",
      "linkType": "SEASON"
      // linkTargetType / linkTargetId 不传或传 null 都可以，后端会强制置空
    },
    {
      "title": "南线4天3晚",
      "subtitle": "王牌行程",
      "coverUrl": "https://cdn.example.com/cards/south.jpg",
      "linkType": "PRODUCT",
      "linkTargetType": "CORE",
      "linkTargetId": 2045345825172639746
    }
  ],
  "publish": true
}
```

#### 请求示例：运营选了 CORE_LIST（核心产品列表）

```jsonc
{
  "cards": [
    {
      "title": "核心系列",
      "subtitle": "最受欢迎",
      "coverUrl": "https://cdn.example.com/cards/core.jpg",
      "linkType": "CORE_LIST"
    }
  ],
  "publish": true
}
```

> `linkType` 是 `SEASON` / `CORE_LIST` / `MENGMA` 三者之一时，即使前端在表单里留了 `linkTargetType` / `linkTargetId` 的旧值，**后端也会强制清空**，不会留脏数据。所以前端切换跳转类型后不需要特意 reset 这两个字段。

#### 错误响应样例

```jsonc
// PRODUCT 缺字段
{
  "code": 500,
  "msg": "第 2 张卡片跳转到产品详情时必须同时选择产品类型和目标产品"
}

// PRODUCT 指的产品被人下架了
{
  "code": 500,
  "msg": "第 1 张卡片目标产品未上架(当前状态=UNPUBLISHED)，请先上架"
}

// PRODUCT 指的产品被删了或不存在
{
  "code": 500,
  "msg": "第 1 张卡片目标产品不存在或已删除: id=9999"
}
```

---

### 2. 查询品牌故事 `GET /admin/home-config/brand-story`

响应体 `data` 除了原有 title/subtitle/body/coverUrl/cards/version/status 等字段外，**新增两个 Map** 用于管理端列表展示"目标产品 XXX（已上架）"徽章：

| 字段 | 类型 | 说明 |
|---|---|---|
| `linkTargetStatusMap` | `Map<Long, String>`（productId → product_status） | 仅 `linkType=PRODUCT` 的卡片有条目；Feign 降级时**返回空对象** `{}`（非 null），不阻塞主流程 |
| `linkTargetNameMap` | `Map<Long, String>`（productId → 产品名） | 同上，用于直接渲染"跳转到：游牧的森林-短途版" |

`cards[]` 里也携带 `linkType` / `linkTargetType` / `linkTargetId` 三个字段，其中 **老数据 `linkType=null` 会被后端兜底改写为 `SEASON`**（和写入路径兜底逻辑对齐），所以前端拿到的 `linkType` 永远不会是 null/空字符串。

#### 响应示例

```jsonc
GET /admin/home-config/brand-story

{
  "code": 200,
  "data": {
    "id": 1,
    "title": "我们的品牌故事",
    "subtitle": "在草原遇见你",
    "body": "……",
    "coverUrl": "https://cdn.example.com/brand/story.jpg",
    "cards": [
      {
        "title": "秋色限定",
        "subtitle": "九月走一遭",
        "coverUrl": "https://cdn.example.com/cards/autumn.jpg",
        "linkType": "SEASON",
        "linkTargetType": null,
        "linkTargetId": null
      },
      {
        "title": "南线4天3晚",
        "subtitle": "王牌行程",
        "coverUrl": "https://cdn.example.com/cards/south.jpg",
        "linkType": "PRODUCT",
        "linkTargetType": "CORE",
        "linkTargetId": 2045345825172639746
      }
    ],
    "linkTargetStatusMap": {
      "2045345825172639746": "PUBLISHED"
    },
    "linkTargetNameMap": {
      "2045345825172639746": "游牧的森林-短途版"
    },
    "status": "ACTIVE",
    "version": 7,
    "updatedByName": "wx",
    "updatedAt": "2026-04-21T13:51:46"
  }
}
```

> 表里没有任何记录时 `data=null`，老逻辑不变。

---

### 3. 产品下拉列表（复用已有接口）

前端管理端在"选指定产品"场景下，请直接复用已有的：

**`GET /admin/product/item/simple-list`**（hl-product-service-v2）

| Query 参数 | 类型 | 说明 |
|---|---|---|
| `keyword` | string | 关键词（产品名或产品编号） |
| `productType` | string / string[] | `CORE,GROUP` 逗号分隔 或 `productType=CORE&productType=GROUP` 多值重复 key；也支持单值。品牌故事跳转场景建议传 `CORE,GROUP` 两个都要 |
| `page` | int | 默认 1 |
| `pageSize` | int | 默认 20，最大 100 |

响应返回 `PageResult<ProductSimpleListVO>`，每项有 `productId` / `productNo` / `name` / `tripDays` / `lineName`，拼下拉选项够用。

**注意**：该接口只返回**已上架**的产品（产品服务内部按 `status=PUBLISHED` 过滤），所以下拉选中的产品在保存时大概率能过 Feign 校验。极端情况（选完→运营 B 把它下架→运营 A 保存）仍会被 Feign 兜底拦住（返回第 5 条错误）。

---

## 小程序端变化

### `GET /mp/home-config/screen2`

响应体里 `cards[]` 每项新增 `linkType` / `linkTargetType` / `linkTargetId`，语义和管理端一致。

**关键兜底**：无论是老 JSON（字段完全不存在）还是历史脏数据（`linkType=null`），后端读路径**统一兜底** `linkType=SEASON`，所以小程序拿到的 `linkType` **永远非空**。

#### 响应示例

```jsonc
GET /mp/home-config/screen2
// 无需登录

{
  "code": 200,
  "data": {
    "brandStory": {
      "title": "我们的品牌故事",
      "subtitle": "在草原遇见你",
      "body": "……",
      "coverUrl": "https://cdn.example.com/brand/story.jpg",
      "cards": [
        {
          "title": "秋色限定",
          "subtitle": "九月走一遭",
          "coverUrl": "https://cdn.example.com/cards/autumn.jpg",
          "linkType": "SEASON",
          "linkTargetType": null,
          "linkTargetId": null
        },
        {
          "title": "南线4天3晚",
          "subtitle": "王牌行程",
          "coverUrl": "https://cdn.example.com/cards/south.jpg",
          "linkType": "PRODUCT",
          "linkTargetType": "CORE",
          "linkTargetId": 2045345825172639746
        }
      ]
    }
  }
}
```

### 小程序前端跳转约定

```js
// 伪代码示意，实际路径以前端项目既有封装为准
function onCardTap(card) {
  switch (card.linkType) {
    case 'SEASON':
      return wx.navigateTo({ url: '/packages/product/season/list/list' });
    case 'CORE_LIST':
      return wx.navigateTo({ url: '/packages/product/core/list/list' });
    case 'MENGMA':
      return wx.navigateTo({ url: '/packages/product/mengma/list/list' });
    case 'PRODUCT':
      // linkTargetType = CORE / GROUP，linkTargetId 是产品ID
      // 按项目既有的"产品详情页"映射规则拼 url
      return navigateToProductDetail(card.linkTargetType, card.linkTargetId);
    default:
      // ⭐⭐⭐ 必写的 default 兜底
      // 避免后端以后新增字典 value 时老客户端白屏
      return wx.navigateTo({ url: '/packages/product/season/list/list' });
  }
}
```

> ⚠️ **default 分支必须写**。后端字典 value 层面是可扩展的，如果以后加第 5 种跳转类型，老小程序不带 default 会直接不响应点击，是体验灾难。default 统一降级到"季节之旅"列表页最稳。

---

## 字典 `theme_card_link_type`（4 条）

| dict_value | dict_label | sort | 前端路由 |
|---|---|---|---|
| `SEASON` | 季节之旅 | 10 | `packages/product/season/list/list` |
| `CORE_LIST` | 核心产品 | 20 | `packages/product/core/list/list` |
| `MENGMA` | 亲子游学 | 30 | `packages/product/mengma/list/list` |
| `PRODUCT` | 指定产品 | 40 | 按 linkTargetType + linkTargetId 拼产品详情 |

**铁律**：
- `dict_value` 是**前端路由硬契约**，**严禁删除或修改** `dict_value`
- `dict_label`（中文显示名）可以改，影响的只是管理端下拉和字典弹窗的展示文案
- 如果未来要新增跳转类型（比如 `ACTIVITY` 活动列表），**必须先前端发版带 default + 新 case，再后端加字典 value**，否则老客户端会静默失败

管理端「跳转类型」下拉建议走 `GET /dict/all`（公开接口，无需 token），取 `theme_card_link_type` 整块塞进 dictStore。

---

## 前后端发版顺序

1. **后端（本期已完成）**：hl-user-service + hl-mp-service 部署到测试环境，字典 SQL 各环境手动执行
2. **前端管理端**：上新版（表单加跳转类型下拉 + 产品选择器）
3. **前端小程序**：上新版（cards 按 linkType 分发路由 + default 兜底）

> 严格顺序：**字典 value 严禁改**；label 随时可改；新增 value 前必须先等小程序新版本覆盖率达标再改字典。

---

## 缓存

- Key：`home:screen2`（由 `HomeScreen2CacheEvictor` 管理，TTL 30 分钟；空值 60s 防穿透）
- 保存品牌故事且 `publish=true` 时**会清缓存**；`publish=false` 只存草稿**不清缓存**
- 读路径即便命中老缓存值（`linkType` 字段不存在），下次落回 DB 时会被 `extractCards` 兜底成 `SEASON`，前端能拿到稳定的 linkType

---

## 不在本次范围

- ❌ **不做已有数据迁移**：DB 里老的 BrandStoryDTO JSON 不含新字段，读路径兜底 `SEASON` 后行为与改造前一致，无需 UPDATE
- ❌ **不加新的 internal 接口**：后端 `HomeBrandStoryService` 复用已有的 `ProductTypeFeignClient.batchProductSimple(ids)`，没有新增 internal 契约
- ❌ **小程序端不做 Feign 回显校验**：小程序拿到 PRODUCT 类型后直接跳详情页，详情页自己会处理产品已下架/已删除的场景（现有能力）
- ❌ **管理端新增/编辑时不做跨卡片去重**：2 张卡片允许都跳同一个产品（产品运营的合理选择）

---

## 部署提醒

### 字典 SQL（各环境手动执行一次）

文件：`sql/dict_theme_card_link_type.sql`（随 PR 合入），内容：

```sql
-- sys_dict_type: dict_type_id = 8090
INSERT IGNORE INTO sys_dict_type (dict_type_id, dict_type, dict_name, category, status, remark, created_at, updated_at)
VALUES (8090, 'theme_card_link_type', '首页主题卡片跳转类型', 'BUSINESS', 'ACTIVE',
        '首页第二屏主题卡片点击跳转类型；value 为前端路由契约，严禁改', NOW(), NOW());

-- sys_dict_data: dict_data_id = 80901..80904
INSERT IGNORE INTO sys_dict_data (dict_data_id, dict_type, dict_label, dict_value, sort_order, status, remark, created_at, updated_at)
VALUES
(80901, 'theme_card_link_type', '季节之旅', 'SEASON',    10, 'ACTIVE', '跳转: packages/product/season/list/list', NOW(), NOW()),
(80902, 'theme_card_link_type', '核心产品', 'CORE_LIST', 20, 'ACTIVE', '跳转: packages/product/core/list/list',   NOW(), NOW()),
(80903, 'theme_card_link_type', '亲子游学', 'MENGMA',    30, 'ACTIVE', '跳转: packages/product/mengma/list/list', NOW(), NOW()),
(80904, 'theme_card_link_type', '指定产品', 'PRODUCT',   40, 'ACTIVE', '跳转: 指定产品详情（linkTargetType + linkTargetId）', NOW(), NOW());
```

- 幂等（`INSERT IGNORE`），可安全多次重跑
- 库：`hl_user_service`
- 本地 / 测试服 / 生产各执行一次

### 服务重启

- **hl-user-service**：必须重启（主要代码在这）
- **hl-mp-service**：必须重启（`BrandCard` 是 common-core 里的跨服务 DTO，新增字段后 mp-service 旧 fat jar 里的 DTO 版本会丢字段，与 `common-core DTO 改动必须部署所有消费侧` 的教训一致）

---

## 验证

- ✅ 后端单测 全绿（`HomeBrandStoryServiceTest` 覆盖 normalize / Feign 校验 / 降级分支）
- ✅ `AdminBrandStorySaveReqVOTest` 覆盖白名单 / PRODUCT 缺字段 / 非 PRODUCT 多余字段
- ✅ `BrandCardTest` 覆盖 DTO 向后兼容反序列化（老 JSON 无新字段 → 字段 null）
- ✅ `MpHomeConfigServiceTest` 覆盖读路径 linkType 兜底为 SEASON
- ⏳ 前端发版后端到端回归
