# 小程序导览音频与讲解词接口对接说明 ## 0. 当前结论 小程序播放导览音频时,先调用播放解析接口: ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001&lang=zh-CN ``` 如果需要展示讲解词正文,再按需调用文本接口: ```http GET /app-api/gis/guide/audio/text-info?targetType=ITEM&targetId=1001&lang=zh-CN ``` 两个接口刻意分开: - `play-info` 只负责尽快返回唯一可播放 `playUrl`,保证游客点击后音频优先播放。 - `text-info` 只在页面确实要展示正文、字幕面板或讲解词详情时调用,避免每次播放都带上大段文本。 - 音频播放失败不应被文本查询拖累;文本缺失也不影响音频播放。 - 两类数据缓存策略不同:播放解析是短 TTL 元信息缓存,讲解词是热门文本缓存 + LRU 淘汰。 ## 1. 默认参数规则 `targetType` 和 `targetId` 必须传,不做默认值。 `lang` 可以不传,不传时后端默认按中文标准语言 `zh-CN` 解析。 ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001 GET /app-api/gis/guide/audio/text-info?targetType=ITEM&targetId=1001 ``` 等价于: ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001&lang=zh-CN GET /app-api/gis/guide/audio/text-info?targetType=ITEM&targetId=1001&lang=zh-CN ``` 小程序仍建议显式传全局语言状态,尤其是用户切换英文后: | 参数 | 是否必填 | 取值 | 说明 | | --- | --- | --- | --- | | `targetType` | 是 | `ITEM` / `STOP` | `ITEM` 是展品入口,`STOP` 是讲解点入口 | | `targetId` | 是 | number | 必须和 `targetType` 匹配 | | `lang` | 否 | `zh-CN` / `en-US` | 不传默认 `zh-CN`;兼容历史 `zh` / `en` 入参 | 不要传: - `standard` / `extended` - `zh` / `en` 作为正式对接语言码 - `businessId` - MinIO 路径 - 四通道 URL,如 `standardAudioUrl`、`extendedAudioUrl` ## 2. ITEM / STOP 语义 `STOP` 是真实讲解播放单元。 `ITEM` 是小程序兼容入口。 | 场景 | 推荐调用 | | --- | --- | | 小程序只有展品 ID | `targetType=ITEM&targetId=展品ID` | | 小程序已经拿到讲解点 ID | `targetType=STOP&targetId=讲解点ID` | 规则: - `ITEM` 的 `targetId` 必须是展品 ID,不要传讲解点 ID。 - `STOP` 的 `targetId` 必须是讲解点 ID,不要传展品 ID。 - `ITEM` 请求内部会根据展品 `stopId` 找到讲解点,但响应里的 `targetType`、`targetId` 仍保持小程序请求值,便于客户端按请求维度缓存。 ## 3. 播放解析接口 ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001&lang=zh-CN ``` 可播放响应: ```json { "code": 0, "data": { "playable": true, "targetType": "ITEM", "targetId": 1001, "lang": "zh-CN", "narrationTier": "STANDARD", "audioId": 8912, "title": "青铜神树", "duration": 120, "format": "mp3", "playUrl": "https://cdn.museum.com/tts-audio/xxx.mp3", "expiresAt": null, "subtitleUrl": null, "hasText": true, "fallback": false, "fallbackReason": null, "reason": null } } ``` 不可播放仍然返回 `code=0`,通过 `playable=false` 表达: ```json { "code": 0, "data": { "playable": false, "targetType": "ITEM", "targetId": 1001, "lang": "en-US", "narrationTier": "STANDARD", "hasText": false, "fallback": false, "reason": "NO_PUBLISHED_AUDIO" } } ``` 字段说明: | 字段 | 说明 | | --- | --- | | `playable` | 是否可播放 | | `playUrl` | 可直接赋值给小程序音频组件的 HTTPS/CDN/对象存储地址 | | `narrationTier` | 后端解析出的版本:`STANDARD` / `EXTENDED` | | `hasText` | 当前语言和版本是否有讲解词正文;有正文时可按需调用 `text-info` | | `fallback` | 是否发生版本降级或历史音频回退 | | `reason` | 不可播放原因 | 常见 `reason`: | reason | 小程序建议处理 | | --- | --- | | `NO_GUIDE_STOP` | 展品未绑定讲解点 | | `NO_GUIDE_CONTENT` | 讲解点没有讲解词 | | `NO_PUBLISHED_AUDIO` | 当前语言没有已发布音频 | | `UNSUPPORTED_LANGUAGE` | 语言不支持 | | `UNSUPPORTED_TARGET_TYPE` | targetType 不支持 | | `TARGET_NOT_FOUND` | 目标不存在 | ## 4. 讲解词正文接口 ```http GET /app-api/gis/guide/audio/text-info?targetType=ITEM&targetId=1001&lang=zh-CN ``` 可用响应: ```json { "code": 0, "data": { "available": true, "targetType": "ITEM", "targetId": 1001, "lang": "zh-CN", "narrationTier": "STANDARD", "title": "青铜神树", "text": "青铜神树是三星堆遗址出土的重要青铜器...", "textLength": 1200, "textHash": "0b32a4..." } } ``` 不可用响应同样返回 `code=0`: ```json { "code": 0, "data": { "available": false, "targetType": "ITEM", "targetId": 1001, "lang": "en-US", "narrationTier": "STANDARD", "reason": "NO_TEXT" } } ``` 字段说明: | 字段 | 说明 | | --- | --- | | `available` | 是否有正文 | | `text` | 讲解词正文,`available=false` 时为空 | | `textLength` | 正文字数,按 Java 字符数统计 | | `textHash` | 正文 MD5,用于小程序本地缓存版本判断 | | `reason` | 不可用原因 | `text-info` 和 `play-info` 使用同一套语言、版本和目标解析逻辑。也就是说,小程序不需要自己判断标准版/拓展版;文本接口会返回与当前后端播放解析一致的正文版本。 ## 5. 推荐调用流程 小程序点击播放时: 1. 调用 `play-info`。 2. 如果 `playable=false`,展示不可播放原因,不调用 `text-info`。 3. 如果 `playable=true`,立即设置 `audio.src = playUrl` 并开始播放。 4. 如果当前页面要展示讲解词,且 `hasText=true`,再异步调用 `text-info`。 5. `text-info` 成功后渲染正文;失败或 `available=false` 不影响音频播放。 示例: ```ts async function playGuideAudio(targetType: "ITEM" | "STOP", targetId: number) { const lang = getGlobalLanguage() || "zh-CN" const playInfo = await api.getAudioPlayInfo({ targetType, targetId, lang }) if (!playInfo.playable || !playInfo.playUrl) { showToast(reasonToText(playInfo.reason)) return } audioContext.stop() audioContext.src = playInfo.playUrl audioContext.title = playInfo.title || "" audioContext.play() if (playInfo.hasText && shouldShowNarrationText()) { loadNarrationTextLazy(targetType, targetId, lang) } } async function loadNarrationTextLazy(targetType: "ITEM" | "STOP", targetId: number, lang: string) { const cacheKey = `${targetType}:${targetId}:${lang}` const cached = narrationTextCache.get(cacheKey) if (cached) { renderNarrationText(cached.text) return } const textInfo = await api.getAudioTextInfo({ targetType, targetId, lang }) if (!textInfo.available) { return } narrationTextCache.set(cacheKey, { text: textInfo.text, textHash: textInfo.textHash }) renderNarrationText(textInfo.text) } ``` ## 6. 为什么不在 play-info 里直接返回正文 从系统架构看,音频播放和正文展示是两个不同优先级的动作。 播放路径应该尽量短: - 游客点击播放后的第一目标是尽快拿到 `playUrl` 并开始播放。 - 大段正文会增加接口响应体,弱网下会拖慢播放启动。 - 很多播放场景只需要音频,不一定展开讲解词面板。 - 音频地址和正文的缓存策略不同,混在一个接口里会让缓存粒度变粗。 - 文本接口失败时,不应该影响音频播放。 因此当前设计是: - `play-info`:轻量、高频、短 TTL,返回播放必要信息。 - `text-info`:按需、可延迟、热门文本 LRU 缓存,返回正文。 这对小程序实现也比较直接:音频先播,文本异步补上。页面可以先显示标题、加载态或正文骨架,文本回来后再填充。 ## 7. 缓存策略 ### 7.1 后端播放解析缓存 `play-info` 使用 Redis 缓存轻量播放解析结果。 | 项 | 策略 | | --- | --- | | Redis key | `gis:guide:play-info:{targetType}:{targetId}:{lang}` | | TTL | 5 分钟 | | 缓存内容 | 播放元信息,不含音频二进制 | | 失效时机 | 音频发布、撤回、通道快照刷新、全量快照重建 | 音频文件流量仍然走对象存储 / CDN / HTTPS 静态地址,业务后端不代理音频流。 ### 7.2 后端热门文本缓存 + LRU 淘汰 `text-info` 使用 Redis 做热门文本缓存,只缓存 `available=true` 的正文响应。 | 项 | 策略 | | --- | --- | | Redis key | `gis:guide:text-info:{targetType}:{targetId}:{lang}:{narrationTier}` | | LRU 索引 | `gis:guide:text-info:lru` | | TTL | 60 分钟 | | 最大条数 | 1000 条 | | 淘汰方式 | Redis ZSet 记录最近访问时间,超过上限时删除最久未访问的 key | | 大文本处理 | 响应 JSON 超过 2KB 时 gzip 后 base64 存储 | | 负结果缓存 | 不缓存 `available=false`,避免后台补文本后长期命中旧缺失状态 | `textHash` 返回给小程序,用于本地缓存版本判断;后端 Redis key 不把 `textHash` 放进去,因为那样会导致每次命中缓存前还要先查数据库计算 hash,反而失去缓存意义。 ### 7.3 小程序本地缓存建议 播放信息本地缓存: - key:`targetType + targetId + lang` - 语言切换后清理旧语言缓存。 - 播放失败、403、404 或 URL 过期时,重新请求一次 `play-info`。 正文文本本地缓存: - key:`targetType + targetId + lang` - value:`text + textHash` - 若重新请求 `text-info` 后 `textHash` 变化,替换本地正文。 - 不要在小程序侧长期永久缓存正文;建议按会话或短期持久化缓存即可。 ## 8. 2 万游客/天的性能口径 按一天 2 万游客访问,播放解析接口和文本接口都应该由 Redis 承接热点请求,但系统瓶颈不能放在 Spring Boot 音频流上。 当前职责分工: - `play-info`:轻量解析,Redis 短 TTL 缓存。 - `text-info`:热门正文 Redis 缓存,LRU 控制容量。 - 音频文件:对象存储 / CDN / HTTPS 直出。 这样即使热门展品被大量点击: - 播放地址解析大多命中 `play-info` 缓存。 - 正文展示大多命中 `text-info` 热文本缓存。 - 最大流量的 MP3 文件不经过业务后端。 后续如果真实压测显示 Redis 或数据库仍有压力,再考虑: - 对展厅/热门展品做预热缓存。 - 对 `play-info` 和 `text-info` 增加批量预取,但播放前仍以单个 `play-info` 为准。 - 将音频和文本静态化到 CDN 边缘,但仍由后端接口返回当前可用地址。 ## 9. 联调检查清单 - 不传 `lang`:`play-info` 默认返回中文标准版解析结果。 - 不传 `lang`:`text-info` 默认返回中文标准版正文。 - 中文可播放:`playable=true`,`playUrl` 可直接播放,`hasText` 正确。 - 中文有正文:`text-info` 返回 `available=true`、`text`、`textLength`、`textHash`。 - 英文缺音频:`play-info` 返回 `NO_PUBLISHED_AUDIO`,不自动播放中文。 - 英文缺正文:`text-info` 返回 `available=false`,不影响中文和音频播放。 - 展品未绑定讲解点:`NO_GUIDE_STOP`。 - 讲解点无讲解词:`NO_GUIDE_CONTENT`。 - 播放 URL 失效:小程序重新请求一次 `play-info`。 - 后台发布/刷新音频后:对应 `play-info` 和 `text-info` 缓存应失效。 - 热门正文多次请求:第二次开始命中 Redis 文本缓存。