From f791edb0fffed7cb2185736b30f4e49b867eef59 Mon Sep 17 00:00:00 2001 From: API Changelog Bot Date: Tue, 21 Apr 2026 13:55:42 +0800 Subject: [PATCH] =?UTF-8?q?docs:=202026-04-21=20=E9=A6=96=E9=A1=B5?= =?UTF-8?q?=E7=AC=AC=E4=BA=8C=E5=B1=8F=E4=B8=BB=E9=A2=98=E5=8D=A1=E7=89=87?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=E8=B7=B3=E8=BD=AC=E9=93=BE=E6=8E=A5=E5=AD=97?= =?UTF-8?q?=E6=AE=B5=20(PR=20#1081)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 管理端 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_home-screen2-theme-card-link.md | 363 ++++++++++++++++++ 1 file changed, 363 insertions(+) create mode 100644 changelogs/2026-04/2026-04-21_home-screen2-theme-card-link.md diff --git a/changelogs/2026-04/2026-04-21_home-screen2-theme-card-link.md b/changelogs/2026-04/2026-04-21_home-screen2-theme-card-link.md new file mode 100644 index 0000000..30929b8 --- /dev/null +++ b/changelogs/2026-04/2026-04-21_home-screen2-theme-card-link.md @@ -0,0 +1,363 @@ +# 首页第二屏主题卡片新增跳转链接字段 + +**日期**: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 +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`(productId → product_status) | 仅 `linkType=PRODUCT` 的卡片有条目;Feign 降级时**返回空对象** `{}`(非 null),不阻塞主流程 | +| `linkTargetNameMap` | `Map`(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`,每项有 `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 +- ⏳ 前端发版后端到端回归