# 小程序首页第二屏配置 - 品牌故事 + 主题卡片入口

- **日期**: 2026-04-18
- **PR**: [#811](https://git.1814.love:8443/wx/HL/pulls/811) (Closes #810)
- **服务**: hl-user-service (8081) + hl-mp-service (8085)
- **状态**: 已合并到 dev，待测试环境部署

---

## 背景

小程序首页分两屏：
- 第一屏：Hero 大图/轮播（现有 `sys_banner`，不变）
- **第二屏（本次新增）**：品牌故事文案 + 大主图 + 2 张主题卡片入口

原型节点：`用户端页面(5).pen / M57M7 首页 / Screen2 plwfj`

---

## 1. 管理端接口（Admin）

### 1.1 查询品牌故事（含草稿）

```
GET /admin/home-config/brand-story
Authorization: Bearer <admin token>
```

**响应** `Result<AdminBrandStoryRespVO>`：
```json
{
  "code": 200,
  "data": {
    "id": "2045369223068311553",
    "title": "每一条线路，我们都亲自走过。",
    "subtitle": null,
    "description": "整个呼伦贝尔...",
    "coverUrl": "https://cdn.example.com/home/team-photo.jpg",
    "coverMaskType": "WHITE_TOP",
    "version": 1,
    "status": "ACTIVE",
    "createTime": "2026-04-18 13:09:59",
    "updateTime": "2026-04-18 13:09:59",
    "createdBy": "1002",
    "updatedBy": "1002",
    "createdByName": null,
    "updatedByName": null
  },
  "success": true
}
```

**字段说明**：

| 字段 | 类型 | 说明 |
|-----|------|-----|
| id | String | 主键（雪花ID，JS 精度防护用 String） |
| title | String | 主标题，支持 \n 换行 |
| subtitle | String? | 副标题（预留，当前可为 null） |
| description | String | 描述文案 |
| coverUrl | String? | 主图 URL（草稿可空；发布时必填） |
| coverMaskType | String | 蒙层类型：`WHITE_TOP`=上白下透 / `NONE`=无 |
| version | Integer | 版本号，publish 每次 +1 |
| status | String | `DRAFT`=草稿 / `ACTIVE`=已发布 / `ARCHIVED`=已归档 |
| createdBy/updatedBy | String | 操作人 adminId（String 防 JS 精度） |

**语义**：
- 返回当前**最新**一条非删除记录（按 id 降序，包含 DRAFT）
- 管理端打开页面即可编辑当前状态，不管是草稿还是已发布
- 无数据时返回 `data: null`（首次部署时可能出现）

---

### 1.2 保存或发布品牌故事

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

**请求体** `AdminBrandStorySaveReqVO`：
```json
{
  "id": null,
  "title": "每一条线路，我们都亲自走过。",
  "subtitle": null,
  "description": "整个呼伦贝尔...",
  "coverUrl": "https://cdn.example.com/home/team-photo.jpg",
  "coverMaskType": "WHITE_TOP",
  "publish": true
}
```

**字段校验**：

| 字段 | 校验 | 说明 |
|-----|-----|-----|
| id | 选填 | 有值=更新指定记录，null=新建 |
| title | @NotBlank, @Length(max=100) | 必填 |
| subtitle | @Length(max=200) | 选填 |
| description | @NotBlank, @Length(max=1000) | 必填 |
| coverUrl | @NotBlank, @URL(protocol="https") | 必填（只接受 https 链接） |
| coverMaskType | @NotBlank | 必填，值同上 |
| publish | Boolean | `true`=直接发布（归档旧 ACTIVE，新记录设为 ACTIVE）；`false`=保存草稿 |

**行为**：
- `publish=false`：`saveDraft` 插入/更新 `status=DRAFT` 记录
- `publish=true`：`publish` 先 `archiveAllActive()` 把当前 ACTIVE 改 ARCHIVED，再插入 ACTIVE 新记录；`@Lock4j` 防并发；自动 evict Redis 缓存

**响应** `Result<Void>`：`{"code":200, "message":"成功", "data":null, "success":true}`

**幂等保护**：`@Idempotent(timeout=5, keyPrefix="admin-brand-story")`，5 秒内重复请求返回缓存结果。

**审计**：`@OperationLog("发布小程序首页第二屏品牌故事")`，操作日志自动写入。

---

## 2. 小程序端接口（MP）

### 2.1 获取首页第二屏数据（聚合）

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

**不需要 token**（Gateway 设为 `X-Auth-Level=SKIP`，匿名可访问）。

**响应** `Result<MpHomeScreen2VO>`：
```json
{
  "code": 200,
  "data": {
    "brandStory": {
      "title": "每一条线路，我们都亲自走过。",
      "subtitle": null,
      "description": "整个呼伦贝尔...",
      "coverUrl": "https://cdn.example.com/home/team-photo.jpg",
      "coverMaskType": "WHITE_TOP"
    },
    "topics": [
      {
        "id": "XXXX",
        "title": "季节之旅",
        "subtitle": "夏/秋/冬三季体验",
        "coverUrl": "https://cdn.example.com/topics/season.jpg",
        "linkType": "PRODUCT_LIST",
        "linkTarget": "theme=SEASON"
      }
    ]
  },
  "success": true
}
```

**字段说明**：

| 字段 | 类型 | 说明 |
|-----|------|-----|
| brandStory | Object? | 品牌故事块，无数据时为 null |
| brandStory.title | String | 主标题，支持 \n 换行 |
| brandStory.subtitle | String? | 副标题 |
| brandStory.description | String | 描述 |
| brandStory.coverUrl | String? | 主图 URL |
| brandStory.coverMaskType | String | 蒙层类型 `WHITE_TOP`/`NONE` |
| topics | Array | 主题卡片列表（按 sort_order 升序） |
| topics[].id | String | 主题 ID |
| topics[].title | String | 卡片标题 |
| topics[].subtitle | String? | 卡片副标题 |
| topics[].coverUrl | String | 卡片封面图 |
| topics[].linkType | String | 跳转类型，如 `PRODUCT_LIST` / `TOPIC_DETAIL` |
| topics[].linkTarget | String | 跳转目标（路径/ID/参数） |

**关键**：响应**不包含** `fromFallback` 字段（内部 Feign 协议字段，VO 层已过滤）。

---

## 3. 缓存策略

| 项 | 值 |
|---|---|
| Key | `home:screen2:mp:{env}`（env=dev/test/prod） |
| TTL | 30 分钟（1800s） |
| 空值 TTL | 60 秒（防穿透） |
| Evict 触发 | brand PUT / topic CUD 任一发生 |

**前端渲染建议**：小程序可本地缓存 5 分钟减少重复请求；发现数据变化再 pull。

---

## 4. 错误处理

所有错误走项目统一错误码，HTTP **始终 200**，业务码在 `code` 字段：

| code | 含义 | 常见场景 |
|------|------|---------|
| 200 | 成功 | — |
| 400 | 参数校验失败 | title 缺失 / coverUrl 非 https |
| 401 | 未登录 | admin token 过期 |
| 403 | 无权限 | 非 admin 访问 `/admin/**` |
| 409 | 状态冲突 | 禁止 ACTIVE → DRAFT 回退 |
| 500 | 服务器错误 | DB/Redis 异常 |

---

## 5. 管理端前端需要做的

1. **页面**：首页配置 / 第二屏
2. **表单字段**：title / subtitle / description / coverUrl（走素材库 system 分类上传） / coverMaskType（下拉 WHITE_TOP/NONE）
3. **操作按钮**：`保存草稿` (publish=false) / `立即发布` (publish=true，需二次确认弹窗)
4. **首次打开**：调 GET 接口，有数据则回填表单，无数据则空表单
5. **发布后提示**：调 GET 验证 status=ACTIVE，提示"发布成功，小程序缓存最多 30 分钟后生效"

---

## 6. 小程序前端需要做的

1. **首页渲染**：调 `GET /mp/home-config/screen2`
2. **数据位置**：
   - `brandStory.coverUrl` 作为第二屏大图背景（375×497 区）
   - `brandStory.title` + `brandStory.description` 浮于大图之上（位置 x=20, y=50 参考原型）
   - `topics` 渲染为 2 个横向卡片（位置 x=20, y=520, 335×220 参考原型）
3. **空态**：`brandStory=null` 时第二屏隐藏；`topics=[]` 时卡片区隐藏
4. **shimmer/skeleton**：请求中显示骨架屏

---

## 7. 变更影响

- **现有 `/admin/banner/*` 接口**：**不变**（第一屏轮播独立）
- **现有 `/mp/banner/active` 接口**：**不变**
- **现有 `/admin/topic/*` 接口**：**不变**（本期复用 sys_topic，不动表结构）
- **TopicService 新增方法**：仅内部调用，不暴露接口
