- 管理端 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 兜底约定
15 KiB
首页第二屏主题卡片新增跳转链接字段
日期: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 |
后端校验顺序(对应错误提示):
linkType缺失 → "跳转类型不能为空"linkType不在白名单 → "第 N 张卡片跳转类型非法: xxx"linkType=PRODUCT但缺 targetType 或 targetId → "第 N 张卡片跳转到产品详情时必须同时选择产品类型和目标产品"linkType=PRODUCT且linkTargetType不在 {CORE, GROUP} → "第 N 张卡片目标产品类型只能是 CORE 或 GROUP"- Feign 回产品服务校验(批量,无 N+1):
- 产品不存在 → "第 N 张卡片目标产品不存在或已删除: id=xxx"
- productType 不匹配 → "第 N 张卡片目标产品类型不匹配: 期望=CORE, 实际=GROUP"
- 产品未上架 → "第 N 张卡片目标产品未上架(当前状态=DRAFT),请先上架"
- 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
}
linkType是SEASON/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-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 永远非空。
响应示例
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 统一降级到"季节之旅"列表页最稳。
字典 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_valuedict_label(中文显示名)可以改,影响的只是管理端下拉和字典弹窗的展示文案- 如果未来要新增跳转类型(比如
ACTIVITY活动列表),必须先前端发版带 default + 新 case,再后端加字典 value,否则老客户端会静默失败
管理端「跳转类型」下拉建议走 GET /dict/all(公开接口,无需 token),取 theme_card_link_type 整块塞进 dictStore。
前后端发版顺序
- 后端(本期已完成):hl-user-service + hl-mp-service 部署到测试环境,字典 SQL 各环境手动执行
- 前端管理端:上新版(表单加跳转类型下拉 + 产品选择器)
- 前端小程序:上新版(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 合入),内容:
-- 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 - ⏳ 前端发版后端到端回归