# 草原指南管理、OSS 素材上传与小程序接口（一期）

> 日期：2026-07-12
>
> 后端 Issue：[HL #4919](https://git.1814.love:8443/wx/HL/issues/4919)、[HL #4922](https://git.1814.love:8443/wx/HL/issues/4922)、[HL #4927](https://git.1814.love:8443/wx/HL/issues/4927)、[HL #4940](https://git.1814.love:8443/wx/HL/issues/4940)、[HL #4949](https://git.1814.love:8443/wx/HL/issues/4949)
>
> 后端 PR：[HL #4921](https://git.1814.love:8443/wx/HL/pulls/4921)、[HL #4924](https://git.1814.love:8443/wx/HL/pulls/4924)、[HL #4929](https://git.1814.love:8443/wx/HL/pulls/4929)、[HL #4930](https://git.1814.love:8443/wx/HL/pulls/4930)、[HL #4946](https://git.1814.love:8443/wx/HL/pulls/4946)、[HL #4948](https://git.1814.love:8443/wx/HL/pulls/4948)、[HL #4953](https://git.1814.love:8443/wx/HL/pulls/4953)、[HL #4958](https://git.1814.love:8443/wx/HL/pulls/4958)
>
> 测试分支：`dev-v3`
>
> 正式环境：一期发布时沿用本文契约

## 1. 最终方案

草原指南是独立的内容管理模块，视频文件仍统一进入素材库，不再使用阿里云 VOD。

- 后台新增一级菜单“草原指南管理”。
- 素材库新增系统内置顶级分类“草原指南”，固定编码 `grassland_guide`。
- 草原指南视频只接受 OSS 原始 `.mp4`，一个视频只返回一个 `videoUrl` 点播地址。
- `videoUrl` 不是直播流，也不是 HLS/M3U8、FLV 或 VOD 播放地址；它是 OSS 原始 MP4 的 HTTPS 地址，通过 HTTP Range 分段读取。
- 不转码、不生成多档清晰度、不接入 VOD/直播播放 SDK、不请求播放凭证、不切换 OSS 自定义域名。
- 视频和封面都只能由后台管理员上传；小程序用户没有上传接口和上传权限。
- 视频可以单独上传一张图片作为封面；未指定时由 OSS 自动截取视频首帧。
- 小程序列表支持分页，详情直接把 `videoUrl` 交给原生 `<video>` 组件播放。
- 相关推荐由后端自动计算，并提供独立分页接口供前端滑动加载；后台不再手工选择推荐内容。
- 视频时长和首次发布时间均为只读字段：时长从视频素材派生，发布时间在首次发布时由后端生成。
- 原型没有“呼籁出品”角标开关，管理端和小程序端均不提供 `showProducedBadge` 字段；前端不要提交、读取或展示该字段。
- 管理端可以通过 `loginRequired` 控制单条视频是否要求小程序用户登录；未设置时默认为 `false`。
- 草原指南内容可以绑定产品；小程序卡片返回 `linkedProductId` 和后端聚合出的 `linkedProductType`。
- 前端不参与数据库乐观锁，创建、更新、发布、下架均不提交 `version`；发布和下架接口没有请求体。

前端必须删除旧版草原指南 VOD 方案中的以下内容：

- `/admin/grassland-guide/vod/**`
- `/mp/grassland-guide/videos/{videoId}/play-auth`
- `vodFileId`、`playAuth`、`expiresInSeconds`
- 阿里云 VOD Web SDK、转码状态轮询、清晰度选择器

## 2. 菜单、分类与权限

### 2.1 后台菜单

- 菜单名称：`草原指南管理`
- 菜单路径：`/grassland-guide`
- 菜单权限：`grassland-guide:list`

按钮权限：

| 功能 | 权限码 |
|---|---|
| 新增 | `grassland-guide:create` |
| 编辑 | `grassland-guide:edit` |
| 发布 | `grassland-guide:publish` |
| 下线 | `grassland-guide:offline` |
| 设置主推 | `grassland-guide:featured` |
| 上传视频 | `grassland-guide:video:upload` |

默认授权角色为 `SUPER_ADMIN`、`OPERATOR`。

### 2.2 素材库分类

- 显示名称：`草原指南`
- 固定编码：`grassland_guide`
- 分类性质：系统内置顶级分类
- 用途：视频、视频封面、正文图片
- 约束：不能删除、停用或修改固定编码；后台错误码为 `210405`

前端应隐藏该顶级分类的删除、停用和修改编码入口。视频素材、封面图片都上传到这个分类；不需要创建“小程序专用”子分类。

## 3. 视频上传流程

### 3.1 视频时长由后端自动提取

前端申请上传凭证和确认上传时均不提交 `durationSeconds`，管理端也不提供可编辑时长输入框。

上传确认阶段由后端通过 OSS Range 读取 MP4 的 `moov` 元数据，自动计算并保存向上取整后的秒数；不下载体积最大的 `mdat` 视频内容。确认成功后，前端只读展示素材或草原指南详情返回的 `durationSeconds`。

视频上传校验的最新完整契约见独立 changelog：[`13_fix_grassland_guide_mp4_progressive_gate.md`](./13_fix_grassland_guide_mp4_progressive_gate.md)。

### 3.2 获取素材上传凭证

`POST /admin/material/upload/token`

请求示例：

```json
{
  "categoryCode": "grassland_guide",
  "materialName": "呼伦贝尔草原上的风为什么特别大？",
  "filename": "hulunbuir-wind.mp4",
  "fileSize": 12925261,
  "md5": "32位文件MD5",
  "contentType": "video/mp4"
}
```

草原指南视频的强制校验：

- `categoryCode` 必须是 `grassland_guide`。
- 文件扩展名必须是 `.mp4`。
- `contentType` 必须以 `video/mp4` 开头。
- 不提交 `durationSeconds`；服务端不采信客户端时长。
- 单个视频最大 `2147483648` 字节（2 GiB），大于 `10485760` 字节（10 MiB）时使用 OSS 分片上传。

响应中的 `uploadMode` 有两种：

- `PRESIGNED_URL`：使用响应中的 `uploadUrl` 执行 `PUT`。
- `STS_MULTIPART`：使用 `stsToken`、`bucket`、`region`、`ossKey` 分片上传。

无论哪种模式，上传时的 `Content-Type` 都必须使用接口返回的 `contentType`。STS 临时密钥只能保存在内存中，不能写入日志或本地存储。

若 `instantUpload=true`，直接使用响应中的 `material`，不再上传文件，也不调用确认接口。

### 3.3 上传完成后确认素材

`POST /admin/material/upload/confirm`

```json
{
  "materialId": "2076195939797688322",
  "description": "草原指南视频",
  "tagIds": []
}
```

确认接口会通过 OSS `HeadObject` 和 Range 读取实际对象元数据及 MP4 `moov`，并校验：

- OSS 对象必须真实存在。
- 实际 `Content-Length` 必须等于申请上传凭证时声明的 `fileSize`。
- MP4 的实际 `Content-Type` 必须是 `video/mp4`（允许携带参数）。
- MP4 满足 `ftyp < moov < mdat`，且包含一条 H.264 视频轨和一条 AAC-LC 音频轨。
- 服务端自动计算 `durationSeconds`，成功状态为 `OSS_MP4_READY_V1`。

任一校验失败时素材不会进入可用状态。确认成功后保存返回的 `materialId`，创建草原指南内容时作为 `videoMaterialId` 提交。

### 3.4 文件夹上传

如素材库继续支持文件夹批量上传，草原指南视频同样不提交 `durationSeconds`；每个视频仍必须完成上传确认并取得 `OSS_MP4_READY_V1` 后才能用于新建或替换草原指南内容。

## 4. 视频封面

封面图片同样通过素材库通用上传接口上传到 `grassland_guide`，图片不传 `durationSeconds`。

- 指定 `customCoverMaterialId`：详情返回 `coverSource=CUSTOM`，使用管理员上传的图片。
- 不指定或清空 `customCoverMaterialId`：详情返回 `coverSource=AUTO`，后端使用视频素材的 OSS 自动截帧地址。

OSS 自动封面格式：

```text
{videoUrl}?x-oss-process=video/snapshot,t_0,f_jpg,w_800,m_fast
```

前端只使用响应中的 `effectiveCoverUrl`/`coverUrl`，不要自行拼接处理参数。

## 5. 草原指南管理接口

基础路径：`/admin/grassland-guide/videos`

| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/admin/grassland-guide/videos` | 分页列表 |
| `GET` | `/admin/grassland-guide/videos/{videoId}` | 详情 |
| `POST` | `/admin/grassland-guide/videos` | 创建草稿 |
| `PUT` | `/admin/grassland-guide/videos/{videoId}` | 全量更新，不传 `version` |
| `PUT` | `/admin/grassland-guide/videos/{videoId}/publish` | 发布/重新发布 |
| `PUT` | `/admin/grassland-guide/videos/{videoId}/offline` | 下线 |

创建或全量更新的主体：

```json
{
  "title": "呼伦贝尔草原上的风为什么特别大？",
  "summary": "视频摘要",
  "videoMaterialId": "2076195939797688322",
  "customCoverMaterialId": "2076196060279070722",
  "contentHtml": "<p>正文内容</p>",
  "contentImageMaterialIds": [],
  "featured": true,
  "sortWeight": 3,
  "linkedProductId": "2076196082752155649",
  "loginRequired": false
}
```

创建和更新使用同一组业务字段，均不提交 `version`。创建时不传 `loginRequired` 按 `false` 处理；更新时不传则保留原值。`publish`、`offline` 只传路径参数 `videoId`，不要发送空对象或版本请求体。创建草稿允许内容暂时不完整，发布时必须满足：

- 标题、视频素材和有效封面完整。
- 视频素材为正常状态的 `grassland_guide` OSS MP4。
- 视频存在 `ossUrl`、`thumbnailUrl` 和正数时长。
- 正文包含有效文字或正文图片。

管理端不再提交 `publishTime`、`durationSeconds`、`relatedVideoIds`：

- `durationSeconds` 从当前 `videoMaterialId` 对应的视频素材自动派生。
- `publishTime` 在首次点击发布时由后端写入；后续编辑、下架和重上架都保留首次值。
- 相关推荐在小程序查询时自动生成，没有其他候选也不阻止当前内容发布。
- `sortWeight` 数值越小越靠前，影响精选 Hero 位及相关内容同分时的弱排序。

后台详情返回新增字段：

```json
{
  "videoMaterialId": "2076195939797688322",
  "videoUrl": "https://.../the-daily-dweebs-1080.mp4",
  "customCoverMaterialId": "2076196060279070722",
  "coverSource": "CUSTOM",
  "effectiveCoverUrl": "https://.../cover.jpg",
  "durationSeconds": 61,
  "publishTime": "2026-07-12 14:44:56",
  "linkedProductId": "2076196082752155649",
  "loginRequired": false
}
```

`durationSeconds`、`publishTime` 在 Knife4j 中均标记为只读。

所有雪花 ID 在 JavaScript 中都按字符串处理，避免超过安全整数范围。

## 6. 小程序接口

小程序提供四个只读接口：

| 方法 | 路径 | 说明 |
|---|---|---|
| `GET` | `/mp/grassland-guide/home?page=1&pageSize=20` | 精选区与视频分页 |
| `GET` | `/mp/grassland-guide/videos?page=1&pageSize=20` | 视频分页列表 |
| `GET` | `/mp/grassland-guide/videos/{videoId}` | 视频详情 |
| `GET` | `/mp/grassland-guide/videos/{videoId}/recommendations?page=1&pageSize=20` | 自动推荐分页，供滑动加载 |

分页规则：

- `page` 默认 `1`，小于 `1` 时按 `1` 处理。
- `pageSize` 默认 `20`，最大 `100`。
- 只返回已发布内容，按发布时间倒序。
- 标准分页字段为 `records`、`total`、`page`、`pageSize`；当前响应不返回 `totalPages`，需要时由前端计算。

详情直接返回 OSS 原视频地址：

```json
{
  "videoId": "2076196082752155649",
  "title": "草原指南测试｜The Daily Dweebs",
  "videoUrl": "https://.../the-daily-dweebs-1080.mp4",
  "durationSeconds": 61,
  "coverSource": "CUSTOM",
  "coverUrl": "https://.../cover.jpg",
  "contentHtml": "<p>正文内容</p>",
  "linkedProductId": "2076196082752155649",
  "linkedProductType": "CORE",
  "loginRequired": false
}
```

首页、列表、详情和推荐卡片均返回以下业务字段：

- `linkedProductId`：字符串，可空；绑定的产品 ID。
- `linkedProductType`：`CORE`、`GROUP`、`CUSTOM` 之一，可空。未绑定、产品不存在或产品服务暂时不可用时为空，草原指南内容仍正常返回。
- `loginRequired`：布尔值；为 `true` 时必须先登录才能读取详情和播放。

鉴权规则：

- 首页、分页列表、相关推荐分页继续允许匿名访问，卡片只返回展示字段，不返回正文和播放地址。
- 详情接口使用可选登录：`loginRequired=false` 时匿名返回 `200`；为 `true` 时匿名返回业务码 `401`，合法用户 JWT 返回 `200`。
- 前端遇到详情业务码 `401` 时拉起小程序登录，成功后携带新 token 重试原详情请求。
- 客户端提交的 `X-User-Id`、`X-User-Type`、`X-Auth-Level` 和管理员身份头均不可信，网关会删除后仅依据有效 JWT 重建身份。
- MP 详情不使用跨用户缓存，防止受保护视频的详情或 `videoUrl` 被匿名请求复用。

详情不再返回固定数组 `relatedVideos`。进入相关推荐区域时，请求独立分页接口：

```json
{
  "records": [
    {
      "videoId": "2076196084920610817",
      "title": "草原指南测试｜Caminandes 3：Llamigos",
      "summary": "Blender 官方真实短片",
      "coverUrl": "https://.../auto-cover.jpg",
      "coverSource": "AUTO",
      "durationSeconds": 151
    }
  ],
  "total": 2,
  "page": 1,
  "pageSize": 1
}
```

推荐排序规则：

1. 仅使用 `PUBLISHED` 内容，排除当前视频、已删除内容和重复项。
2. 同一 `linkedProductId`（关联行程/产品）优先。
3. 再按标题、摘要内容相似度排序。
4. `featured`、`sortWeight`、`publishTime` 只做弱排序。
5. 其余已上架内容使用稳定随机顺序兜底。

候选集合不变时各页顺序稳定且不重不漏。若管理员恰好在用户翻页期间发布或下架内容，标准 offset 分页边界可能变化，前端应按 `videoId` 去重后追加。

小程序直接使用原生组件：

```html
<video src="{{detail.videoUrl}}" controls></video>
```

这是 OSS 原始 MP4 点播/渐进式播放，不是直播流。前端不要接入直播播放器、`live-player`、FLV/HLS SDK，也不要把 `videoUrl` 转成 M3U8。OSS MP4 支持 Range 请求，原生 `<video>` 能从 MP4 元数据读取并显示当前播放时间、总时长和进度条。卡片时长使用接口的 `durationSeconds` 格式化即可，不做清晰度切换。

## 7. 测试环境验收结果

`dev-v3` 已于 2026-07-12 完成部署和真实数据验证：

- `hl-resource-service`：`8082/8182`，Nacos `2/2` 健康。
- `hl-mp-service`：`8085/8185`，Nacos `2/2` 健康。
- `hl-gateway`：`8080/8180`，Nacos `2/2` 健康。
- `hl-user-service`：`8081/8181`，Nacos `2/2` 健康。
- User Flyway `20260712.005` 执行成功；Resource Flyway `20260712.001` 执行成功。
- Knife4j 已展示草原指南管理接口、素材上传接口及只读字段；小程序文档为上述 4 个 GET。
- `POST /admin/material/upload/token`、`POST /admin/material/upload/confirm` 的请求体，以及详情/推荐路径中的 `videoId`，均在 Knife4j 中明确标记为必填。

真实数据：

| 内容 | 素材 ID | 内容 ID | 时长 | 封面 |
|---|---|---|---:|---|
| The Daily Dweebs | `2076195939797688322` | `2076196082752155649` | 61 秒 | `CUSTOM` |
| Caminandes 3: Llamigos | `2076195998660550657` | `2076196084920610817` | 151 秒 | `AUTO` |
| Glass Half | `2076196033146118145` | `2076196086652858370` | 194 秒 | `AUTO` |
| 呼伦贝尔草原有多大？（3812×2160 原片） | `2076248042813480962` | `2076248969276551170` | 83 秒 | `CUSTOM` |

验收结果：

- 三个文件都是不同的 Blender 官方真实作品，不是同一视频的多档清晰度。
- 三个视频均通过 `STS_MULTIPART` 上传，分类为 `grassland_guide`，存储方为 `OSS`。
- 自定义封面通过 `PRESIGNED_URL` 上传。
- MP4 Range 请求均返回 `206 video/mp4`。
- 自定义封面和 OSS 自动封面均返回 `200 image/*`。
- 小程序 `pageSize=2` 实测分页为第 1 页 2 条、第 2 页 1 条，无重复。
- 三个详情均直接返回 `videoUrl` 和正确时长，且不再包含 `relatedVideos`。
- 三个推荐分页均为 `total=2`；按 `pageSize=1` 加载两页，无自身、无重复，重复请求顺序一致。
- 实测下架其中一条后推荐 `total` 从 2 变为 1，候选中不含下架内容；重上架后恢复为 2。
- 故意提交伪造 `publishTime=2000年`、`durationSeconds=9999` 和旧 `relatedVideoIds`，后端均未采纳；下架重上架后首次发布时间保持不变。
- 旧 `/play-auth` 接口已不可用。
- 真实 4K 原片为 `412360919` 字节（约 393 MiB），通过 `STS_MULTIPART` 上传；OSS `HEAD` 返回 `200`、`Content-Type: video/mp4`、`Content-Length: 412360919`。
- 对 4K 原片请求 `Range: bytes=0-1` 返回 `206`、`Content-Range: bytes 0-1/412360919`，证明 OSS 支持原生 `<video>` 所需的分段读取，流量不经过业务服务器。
- 4K 内容使用独立上传的 JPEG 封面，详情返回 `coverSource=CUSTOM`；推荐分页返回 3 条上架内容且排除了当前视频。
- 当前测试桶默认域名同时返回 `Content-Disposition: attachment` 和 `x-oss-force-download: true`。HTTP Range 能正常工作，但小程序仍须做真机播放验收。2026-07-13 最终决定保持默认 OSS 域名，不切换自定义域名；前端不得自行改写域名，若原生 `<video>` 真机播放失败，保留错误码与设备信息后反馈后端处理。
- 2026-07-13 已从管理端和小程序端完整删除 `showProducedBadge`：Resource/MP OpenAPI 均无该字段，首页、列表、详情、推荐 4 个实际接口均返回 `200` 且无该字段；Resource Flyway `20260713.001` 执行成功，遗留数据库列已删除。
- 2026-07-13 PR #4953 已合入 `dev-v3`，并按 `hl-gateway` → `hl-mp-service` → `hl-resource-service` 部署测试环境；三项服务均完成双实例滚动升级。该 PR 补齐产品绑定字段、单条内容登录鉴权并移除前端 `version` 契约。
- 2026-07-13 PR #4958 已将 `dev@73e25cf61` 的完整交付链同步回 `dev-v3@6b7775ab`。该 PR 文件差异为 0，合并前后代码树一致；随后仅在测试环境按 `hl-mp-service` → `hl-gateway` → `hl-resource-service` 再次串行滚动发布，任务分别为 `f9159fab`、`655b9b6e`、`40bacc94`，三项服务 Nacos 均为 `2/2` 健康。
- 本轮真实接口复验确认管理端列表/详情与小程序列表/详情/推荐均成功；管理端和草原指南小程序契约均无前端 `version`，推荐分页排除自身且不重复。
- 2026-07-13 已补充受保护真实 4K 测试内容：匿名详情返回 `401`，伪造 `X-User-Id` 仍返回 `401`，有效小程序 USER token 返回 `200`；关闭 `loginRequired` 后匿名详情恢复 `200`。测试 token 仅在 TEST 宿主机本地 Redis 通过 `SCAN` 读取并在内存中使用，未输出、未落盘。
- 音画同步不能仅凭 OSS `HEAD` 或 `Range 206` 判定通过。当前仍为 OSS 4K 原片直出，目标机型真机播放验收须单独记录；未经真机验证不得标记“音画同步已修复”。

## 8. 2026-07-14 正式环境 Nacos 部署清单

草原指南一期不使用 CDN、自定义 OSS 域名、VOD、直播流、HLS 或转码。2026-07-13 已只读确认正式 Nacos 的视频上限和分片阈值均已是目标值，正式部署时只复核，不重复覆盖配置。部署 `hl-user-service` 前执行：

1. 在正式 Nacos 读取并备份完整 `hl-user-service-prod.yml`，确认生产实例实际加载的 namespace、group 和 dataId。
2. 复核以下配置；当前值一致时不要发布 Nacos 变更：

```yaml
file:
  max-size:
    video: 2147483648
  resumable-threshold: 10485760
```

3. 不复制仓库内测试配置中的其他值，不覆盖正式环境已有的 OSS、数据库、Redis 或鉴权配置。
4. 保持正式 `oss.domain` 为当前默认 Bucket 域名，不切换 `oss.aly.1814.love` 或其他自定义域名。
5. 串行滚动部署 `hl-user-service`，确认两实例均注册健康且配置已生效。
6. 用正式 OSS 做一轮大于 10 MiB 的 MP4 分片上传、确认、`HEAD` 和 `Range 206` 验收；不得在日志或交付文档中记录 STS、AccessKey、Token 等凭证。

### 8.1 `showProducedBadge` 删除的正式环境两阶段发布

正式环境必须把应用字段收缩与数据库删列分两次发布，不能将两个阶段合成一次滚动部署：

1. 先将 PR [#4946](https://git.1814.love:8443/wx/HL/pulls/4946) 的应用层变更单独合入正式目标分支。
2. 依次滚动部署 `hl-resource-service`、`hl-mp-service`，确认两服务的全部正式实例都已升级且健康；此阶段数据库列仍保留。
3. 验证正式 OpenAPI 和草原指南首页、列表、详情、推荐响应均不再含 `showProducedBadge`。
4. 再将 PR [#4948](https://git.1814.love:8443/wx/HL/pulls/4948) 的 `V20260713_001` 迁移合入正式目标分支，只滚动部署 `hl-resource-service`。
5. 确认 Flyway `20260713.001` 成功、`show_produced_badge` 列不存在、资源服务全部实例健康，再结束发布。

若正式目标分支已经同时包含 #4946 和 #4948，不得直接按该分支首次启动旧实例集群；应先准备仅包含 #4946 的中间发布版本完成步骤 1～3，再切换到包含 #4948 的最终版本。该变更不涉及 Nacos 配置修改。

### 8.2 登录鉴权与产品字段正式发布门禁

PR #4953 不新增 Nacos 配置。正式发布最终版本时按以下顺序滚动：

1. `hl-mp-service`：先移除详情缓存并上线产品字段聚合。
2. `hl-gateway`：上线草原详情 `OPTIONAL` 鉴权和客户端身份头清洗。
3. `hl-resource-service`：最后执行 Flyway `20260713.002` 并启用单条内容登录判断。

所有 Resource 实例完成升级前，不得把任何正式视频设置为 `loginRequired=true`。回滚旧 Resource 前应先把受保护视频改为公开或下架，否则旧代码不识别该字段，可能重新暴露详情。正式 `main` 尚未发布时，本节仅作为发布门禁，不代表已部署。

## 9. 前端改造检查单

- [ ] 新增“草原指南管理”列表、编辑和发布页面。
- [ ] 素材库展示顶级分类“草原指南”，隐藏系统分类删除/停用/改码操作。
- [ ] 不提交视频 `durationSeconds`；上传确认后只读展示后端自动提取的时长，不允许手填。
- [ ] 使用素材库通用上传接口处理 `PRESIGNED_URL` 和 `STS_MULTIPART`。
- [ ] 支持单独上传/选择图片作为自定义封面，并允许清空后恢复自动封面。
- [ ] 草原指南内容保存 `videoMaterialId`，不保存 VOD 字段。
- [ ] 不展示“呼籁出品”开关或角标，不提交、不读取 `showProducedBadge`。
- [ ] 创建/编辑页增加“需要登录后查看”开关并提交 `loginRequired`，默认关闭。
- [ ] 删除草原指南所有 `version` 状态、请求参数和发布/下架请求体。
- [ ] 保存关联产品 ID；按小程序返回的 `linkedProductId`、`linkedProductType` 跳转对应产品。
- [ ] 发布日期只读：草稿显示未发布，首次发布成功后刷新详情回显后端时间。
- [ ] 删除管理员手工选择相关推荐的控件，不提交 `relatedVideoIds`。
- [ ] 小程序列表接入分页参数和分页结果。
- [ ] 小程序详情直接使用 `videoUrl` 播放。
- [ ] 卡片读取 `loginRequired`；进入受保护详情前触发登录，详情返回业务码 `401` 时登录后重试。
- [ ] 将 `videoUrl` 作为 OSS 原始 MP4 点播地址处理，不接 `live-player`、直播 SDK、FLV/HLS/M3U8 转换或自定义域名替换逻辑。
- [ ] 相关推荐通过独立分页接口滑动加载，并按 `videoId` 去重追加。
- [ ] 删除 VOD SDK、`play-auth` 调用和清晰度切换 UI。
- [ ] 所有雪花 ID 均按字符串处理。
