hl-api-changelog/changelogs/2026-04/2026-04-21_home-screen2-theme-card-link.md
API Changelog Bot f791edb0ff docs: 2026-04-21 首页第二屏主题卡片新增跳转链接字段 (PR #1081)
- 管理端 PUT/GET /admin/home-config/brand-story: cards 新增 linkType/linkTargetType/linkTargetId
- GET 响应新增 linkTargetStatusMap/linkTargetNameMap 两个 Map 用于管理端徽章展示
- 小程序 GET /mp/home-config/screen2: cards 同步新增 3 字段, 读路径兜底 linkType=SEASON
- 字典 theme_card_link_type 4 条: SEASON/CORE_LIST/MENGMA/PRODUCT, value 严禁改
- 包含请求/响应 JSON 示例, 校验顺序, 错误提示文案, 前端 switch 必写 default 兜底约定
2026-04-21 13:55:42 +08:00

15 KiB

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

日期2026-04-21 PR#1081Closes Issue #1080 合并到 dev commitc17d2563 后端服务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=PRODUCTlinkTargetType 不在 {CORE, GROUP} → "第 N 张卡片目标产品类型只能是 CORE 或 GROUP"
  5. Feign 回产品服务校验(批量,无 N+1
    • 产品不存在 → "第 N 张卡片目标产品不存在或已删除: id=xxx"
    • productType 不匹配 → "第 N 张卡片目标产品类型不匹配: 期望=CORE, 实际=GROUP"
    • 产品未上架 → "第 N 张卡片目标产品未上架(当前状态=DRAFT),请先上架"
  6. Feign 本身失败(熔断 / 极端异常)→ "校验目标产品失败,请稍后重试"(保守策略:拒绝保存,不让脏数据入库)

请求示例:保存两张卡片(一张 SEASON + 一张 PRODUCT

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核心产品列表

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

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

错误响应样例

// 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/空字符串。

响应示例

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-listhl-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 永远非空

响应示例

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
        }
      ]
    }
  }
}

小程序前端跳转约定

// 伪代码示意,实际路径以前端项目既有封装为准
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 统一降级到"季节之旅"列表页最稳。


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 前必须先等小程序新版本覆盖率达标再改字典。


缓存

  • Keyhome: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 合入),内容:

-- 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
  • 前端发版后端到端回归