chore: sync latest project updates
Some checks failed
CI / verify (push) Has been cancelled

This commit is contained in:
lyf
2026-07-03 14:42:38 +08:00
parent 8b2c36677e
commit 8fed715235
106 changed files with 6030 additions and 121 deletions

View File

@@ -225,7 +225,7 @@ MuseumFloor {
| 后端 floorCode | 领域 label |
| --- | --- |
| `EXTERIOR` | `外` |
| `EXTERIOR` | `外` |
| `L-2` | `B2` |
| `L-1` | `B1` |
| `L1` | `1F` |
@@ -328,7 +328,7 @@ interface SgsNavigablePlaceDomain {
```text
mapDiagnostics.status === 'OK'
且所有内可导航楼层 routePlanningReady=true
且所有内可导航楼层 routePlanningReady=true
且 routeNodeCount > 0
且 routeEdgeCount > 0
且关键楼层 navigablePlaceCount > 0
@@ -536,7 +536,7 @@ pnpm build:h5
## 12. 上线前阻断项
以下问题未解决前,不建议对用户宣称“正式内导航”:
以下问题未解决前,不建议对用户宣称“正式内导航”:
- `L-1` 无可导航目的地。
- `EXTERIOR` 路网未就绪。

View File

@@ -3,7 +3,7 @@
审计日期2026-05-28
审计分支:`analysis/ux-ui-audit-2026-05-28`
审计对象:`museum-guide-v4.0/frontend-miniapp`
审计范围Mock 数据、类型定义、数据加载、搜索/地图/详情页硬编码数据、静态资源、3D/内 POI 数据、数据治理与性能策略。
审计范围Mock 数据、类型定义、数据加载、搜索/地图/详情页硬编码数据、静态资源、3D/内 POI 数据、数据治理与性能策略。
## 执行摘要
@@ -25,7 +25,7 @@
| 方法 | 覆盖内容 | 证据来源 |
| --- | --- | --- |
| JSON 结构扫描 | 展品、展厅、设施、路线、楼层、内 POI 数量与字段 | `src/assets/data/*.json``static/data/f1-indoor-pois.json` |
| JSON 结构扫描 | 展品、展厅、设施、路线、楼层、内 POI 数量与字段 | `src/assets/data/*.json``static/data/f1-indoor-pois.json` |
| 引用完整性校验 | 展品到展厅、楼层到展厅/设施、路线到站点 | Node 脚本读取 JSON 后交叉比对 |
| 静态资源存在性校验 | 图片、音频、展厅图是否存在于 `static/` | 文件系统校验 |
| 硬编码扫描 | 页面、组件、地图、搜索中的本地数组和占位 URL | `rg` 搜索 |
@@ -40,7 +40,7 @@
| `src/assets/data/facilities.json` | 8 | 基础设施/模型坐标混合数据 | 点位坐标来自 `f1-floor.glb` 提取,应保留;但实体名称、类型、描述仍与真实 1F POI 和自然博物馆语义不一致 |
| `src/assets/data/routes.json` | 3 | 艺术馆路线 Mock | 路线名称、站点均围绕艺术作品;不适配自然博物馆参观动线 |
| `src/assets/data/floors.json` | 4 | 楼层索引 | `B1` 引用不存在设施,且无展厅 |
| `static/data/f1-indoor-pois.json` | 67 | 真实 1F 内点位基准 | 其它设施、展厅、搜索、地图详情数据未以它为准,存在大面积冲突 |
| `static/data/f1-indoor-pois.json` | 67 | 真实 1F 内点位基准 | 其它设施、展厅、搜索、地图详情数据未以它为准,存在大面积冲突 |
| `static/models/*.glb` | 2 | 真实 1F 3D 模型资产 | `f1-indoor.glb``f1-floor.glb` 均为自然博物馆 1F 真实模型;需补模型清单、坐标系与部署目录策略 |
| `static/icons/*.svg` | 8 | 地图图标 | 可用,但 POI 类型枚举与图标映射未统一 |
@@ -53,7 +53,7 @@ flowchart TD
D["pages/detail.vue<br/>硬编码详情"] --> E["详情页展示"]
F["SearchPanel / ExplainList<br/>自然博物馆硬编码 Mock"] --> G["搜索/讲解抽屉"]
H["TencentMap / ThreeMap<br/>硬编码地图点"] --> I["地图弹层"]
J["static/data/f1-indoor-pois.json<br/>67 个内 POI"] -.未统一接入.-> I
J["static/data/f1-indoor-pois.json<br/>67 个内 POI"] -.未统一接入.-> I
```
## 1. 数据完整性审计
@@ -64,7 +64,7 @@ flowchart TD
| --- | --- | --- | --- |
| 展品 | 5 个艺术作品 | 缺少标本、化石、矿物、动植物、年代、分类、馆藏编号、展陈状态、讲解文本层级 | P0 |
| 展厅 | 5 个艺术主题展厅 | 缺少自然史展厅分区、楼层分布、入口/出口、展厅开放状态、人流/容量 | P0 |
| 设施 | 8 个基础设施 | 内 POI 有 58 个设施,但未与设施表合并;缺少服务台、母婴室、寄存、无障碍、楼梯等正式类型 | P1 |
| 设施 | 8 个基础设施 | 内 POI 有 58 个设施,但未与设施表合并;缺少服务台、母婴室、寄存、无障碍、楼梯等正式类型 | P1 |
| 路线 | 3 条艺术馆路线 | 缺少亲子、研学、无障碍、快速参观、自然史主题路线 | P0 |
| 楼层 | 4 层索引 | 只有简单数组引用,无真实地图区域、楼层坐标系、模型版本、开放状态 | P1 |
| POI | F1 有 67 个模型点 | 只覆盖 `1F`,未绑定展品/展厅/路线/设施详情 | P1 |
@@ -202,7 +202,7 @@ POI floors: 1F
| 类型 | 当前定义 | 发现的问题 | 严重性 |
| --- | --- | --- | --- |
| `Position` | `{ x, y }` | 2D 坐标,不支持内 POI 的 `{ x, y, z }`,也不支持腾讯地图的 `{ latitude, longitude }` | P1 |
| `Position` | `{ x, y }` | 2D 坐标,不支持内 POI 的 `{ x, y, z }`,也不支持腾讯地图的 `{ latitude, longitude }` | P1 |
| `Facility.type` | `restroom/cafe/shop/exit/elevator/info` | 不包含 `entrance``hall``stairs``mother_baby``accessible_restroom``service_desk` 等真实 POI 类型 | P1 |
| `POIMarker.type` | `exhibit/hall/facility/location` | 不包含 `entrance`,与 `ThreeMap``f1-indoor-pois.json` 不一致 | P1 |
| `Exhibit` | 艺术品字段 `artist/year/material/size` | 自然博物馆需要 `taxonomy``period``specimenType``collectionNo``scientificName``ageRange` 等字段 | P0 |
@@ -309,9 +309,9 @@ interface DataResult<T> {
| --- | ---: | ---: | ---: |
| 展品/标本 | 5 | 30 到 60 | 200+ |
| 展厅/展区 | 5 | 8 到 12 | 按真实楼层和展陈分区完整覆盖 |
| 设施 | 8 | 30 到 60 | 与内 POI 全量绑定 |
| 设施 | 8 | 30 到 60 | 与内 POI 全量绑定 |
| 路线 | 3 | 5 到 8 | 支持人群、时长、无障碍、拥堵策略 |
| 内 POI | 67仅 1F | 每层 50+ | 全楼层、全关键节点 |
| 内 POI | 67仅 1F | 每层 50+ | 全楼层、全关键节点 |
当前数量只适合演示 UI无法支撑真实导览。
@@ -323,9 +323,9 @@ interface DataResult<T> {
| --- | --- | --- |
| 核心 JSON | `position: { x, y }` | 未声明坐标系、楼层、比例尺和原点 |
| 3D POI | `{ x, y, z }` | 未进入 `types/index.ts`,未绑定实体 |
| 腾讯地图 | `{ latitude, longitude }` | 硬编码在组件内,未与内 POI 建立转换关系 |
| 腾讯地图 | `{ latitude, longitude }` | 硬编码在组件内,未与内 POI 建立转换关系 |
`TencentMap.vue` 里的建筑轮廓和 `ThreeMap.vue` 的中心点均硬编码为深圳自然博物馆附近坐标,但无法验证这些点与 GLB 模型、内 POI、楼层图是否同源。
`TencentMap.vue` 里的建筑轮廓和 `ThreeMap.vue` 的中心点均硬编码为深圳自然博物馆附近坐标,但无法验证这些点与 GLB 模型、内 POI、楼层图是否同源。
### 3.4 多语言数据
@@ -419,7 +419,7 @@ flowchart LR
| 资源 | 路径 | 大小 |
| --- | --- | ---: |
| 内模型 | `static/models/f1-indoor.glb` | 1,562,816 bytes |
| 内模型 | `static/models/f1-indoor.glb` | 1,562,816 bytes |
| 楼层模型 | `static/models/f1-floor.glb` | 95,720 bytes`facilities.json` 点位坐标来源 |
| 部署副本 | `public/models/f1-indoor.glb``public/models/f1-floor.glb` | 与 `static/models` 同大小;如果两端都会打包,需要明确去重或按端分发 |
@@ -673,7 +673,7 @@ scripts/
| P1 | 设施类型粒度不足 | 真实 POI 全部压成 `facility/entrance/hall`,具体类别藏在中文 label 中 | 搜索、筛选、图标、无障碍路线无法可靠判断类别 | 增加 `category/subtype`,如 `restroom_female``elevator``stairs``ticket_office``nursing_room` |
| P1 | UI 筛选分类与真实标签没有规范映射 | `AreaSelector``卫生间/电梯/楼梯/停车场/母婴室/服务中心/寄存处/饮水处/影院`,真实标签是 `女卫001``电梯020``楼梯.001``存包处``茶水间` 等 | 用户选择分类后很难精确筛出真实点位 | 建立 label 归一化和分类映射表,区分展示名与检索名 |
| P1 | 搜索数据未覆盖真实 1F 设施 | 搜索 Mock 只有 `洗手间``咖啡厅``服务中心``纪念品商店` 等 | 搜索不到真实存在的售票机、服务台、母婴间、轮椅及儿童车租车处、贵宾卫生间等 | 搜索索引从真实 POI 和设施实体生成 |
| P1 | 存在两套 POI 坐标模型 | 真实 POI 为 `{x,y,z}``ThreeMap.vue` props/default POI 为 `{latitude,longitude}` | 组件接口同名但数据形态不同,后续接入容易错用 | 将内 3D POI 与外经纬度 POI 拆成不同类型 |
| P1 | 存在两套 POI 坐标模型 | 真实 POI 为 `{x,y,z}``ThreeMap.vue` props/default POI 为 `{latitude,longitude}` | 组件接口同名但数据形态不同,后续接入容易错用 | 将内 3D POI 与外经纬度 POI 拆成不同类型 |
| P2 | 真实 POI label 带模型导出痕迹 | `电梯001``楼梯.001``无障碍卫生间.001` | 直接展示会显得粗糙,也不利于搜索同义词 | 保留原始 label同时新增 `displayName``instanceNo``normalizedName` |
| P2 | `entrance` 类型语义混杂 | `售票处``售票机``服务台` 被标为 `entrance` | 类型名称无法表达真实服务属性 | 原始类型可保留,业务层补充 `category: ticket/service` |
@@ -685,7 +685,7 @@ scripts/
| `src/assets/data/halls.json` | 1F 展厅名称和数量与真实 POI 冲突 | 重建 halls并绑定 `poi_0/poi_51``poi_55` |
| `src/assets/data/floors.json` | 1F 只引用 3 个旧设施 | 从真实 POI 派生楼层设施索引 |
| `src/pages/index/index.vue` | `markerDataMap` 不认识 `poi_*`,点击设施被忽略 | 改为根据 `poiId/entityId` 查询 repository |
| `src/components/map/ThreeMap.vue` | 默认 POI 使用经纬度,和真实`{x,y,z}` POI 混用 | 拆分 `IndoorPOI``OutdoorMarker` |
| `src/components/map/ThreeMap.vue` | 默认 POI 使用经纬度,和真实`{x,y,z}` POI 混用 | 拆分 `IndoorPOI``OutdoorMarker` |
| `src/types/index.ts` | `Position` 只有 `{x,y}``POIMarker` 不含 `entrance` | 扩展坐标和 POI 类型 |
| `src/components/search/SearchPanel.vue` | 搜索 Mock 未来自真实 POI | 搜索索引从 `pois + entities` 生成 |
| `src/components/area/AreaSelector.vue` | 分类只停留在 UI 文案,没有连接真实 POI subtype | 建立设施分类字典和 label 归一化规则 |
@@ -736,7 +736,7 @@ interface FacilityInstance {
| 数据/资产 | 可信部分 | 仍存在的问题 |
| --- | --- | --- |
| `static/models/f1-indoor.glb` | 真实 1F 内 3D 模型 | 缺少 `modelId`、hash、版本、来源、坐标系、比例尺、与 POI 文件的绑定说明 |
| `static/models/f1-indoor.glb` | 真实 1F 内 3D 模型 | 缺少 `modelId`、hash、版本、来源、坐标系、比例尺、与 POI 文件的绑定说明 |
| `static/models/f1-floor.glb` | 真实 1F 楼层模型,且是 `facilities.json.position` 的提取来源 | `facilities.json` 未记录 `sourceModel`、提取工具、提取时间、坐标原点、单位、投影规则 |
| `static/data/f1-indoor-pois.json` | 真实 1F POI 点位,使用 `{x,y,z}` 模型空间坐标 | 未绑定业务实体label 仍是模型导出名称,需要业务归一化 |
| `src/assets/data/facilities.json` | `position` 字段有真实模型来源 | 设施名称、类型、描述仍是旧艺术馆语义;坐标字段只有 `{x,y}`,无法判断与 `f1-indoor-pois.json``{x,y,z}` 如何互转 |

View File

@@ -0,0 +1,281 @@
# 小程序语音播放接口对接说明
## 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<string, AudioPlayInfo>()
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` 后恢复播放或展示原因。
- 快速连续点击多个展品:最终只播放最后一次点击的展品。

View File

@@ -0,0 +1,352 @@
# 小程序导览音频与讲解词接口对接说明
## 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 文本缓存。