# 小程序语音播放接口对接说明 ## 0. 对接速览 小程序播放语音时统一调用后端播放解析接口,返回的是可播放文件地址 `playUrl` 和元信息,不再从展品列表或详情中直接读取四通道音频 URL,也不再按字节流方式处理音频。 ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001&lang=zh-CN GET /app-api/gis/guide/audio/play-info?targetType=STOP&targetId=2001&lang=en-US ``` 小程序只需要传: | 参数 | 取值 | 说明 | | --- | --- | --- | | `targetType` | `ITEM` / `STOP` | `ITEM` 为展品入口,`STOP` 为讲解点入口 | | `targetId` | number | 展品 ID 或讲解点 ID,必须和 `targetType` 匹配 | | `lang` | `zh-CN` / `en-US` | 来自小程序全局语言状态 | 小程序不要传标准版 / 拓展版,版本由后端根据用户权益解析。返回 `playable=true` 时直接播放 `playUrl`;返回 `playable=false` 时按 `reason` 展示不可播放提示。 ## 1. 对接原则 Phase 1 后,小程序不要再从展品列表或详情里的四个音频字段自行判断播放地址。播放时统一调用后端播放解析接口,由后端根据展品/讲解点、全局语言和后续权益策略返回唯一播放资源。 小程序只维护三件事: 1. 全局语言状态:`zh-CN` / `en-US`。 2. 播放目标:`targetType + targetId + lang`。 3. 使用后端返回的唯一 `playUrl` 播放音频。 不要在小程序内做这些事情: - 不要读取 `standardAudioUrl`、`standardAudioUrlEn`、`extendedAudioUrl`、`extendedAudioUrlEn` 来选择音频。 - 不要给游客提供“标准版 / 拓展版”切换。 - 不要在小程序内硬编码收费、会员、白名单等权益判断。 - 不要把某个固定 MinIO 或 CDN URL 当作长期有效地址永久缓存。 ## 2. 语言码约定 播放接口、小程序和 TTS 资产统一使用 TTS 标准语言码: | 语言 | 小程序入参 | 后端响应 | TTS 表字段 | | --- | --- | --- | --- | | 中文 | `zh-CN` | `zh-CN` | `tts_audio_file.language` / `tts_voice.language` | | 英文 | `en-US` | `en-US` | `tts_audio_file.language` / `tts_voice.language` | 注意: - 小程序不要把 `zh-CN` 转成 `zh`,也不要把 `en-US` 转成 `en`。 - 后端会临时兼容历史入参 `zh` / `en` / `zh_CN` / `en_US`,但这不是小程序对接契约。 - GIS 历史 `businessId = guide_content:{id}:{version}:{zh/en}` 只属于服务端内部兼容细节,不传给小程序。 - 英文没有音频时,后端不会自动降级播放中文。 ## 3. 播放目标入参规则 Phase 1 同时支持 `ITEM` 和 `STOP`,但两者语义不同: | 场景 | 推荐入参 | 说明 | | --- | --- | --- | | 小程序当前只有展品 ID | `targetType=ITEM&targetId=展品ID` | 兼容入口。后端根据展品绑定的 `stopId` 解析到讲解点音频,小程序不需要自己查讲解点。 | | 小程序已经拿到讲解点 ID | `targetType=STOP&targetId=讲解点ID` | 首选入口。讲解点是当前阶段真实的语音生产和播放单元。 | 关键规则: - `ITEM` 的 `targetId` 必须是展品 ID,不要传讲解点 ID。 - `STOP` 的 `targetId` 必须是讲解点 ID,不要传展品 ID。 - 如果展品没有绑定讲解点,后端返回 `playable=false`,`reason=NO_GUIDE_STOP`。 - 如果讲解点存在但没有对应语言的发布音频,后端返回 `playable=false`,`reason=NO_PUBLISHED_AUDIO` 或 `NO_GUIDE_CONTENT`。 - Phase 1 响应里的 `targetType`、`targetId` 保持为请求目标,便于小程序按请求维度缓存;后端内部是否解析到 `STOP` 不要求小程序感知。 - 后续如果展品列表或详情响应增加 `playTargetType`、`playTargetId`,小程序优先使用这两个字段;没有这两个字段时继续按 `ITEM + 展品ID` 调用。 也就是说,小程序现在按展品 ID 播放不是问题,但它应该调用播放解析接口,而不是自己读取展品里的音频 URL。后台负责把“展品 -> 讲解点 -> 当前语言/权益音频”这条链路解析完。 ## 4. 单个播放解析 ```http GET /app-api/gis/guide/audio/play-info?targetType=ITEM&targetId=1001&lang=zh-CN GET /app-api/gis/guide/audio/play-info?targetType=STOP&targetId=2001&lang=zh-CN ``` Query 参数: | 参数 | 类型 | 必填 | 说明 | | --- | --- | --- | --- | | `targetType` | string | 是 | `ITEM` 表示展品,`STOP` 表示讲解点 | | `targetId` | number | 是 | 对应目标类型的业务 ID:展品 ID 或讲解点 ID | | `lang` | string | 是 | 全局语言,`zh-CN` 或 `en-US` | Header: | Header | 必填 | 说明 | | --- | --- | --- | | `Authorization` | 否 | 登录用户建议携带;Phase 1 未登录默认按免费标准版处理 | 可播放响应: ```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, "fallback": false, "fallbackReason": null, "reason": null } } ``` 不可播放响应仍是业务成功,靠 `playable=false` 表达: ```json { "code": 0, "data": { "playable": false, "targetType": "ITEM", "targetId": 1001, "lang": "en-US", "narrationTier": "STANDARD", "fallback": false, "reason": "NO_PUBLISHED_AUDIO" } } ``` 常见 `reason`: | reason | 小程序建议处理 | | --- | --- | | `NO_PUBLISHED_AUDIO` | 提示“当前语言暂无语音讲解” | | `NO_GUIDE_STOP` | 提示“该展品暂未配置语音讲解” | | `NO_GUIDE_CONTENT` | 提示“该目标暂无讲解内容” | | `UNSUPPORTED_LANGUAGE` | 提示“不支持该语言” | | `UNSUPPORTED_TARGET_TYPE` | 记录错误,不展示播放入口 | | `TARGET_NOT_FOUND` | 提示“目标信息不存在” | ## 5. 展品列表摘要字段 展品列表和详情响应会补充轻量音频摘要,供页面决定是否展示播放入口: | 字段 | 说明 | | --- | --- | | `hasAudio` | 是否存在任一可播放音频 | | `supportedLanguages` | 可播放语言列表,值为 `zh-CN` / `en-US` | | `audioStatus` | `READY` / `MISSING` | | `audioCount` | 可播放音频通道数量 | | `playTargetType` | 可选;后端建议的小程序播放目标,通常为 `STOP` | | `playTargetId` | 可选;后端建议的小程序播放目标 ID,通常为讲解点 ID | 这些字段只用于列表展示和按钮可用态。真正播放前仍调用 `play-info` 获取当次可播放 URL。 `playUrl` 是“可播放地址”,不是音频字节流。小程序拿到后直接赋给 `audio.src` 即可,不需要再做下载、转 Blob 或手动拼接媒体流。 如果响应里没有 `playTargetType`、`playTargetId`,小程序按兼容规则使用 `targetType=ITEM&targetId=展品ID` 即可。 ## 6. 推荐播放流程 ```ts type AudioPlayInfo = { playable: boolean targetType: "ITEM" | "STOP" targetId: number lang: "zh-CN" | "en-US" narrationTier: "STANDARD" | "EXTENDED" playUrl?: string expiresAt?: string | null title?: string reason?: string } const audioPlayInfoMap = new Map() function audioKey(targetType: "ITEM" | "STOP", targetId: number, lang: string) { return `${targetType}:${targetId}:${lang}` } function isExpired(expiresAt?: string | null) { return !!expiresAt && new Date(expiresAt).getTime() <= Date.now() } async function playGuideAudio(targetType: "ITEM" | "STOP", targetId: number) { const lang = getGlobalLanguage() // "zh-CN" | "en-US" const key = audioKey(targetType, targetId, lang) let info = audioPlayInfoMap.get(key) if (!info || isExpired(info.expiresAt)) { info = await api.getAudioPlayInfo({ targetType, targetId, lang }) audioPlayInfoMap.set(key, info) } if (!info.playable || !info.playUrl) { showToast(reasonToText(info.reason)) return } audioContext.stop() audioContext.src = info.playUrl audioContext.title = info.title || "" audioContext.play() } ``` 播放失败或 URL 过期时,重新请求一次单个解析接口: ```ts audioContext.onError(async () => { const lang = getGlobalLanguage() const fresh = await api.getAudioPlayInfo({ targetType: currentTargetType, targetId: currentTargetId, lang }) audioPlayInfoMap.set(audioKey(currentTargetType, currentTargetId, lang), fresh) if (fresh.playable && fresh.playUrl) { audioContext.src = fresh.playUrl audioContext.play() } else { showToast(reasonToText(fresh.reason)) } }) ``` ## 7. 缓存与流量策略 Phase 1 已接入 Redis 播放解析缓存。当前后端会缓存 `play-info` 结果和必要的播放元信息,但不会把音频二进制放进 Redis。 职责边界: - 音频文件播放流量应直接走对象存储 / CDN,业务后端只承接轻量的播放解析请求。 - 后端对 `play-info` 做基础 IP 限流,避免脚本刷解析接口。 - 后端使用 Redis 缓存播放解析结果,缓存维度至少包含 `targetType + targetId + lang`,并在发布、撤回、重建快照后主动失效。 - 如果后续 `playUrl` 改成短期签名地址,本期小程序按 `expiresAt` 和播放失败重试机制重新请求 `play-info` 即可。 小程序侧配合: - 不要预下载大量音频文件,点击播放时拿到 `playUrl` 后再播放。 - 本地可以短暂缓存 `play-info` 结果,缓存 key 使用 `targetType + targetId + lang`;语言切换后清理旧语言缓存。 - 播放失败、403、404 或 URL 过期时,重新请求一次 `play-info`,不要无限重试。 - 连续快速点击多个展品时,只保留最后一次点击的播放请求和播放结果。 - 切换语言时清理旧语言的本地播放缓存,避免中文/英文串播。 后续如果真实流量证明播放解析接口成为瓶颈,再进入 Phase 2 增加 Redis 元信息缓存。届时缓存 key 至少应包含 `targetType + targetId + lang + narrationTier`,且后台发布音频时需要精确失效对应缓存。真正的大流量压力仍应由 CDN / 对象存储承担。 ## 8. 语言切换处理 用户切换全局语言后: - 后续所有 `play-info` 都传新语言码。 - 本地播放结果缓存按 `targetType + targetId + lang` 分开,或直接清理旧语言缓存。 - 当前正在播放的音频建议停止,提示用户重新播放当前展品的新语言版本。 - 如果新语言没有音频,展示不可播放提示,不自动播放另一种语言。 ## 9. 联调检查清单 - 中文标准音频展品:`lang=zh-CN` 能拿到唯一 `playUrl` 并播放。 - 展品 ID 兼容播放:`targetType=ITEM&targetId=展品ID` 能由后端解析到绑定讲解点音频。 - 中文讲解点音频:`targetType=STOP&lang=zh-CN` 能拿到唯一 `playUrl` 并播放。 - 展品未绑定讲解点:返回 `playable=false`、`reason=NO_GUIDE_STOP`。 - 英文缺失展品:`lang=en-US` 返回 `playable=false`,不会拿中文 URL。 - 语言切换后:请求参数和本地缓存 key 都发生变化。 - 未登录用户:返回 `STANDARD`。 - 播放 URL 失效:重新请求 `play-info` 后恢复播放或展示原因。 - 快速连续点击多个展品:最终只播放最后一次点击的展品。