diff --git a/doc/h5-guide-api-audit-2026-08-04.md b/doc/h5-guide-api-audit-2026-08-04.md new file mode 100644 index 0000000..384eade --- /dev/null +++ b/doc/h5-guide-api-audit-2026-08-04.md @@ -0,0 +1,46 @@ +# H5 讲解接口只读审计 + +- 审计时间:2026-08-04(Asia/Shanghai) +- 目标:`https://guide.whaoyue.com:4433/app-api/gis/guide` +- 认证:无。五个正式 `GET` 接口均可匿名访问。 +- 数据安全:仅执行读取请求;未调用删除、覆盖、批量修改或其他写操作。 + +## 接口契约 + +| 接口 | 方法 | 必填项 | 可选项 | 预期响应 | +| --- | --- | --- | --- | --- | +| `/catalog/halls` | GET | 无 | `lang` | `data` 为展厅数组 | +| `/catalog/halls/{hallId}/outlines` | GET | `hallId` | `lang` | `data` 为一级单元数组 | +| `/catalog/outlines/{outlineId}/stops` | GET | `outlineId` | `lang` | `data` 为讲解点摘要数组 | +| `/catalog/halls/{hallId}/stops/page` | GET | `hallId` | `pageNo`、`pageSize`、`lang` | `data.list`、`data.total` 分页对象 | +| `/stops/{stopId}` | GET | `stopId` | `version`(`standard` / `extended`,缺省 `standard`) | 统一详情及当前版本的 `textVariants`、`audioTracks` | + +## 执行记录 + +| 接口 | 场景 / 关键参数 | 预期 | 实际(状态码 / 业务 code / 摘要 / 耗时) | 结论 | +| --- | --- | --- | --- | --- | +| 展厅列表 | 正常:`lang=zh-CN` | 8 个展厅摘要 | `200 / 0 / array:8 / 263ms` | 通过 | +| 展厅列表 | 缺少可选 `lang` | 使用默认语言并返回数组 | `200 / 0 / array:8 / 115ms` | 通过 | +| 展厅列表 | 非法语言:`lang=ja-JP` | 文档未明确错误口径 | `200 / 0 / array:8 / 108ms` | 通过;服务端宽松接受,需补充文档 | +| 一级单元 | 正常:`hallId=715792102100832258`、`lang=zh-CN` | 一级单元数组 | `200 / 0 / array:3 / 95ms` | 通过 | +| 一级单元 | 错误非数值 hall ID | 参数错误 | `200 / 400 / 参数类型错误 / 110ms` | 通过 | +| 单元讲解点 | 文档示例单元:`outlineId=7467940240901013505` | 单元讲解点数组 | `200 / 0 / array:0 / 97ms` | 通过;该一级单元当前无讲解点 | +| 单元讲解点 | 详情所属单元:`outlineId=865546627916591104` | 包含当前讲解点的数组 | `200 / 0 / array:39 / 132ms` | 通过 | +| 单元讲解点 | 错误非数值 outline ID | 参数错误 | `200 / 400 / 参数类型错误 / 113ms` | 通过 | +| 展厅讲解点分页 | 正常:`pageNo=1&pageSize=20&lang=zh-CN` | 分页摘要 | `200 / 0 / list:20,total:39 / 197ms` | 通过 | +| 展厅讲解点分页 | 缺少可选分页参数 | 默认 `pageNo=1&pageSize=20` | `200 / 0 / list:20,total:39 / 101ms` | 通过 | +| 展厅讲解点分页 | 边界:`pageNo=999&pageSize=100` | 空页 | `200 / 0 / list:0,total:39 / 171ms` | 通过 | +| 展厅讲解点分页 | 非法:`pageNo=-1&pageSize=0` | 文档未明确错误口径 | `200 / 0 / list:20,total:39 / 113ms` | 通过;服务端回退默认值 | +| 展厅讲解点分页 | 超文档上限:`pageSize=101` | 文档声明最大 100 | `200 / 0 / list:39,total:39 / 120ms` | 偏差:服务端未限制 | +| 统一详情 | 正常:`stopId=865546647764037632&version=standard` | 标准版完整详情、正文与音轨 | `200 / 0 / version:standard,textVariants:3,audioTracks:5,recommendedTrackCode:standard.zh-CN.female / 150ms` | 通过 | +| 统一详情 | 版本边界:`version=extended` | 拓展版当前版本详情 | `200 / 0 / version:extended,textVariants:0,audioTracks:0 / 107ms` | 通过;该讲解对象暂无拓展版内容 | +| 统一详情 | 缺少可选版本 | 按 `standard` 返回 | `200 / 0 / version:standard,textVariants:3,audioTracks:5 / 110ms` | 通过 | +| 统一详情 | 非法版本:`version=unsupported` | 业务错误,不静默降级 | `200 / 1020005006 / 讲解点详情版本仅支持 standard / extended / 118ms` | 通过 | +| 统一详情 | 非数值错误 ID | 参数错误 | `200 / 400 / 参数类型错误 / 93ms` | 通过 | +| 统一详情 | 数值但不存在 ID:`999999999999999999` | `GUIDE_STOP_NOT_EXISTS` 业务错误 | `200 / 1020005000 / 导览讲解点不存在 / 117ms` | 通过 | + +## 数据关联结论 + +展厅的 3 个一级单元当前均为 `stopCount=0`,而详情讲解点 `865546647764037632` 实际属于单元 `865546627916591104`,该单元可返回 39 条讲解点。这说明一级单元目录与讲解点关联仍存在后端数据不一致。H5 主流程保持为 `展厅 -> 讲解对象 -> 统一详情`,不以一级单元作为主导航。 + +统一详情即使参数错误也使用 HTTP `200`,客户端必须结合业务 `code` 判断结果。语言和性别均由当前版本的详情数组在本地切换;切换标准版与拓展版时才重新请求统一详情并传入 `version`。 diff --git a/src/components/explain/ExplainGuideStopCatalog.vue b/src/components/explain/ExplainGuideStopCatalog.vue index 639259f..fdb6499 100644 --- a/src/components/explain/ExplainGuideStopCatalog.vue +++ b/src/components/explain/ExplainGuideStopCatalog.vue @@ -62,10 +62,7 @@ export interface ExplainGuideStopCatalogItem { description?: string audioStatus?: string hasAudio?: boolean - hasTextRecord?: boolean guideLevel?: string - playTargetType?: 'ITEM' | 'STOP' - playTargetId?: string } const props = withDefaults(defineProps<{ @@ -144,7 +141,7 @@ const handleImageError = (stopId: string) => { } const availabilityLabel = (stop: ExplainGuideStopCatalogItem) => ( - stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : stop.hasTextRecord ? '图文' : '暂无内容' + stop.audioStatus === 'READY' || stop.hasAudio ? '讲解' : '图文' ) const requestMore = () => { if (props.hasMore && !props.loading && !props.loadingMore && !props.loadMoreError) emit('retryMore') } const handleScroll = (event: Event) => { const target = event.target as HTMLElement | null; if (target && target.scrollTop + target.clientHeight >= target.scrollHeight - 8) requestMore() } diff --git a/src/components/explain/ExplainHallSelect.vue b/src/components/explain/ExplainHallSelect.vue index ff7b36d..971b117 100644 --- a/src/components/explain/ExplainHallSelect.vue +++ b/src/components/explain/ExplainHallSelect.vue @@ -210,8 +210,6 @@ export interface ExplainGuideStopSelectItem { hasAudio?: boolean audioStatus?: string guideLevel?: string - playTargetType?: 'ITEM' | 'STOP' - playTargetId?: string } export interface ExplainBusinessUnitSelectItem { diff --git a/src/composables/useGlobalAudioPlayer.ts b/src/composables/useGlobalAudioPlayer.ts index a6b661d..86ca469 100644 --- a/src/composables/useGlobalAudioPlayer.ts +++ b/src/composables/useGlobalAudioPlayer.ts @@ -4,19 +4,20 @@ import type { AudioItem } from '@/components/audio/AudioPlayer.vue' import { - audioPlayInfoRepository, - audioReasonToText, type AudioLanguage } from '@/repositories/AudioPlayInfoRepository' -import type { AudioPlayTargetType } from '@/domain/museum' +import type { AudioPlayTargetType, MuseumGuideAudioOption } from '@/domain/museum' +import { resolveGuideAudioOption } from '@/domain/guideAudioOptions' export interface GlobalAudioSource { exhibitId?: string + stopId?: string targetType?: AudioPlayTargetType targetId?: string lang?: AudioLanguage | string channelCode?: string voiceGender?: 'male' | 'female' + audioOptions?: MuseumGuideAudioOption[] detailRoute?: string title?: string } @@ -192,14 +193,14 @@ const updateDetailRouteLanguage = (route: string | undefined, lang: AudioLanguag } const toSwitchedAudioItem = ( - playInfo: Awaited>, + audioOption: MuseumGuideAudioOption, lang: AudioLanguage ): AudioItem => ({ - id: `play-${playInfo.audioId || `${playInfo.targetType}-${playInfo.targetId}-${lang}`}`, - name: currentAudio.value?.name || currentSource.value?.title || playInfo.title || '讲解音频', - audioUrl: playInfo.playUrl || '', + id: `channel-${audioOption.channelCode}`, + name: currentAudio.value?.name || currentSource.value?.title || audioOption.displayName || '讲解音频', + audioUrl: audioOption.playUrl, image: currentAudio.value?.image, - duration: typeof playInfo.duration === 'number' ? playInfo.duration : currentAudio.value?.duration, + duration: audioOption.duration || currentAudio.value?.duration, language: lang, supportedLanguages: currentAudio.value?.supportedLanguages }) @@ -292,7 +293,7 @@ const switchLanguage = async (lang: AudioLanguage) => { } const source = currentSource.value - if (!source?.targetType || !source.targetId) { + if (!source?.audioOptions?.length) { error.value = '当前讲解暂不支持语言切换' showToast(error.value) return false @@ -318,20 +319,19 @@ const switchLanguage = async (lang: AudioLanguage) => { } try { - const playInfo = await audioPlayInfoRepository.getPlayInfo({ - targetType: source.targetType, - targetId: source.targetId, + const audioOption = resolveGuideAudioOption( + source.audioOptions, lang, - refresh: true - }) + source.voiceGender || 'female' + ) - if (!playInfo.playable || !playInfo.playUrl) { + if (!audioOption?.playUrl) { stopAudioElement() currentSource.value = nextSource currentAudio.value = currentAudio.value ? { ...currentAudio.value, - id: `unavailable-${source.targetType}-${source.targetId}-${lang}`, + id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`, audioUrl: '', duration: 0, language: lang @@ -343,14 +343,19 @@ const switchLanguage = async (lang: AudioLanguage) => { loading.value = false currentTime.value = 0 duration.value = 0 - error.value = audioReasonToText(playInfo.reason || 'NO_PUBLISHED_AUDIO') + error.value = '当前语言暂无语音讲解' showToast(error.value) return false } - currentSource.value = nextSource - return await play(toSwitchedAudioItem(playInfo, lang), { - source: nextSource, + const nextTrackSource: GlobalAudioSource = { + ...nextSource, + channelCode: audioOption.channelCode, + voiceGender: audioOption.gender + } + currentSource.value = nextTrackSource + return await play(toSwitchedAudioItem(audioOption, lang), { + source: nextTrackSource, retryOnError: retryOnError || undefined, displayMode: preservedMode }) @@ -361,7 +366,7 @@ const switchLanguage = async (lang: AudioLanguage) => { if (currentAudio.value) { currentAudio.value = { ...currentAudio.value, - id: `unavailable-${source.targetType}-${source.targetId}-${lang}`, + id: `unavailable-${source.stopId || source.targetId || source.exhibitId || 'detail'}-${lang}`, audioUrl: '', duration: 0, language: lang @@ -462,14 +467,16 @@ const unregisterHost = (hostId: string) => { } } -const isCurrentSource = (source: Pick) => { +const isCurrentSource = (source: Pick) => { if (!currentAudio.value || !currentSource.value) return false return Boolean( - source.targetType - && source.targetId - && currentSource.value.targetType === source.targetType - && currentSource.value.targetId === source.targetId + (source.stopId + ? currentSource.value.stopId === source.stopId || currentSource.value.targetId === source.stopId + : source.targetType + && source.targetId + && currentSource.value.targetType === source.targetType + && currentSource.value.targetId === source.targetId) && (!source.lang || !currentSource.value.lang || currentSource.value.lang === source.lang) && (!source.channelCode || currentSource.value.channelCode === source.channelCode) ) diff --git a/src/data/adapters/backendExplainDataAdapter.ts b/src/data/adapters/backendExplainDataAdapter.ts index 5c001ae..b01aaca 100644 --- a/src/data/adapters/backendExplainDataAdapter.ts +++ b/src/data/adapters/backendExplainDataAdapter.ts @@ -158,9 +158,14 @@ export interface BackendCatalogStopItem { hasAudio?: boolean | null audioStatus?: string | null supportedLanguages?: string[] | null - hasTextRecord?: boolean | null - playTargetType?: string | null - playTargetId?: string | number | null + audioOptions?: Array<{ + version?: string | null + languageCode?: string | null + languageName?: string | null + gender?: string | null + displayName?: string | null + isDefault?: boolean | null + }> | null } export interface BackendExplainAdapterResult { @@ -339,8 +344,6 @@ export const toCatalogGuideStop = ( const id = stringifyId(source.stopId) || stringifyId(source.id) if (!id) return null - const playTargetType = normalizeAudioTargetType(source.playTargetType) || 'STOP' - const playTargetId = stringifyId(source.playTargetId) || id const imageStatus = firstText(source.imageStatus) || 'MISSING' const canUseStopImage = imageStatus === 'READY' @@ -350,8 +353,6 @@ export const toCatalogGuideStop = ( hallId: hall?.id, hallName: hall?.name, floorId: stringifyId(source.floorId) || hall?.floorId, - targetType: playTargetType, - targetId: playTargetId, coverImageUrl: canUseStopImage ? normalizeSameOriginPublicUrl(firstText(source.coverImageUrl)) || undefined : undefined, @@ -359,7 +360,6 @@ export const toCatalogGuideStop = ( hasAudio: source.hasAudio === true, audioStatus: normalizeCatalogAudioStatus(source.audioStatus), supportedLanguages: normalizeSupportedLanguages(source.supportedLanguages), - hasTextRecord: source.hasTextRecord === true, poiId: stringifyId(source.poiId) || undefined, mapX: normalizeNumber(source.mapX), mapY: normalizeNumber(source.mapY), @@ -373,8 +373,27 @@ export const toCatalogGuideStop = ( linkedExhibits: (source.linkedExhibits || []) .map(toCatalogLinkedExhibitSummary) .filter(Boolean) as NonNullable, - playTargetType, - playTargetId + stopId: id, + // Catalog audio metadata is intentionally display-only: playback always + // comes from the unified stop detail and therefore has no URL here. + audioOptions: (source.audioOptions || []) + .map((option) => { + const gender = option.gender?.trim().toLowerCase() + if (gender !== 'male' && gender !== 'female') return null + const languageCode = normalizeGuideAudioLanguage(option.languageCode) + const version = option.version?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard' + return { + channelCode: `${version}.${languageCode}.${gender}`, + version, + languageCode, + languageName: firstText(option.languageName) || undefined, + gender, + displayName: firstText(option.displayName) || undefined, + playUrl: '', + isDefault: option.isDefault === true + } + }) + .filter(Boolean) as NonNullable } } @@ -404,15 +423,15 @@ export const toCatalogMuseumExhibitFromStop = ( audioAvailable: audioReady, audioStatus: stop.audioStatus, supportedLanguages: stop.supportedLanguages, - audioHasText: stop.hasTextRecord, + // List summaries do not carry authoritative text availability. It is read + // from the unified detail together with the full text variants. + audioHasText: false, imageStatus: stop.imageStatus, linkedExhibitCount: stop.linkedExhibitCount, isSharedStop: stop.isSharedStop, linkedExhibits: stop.linkedExhibits, stopInfoAvailable: true, - resolvedStopId: stop.id, - playTargetType: stop.playTargetType || stop.targetType || 'STOP', - playTargetId: stop.playTargetId || stop.targetId || stop.id + resolvedStopId: stop.id } } diff --git a/src/data/adapters/guideStopInfoAdapter.ts b/src/data/adapters/guideStopInfoAdapter.ts index 18160a7..35b16cd 100644 --- a/src/data/adapters/guideStopInfoAdapter.ts +++ b/src/data/adapters/guideStopInfoAdapter.ts @@ -9,6 +9,54 @@ import { export type GuideAudioLanguage = 'zh-CN' | 'yue-HK' | 'en-US' +export type GuideAudioVersion = 'standard' | 'extended' + +export interface BackendGuideTextVariant { + version?: string | null + languageCode?: string | null + languageName?: string | null + text?: string | null + textLength?: number | string | null + textHash?: string | null +} + +export interface BackendGuideAudioTrack { + version?: string | null + languageCode?: string | null + languageName?: string | null + gender?: string | null + displayName?: string | null + playUrl?: string | null + duration?: number | string | null + format?: string | null + isDefault?: boolean | null +} + +export interface BackendGuideStopDetail { + id?: string | number | null + version?: string | null + outlineId?: string | number | null + poiId?: string | number | null + floorId?: string | number | null + mapX?: number | string | null + mapY?: number | string | null + name?: string | null + description?: string | null + coverImageUrl?: string | null + galleryUrls?: string | string[] | null + imageStatus?: string | null + linkedExhibits?: BackendGuideStopLinkedExhibit[] | null + linkedExhibitCount?: number | string | null + isSharedStop?: boolean | null + recommendedTrackCode?: string | null + textVariants?: BackendGuideTextVariant[] | null + audioTracks?: BackendGuideAudioTrack[] | null + hasText?: boolean | null + hasAudio?: boolean | null + textVariantCount?: number | string | null + audioTrackCount?: number | string | null +} + export interface BackendGuideStopLinkedExhibit { id?: string | number | null name?: string | null @@ -34,6 +82,44 @@ export interface BackendGuideAudioOption { sortOrder?: number | string | null } +export interface GuideTextVariant { + version: GuideAudioVersion + languageCode: GuideAudioLanguage + languageName?: string + text?: string + textLength?: number + textHash?: string +} + +export interface GuideAudioTrack extends MuseumGuideAudioOption { + version: GuideAudioVersion +} + +export interface GuideStopDetail { + id: string + version: GuideAudioVersion + outlineId?: string + poiId?: string + floorId?: string + mapX?: number + mapY?: number + name: string + description?: string + coverImageUrl?: string + galleryUrls: string[] + imageStatus: 'READY' | 'MISSING' | string + linkedExhibits: GuideStopLinkedExhibit[] + linkedExhibitCount?: number + isSharedStop: boolean + recommendedTrackCode?: string + textVariants: GuideTextVariant[] + audioTracks: GuideAudioTrack[] + hasText: boolean + hasAudio: boolean + textVariantCount: number + audioTrackCount: number +} + export interface BackendGuideStopInfo { available?: boolean targetType?: string | null @@ -214,6 +300,57 @@ const normalizeAudioGender = (value: string | null | undefined): GuideAudioGende return normalized === 'male' || normalized === 'female' ? normalized : null } +const normalizeAudioVersion = (value: string | null | undefined): GuideAudioVersion => ( + value?.trim().toLowerCase() === 'extended' ? 'extended' : 'standard' +) + +const normalizeTextVariants = (items: BackendGuideTextVariant[] | null | undefined): GuideTextVariant[] => ( + (items || []) + .map((item) => { + const text = item.text?.trim() || undefined + const languageCode = normalizeGuideAudioLanguage(item.languageCode) + if (!text) return null + return { + version: normalizeAudioVersion(item.version), + languageCode, + languageName: item.languageName?.trim() || undefined, + text, + textLength: normalizeNumber(item.textLength), + textHash: item.textHash?.trim() || undefined + } + }) + .filter(Boolean) as GuideTextVariant[] +) + +const trackCodeFor = (version: GuideAudioVersion, languageCode: GuideAudioLanguage, gender: GuideAudioGender) => ( + `${version}.${languageCode}.${gender}` +) + +const normalizeAudioTracks = (items: BackendGuideAudioTrack[] | null | undefined): GuideAudioTrack[] => ( + (items || []) + .map((item) => { + const languageCode = normalizeGuideAudioLanguage(item.languageCode) + const gender = normalizeAudioGender(item.gender) + const playUrl = normalizeSameOriginPublicUrl(item.playUrl) + if (!gender || !playUrl) return null + const version = normalizeAudioVersion(item.version) + return { + channelCode: trackCodeFor(version, languageCode, gender), + displayName: item.displayName?.trim() || undefined, + version, + languageCode, + languageName: item.languageName?.trim() || undefined, + gender, + playUrl, + duration: normalizeNumber(item.duration), + format: item.format?.trim() || undefined, + isDefault: item.isDefault === true, + sortOrder: undefined + } + }) + .filter(Boolean) as GuideAudioTrack[] +) + const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefined): MuseumGuideAudioOption[] => ( (items || []) .map((item) => { @@ -240,6 +377,43 @@ const normalizeAudioOptions = (items: BackendGuideAudioOption[] | null | undefin .filter(Boolean) as MuseumGuideAudioOption[] ) +export const toGuideStopDetail = (source: BackendGuideStopDetail): GuideStopDetail => { + const id = stringifyId(source.id) + if (!id) throw new Error('讲解点详情缺少 id') + const imageStatus = source.imageStatus || 'MISSING' + const canUseStopImages = imageStatus === 'READY' + const textVariants = normalizeTextVariants(source.textVariants) + const audioTracks = normalizeAudioTracks(source.audioTracks) + const linkedExhibits = normalizeLinkedExhibits(source.linkedExhibits) + + return { + id, + version: normalizeAudioVersion(source.version), + outlineId: stringifyId(source.outlineId) || undefined, + poiId: stringifyId(source.poiId) || undefined, + floorId: stringifyId(source.floorId) || undefined, + mapX: normalizeNumber(source.mapX), + mapY: normalizeNumber(source.mapY), + name: source.name?.trim() || '讲解内容', + description: source.description?.trim() || undefined, + coverImageUrl: canUseStopImages + ? normalizeSameOriginPublicUrl(source.coverImageUrl) || undefined + : undefined, + galleryUrls: canUseStopImages ? parseGalleryUrls(source.galleryUrls) : [], + imageStatus, + linkedExhibits, + linkedExhibitCount: normalizeNumber(source.linkedExhibitCount) ?? linkedExhibits.length, + isSharedStop: source.isSharedStop === true, + recommendedTrackCode: source.recommendedTrackCode?.trim() || undefined, + textVariants, + audioTracks, + hasText: source.hasText === true || textVariants.length > 0, + hasAudio: source.hasAudio === true || audioTracks.length > 0, + textVariantCount: normalizeNumber(source.textVariantCount) ?? textVariants.length, + audioTrackCount: normalizeNumber(source.audioTrackCount) ?? audioTracks.length + } +} + const parseGalleryUrls = (value: BackendGuideStopInfo['galleryUrls'] | BackendGuideStopLinkedExhibit['galleryUrls']) => { if (!value) return [] if (Array.isArray(value)) { diff --git a/src/data/providers/backendExplainContentProvider.ts b/src/data/providers/backendExplainContentProvider.ts index 8a6fdba..49be00d 100644 --- a/src/data/providers/backendExplainContentProvider.ts +++ b/src/data/providers/backendExplainContentProvider.ts @@ -259,10 +259,7 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (inflight) return inflight const promise = (async () => { - const params = new URLSearchParams({ - includeChildren: 'true', - lang: catalogLang() - }) + const params = new URLSearchParams({ lang: catalogLang() }) const url = `${resolveAppApiBaseUrl()}/gis/guide/catalog/halls/${encodeURIComponent(normalizedHallId)}/outlines?${params.toString()}` const response = await requestJson>(url) const outlines = requireArrayData(response, '讲解单元目录加载失败') @@ -511,9 +508,20 @@ export class BackendExplainContentProvider implements ExplainContentProvider { if (inflight) return inflight const promise = (async () => { - const outlines = await this.requestCatalogOutlinesByHall(normalizedHallId) + const [halls, outlines] = await Promise.all([ + this.requestCatalogHalls(), + this.requestCatalogOutlinesByHall(normalizedHallId) + ]) // The outline response carries authoritative counts. Stops are loaded only after unit selection. const units = toCatalogBusinessUnits(normalizedHallId, outlines, []) + const hall = halls.find((item) => item.id === normalizedHallId) + if ((hall?.stopCount || 0) > 0 && units.length > 0 && units.every((unit) => unit.guideStopCount === 0)) { + console.warn('一级单元与讲解点关联异常,一级单元仅作为兼容入口:', { + hallId: normalizedHallId, + hallStopCount: hall?.stopCount, + unitStopCounts: units.map((unit) => ({ id: unit.id, stopCount: unit.guideStopCount })) + }) + } this.setCached(this.businessUnitCache, key, units) return units })().catch((error) => { diff --git a/src/domain/museum.ts b/src/domain/museum.ts index 1aa7744..7e23fa0 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -160,6 +160,15 @@ export interface MuseumGuideAudioOption { sortOrder?: number } +export interface MuseumGuideTextVariant { + version: 'standard' | 'extended' + languageCode: string + languageName?: string + text?: string + textLength?: number + textHash?: string +} + export interface MuseumExhibit { id: string name: string @@ -196,6 +205,11 @@ export interface MuseumExhibit { audioStatus?: string supportedLanguages?: string[] audioOptions?: MuseumGuideAudioOption[] + textVariants?: MuseumGuideTextVariant[] + recommendedTrackCode?: string + guideVersion?: 'standard' | 'extended' + textVariantCount?: number + audioTrackCount?: number audioVariants?: Record playTargetType?: AudioPlayTargetType playTargetId?: string + stopId?: string + audioOptions?: MuseumGuideAudioOption[] } export interface ExplainGuideStopPage { diff --git a/src/pages/exhibit/detail.vue b/src/pages/exhibit/detail.vue index fa6ebfb..19a4370 100644 --- a/src/pages/exhibit/detail.vue +++ b/src/pages/exhibit/detail.vue @@ -172,10 +172,7 @@ import { toExplainDetailPageViewModel, type ExplainDetailPageViewModel } from '@/view-models/explainViewModels' -import type { - AudioPlayTargetType, - GuideAudioGender -} from '@/domain/museum' +import type { GuideAudioGender } from '@/domain/museum' import type { AudioLanguage } from '@/repositories/AudioPlayInfoRepository' @@ -222,12 +219,11 @@ const activeTopTab = ref('explain') const retryingAudio = ref(false) const languageSwitchLoading = ref(false) const selectedAudioLanguage = ref('zh-CN') -const selectedAudioGender = ref('male') +const selectedAudioGender = ref('female') let languageSwitchSequence = 0 const detailEntryRequest = ref<{ exhibitId: string - targetType?: AudioPlayTargetType - targetId?: string + stopId: string hallId?: string hallName?: string floorId?: string @@ -255,9 +251,17 @@ const isAudioLanguage = (value: unknown): value is AudioLanguage => ( value === 'zh-CN' || value === 'yue-HK' || value === 'en-US' ) +const detailAudioVersion = computed<'standard' | 'extended'>(() => ( + exhibit.value.audio.version === 'extended' ? 'extended' : 'standard' +)) +const activeDetailAudioOptions = computed(() => ( + (exhibit.value.audio.audioOptions || []).filter((option) => ( + !option.version || option.version === detailAudioVersion.value + )) +)) const supportedDetailLanguages = computed(() => ( resolveGuideAudioLanguages( - exhibit.value.audio.audioOptions, + activeDetailAudioOptions.value, exhibit.value.audio.supportedLanguages ).filter(isAudioLanguage) )) @@ -316,7 +320,7 @@ const heroSubtitle = computed(() => [ detailMeta.value ].filter(Boolean).join(' · ')) const selectedDetailAudioOption = computed(() => resolveGuideAudioOption( - exhibit.value.audio.audioOptions, + activeDetailAudioOptions.value, selectedAudioLanguage.value, selectedAudioGender.value )) @@ -325,7 +329,7 @@ const currentDetailAudioGender = computed(() => ( || resolveGuideAudioGender(selectedAudioLanguage.value, selectedAudioGender.value) )) const canSwitchDetailAudioVoice = computed(() => canToggleGuideAudioGender( - exhibit.value.audio.audioOptions, + activeDetailAudioOptions.value, selectedAudioLanguage.value )) const detailAudioVoiceLabel = computed(() => { @@ -337,21 +341,18 @@ const selectedDetailAudioAvailable = computed(() => ( || exhibit.value.audio.status === 'playable' )) const currentAudioTarget = computed(() => ({ - targetType: exhibit.value.audio.playTargetType || 'ITEM', - targetId: exhibit.value.audio.playTargetId || exhibit.value.id, + stopId: exhibit.value.audio.stopId || exhibit.value.id, lang: selectedAudioLanguage.value, channelCode: selectedDetailAudioOption.value?.channelCode })) const isCurrentDetailAudio = computed(() => globalAudioPlayer.isCurrentSource(currentAudioTarget.value)) const isCurrentDetailAudioTarget = computed(() => { const source = globalAudioPlayer.currentSource.value - const targetType = exhibit.value.audio.playTargetType || 'ITEM' - const targetId = exhibit.value.audio.playTargetId || exhibit.value.id + const stopId = exhibit.value.audio.stopId || exhibit.value.id return Boolean( globalAudioPlayer.currentAudio.value - && source?.targetType === targetType - && source.targetId === targetId + && (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId)) ) }) let detailAudioClosedOnExit = false @@ -418,8 +419,8 @@ const isCurrentDetailAudioError = computed(() => { const source = globalAudioPlayer.currentSource.value return Boolean( globalAudioPlayer.error.value - && source?.targetType === currentAudioTarget.value.targetType - && source.targetId === currentAudioTarget.value.targetId + && (source?.stopId === currentAudioTarget.value.stopId + || (source?.targetType === 'STOP' && source.targetId === currentAudioTarget.value.stopId)) ) }) const isDetailAudioLoading = computed(() => ( @@ -451,25 +452,8 @@ const applyDetailText = (lang: AudioLanguage, text?: string) => { } } -const buildTextSourceFromViewModel = (viewModel: ExplainDetailPageViewModel) => ({ - id: viewModel.id, - name: viewModel.title, - hallId: viewModel.hallId, - hallName: viewModel.hallName, - floorId: viewModel.floorId, - floorLabel: viewModel.floorLabel, - image: viewModel.coverImages[0], - description: viewModel.summary, - guideText: viewModel.summary, - audioLanguage: viewModel.audio.language, - audioHasText: viewModel.audio.hasText, - audioOptions: viewModel.audio.audioOptions, - playTargetType: viewModel.audio.playTargetType, - playTargetId: viewModel.audio.playTargetId -}) - const loadFullDetailTextForLanguage = async ( - request: NonNullable, + _request: NonNullable, lang: AudioLanguage, preferredViewModel?: ExplainDetailPageViewModel ) => { @@ -479,21 +463,15 @@ const loadFullDetailTextForLanguage = async ( } try { - const viewModel = preferredViewModel || toExplainDetailPageViewModel(await explainUseCase.enterExplainDetail({ - ...request, - lang - })) - - if (viewModel.audio.hasText) { - const selection = await explainUseCase.loadExplainDetailText(buildTextSourceFromViewModel(viewModel)) - if (selection.available) { - const nextViewModel = toExplainDetailPageViewModel(selection.exhibit) - applyDetailText(lang, nextViewModel.body || nextViewModel.summary) - return - } - } - - applyDetailText(lang, viewModel.body || viewModel.summary) + const viewModel = preferredViewModel || exhibit.value + const variants = viewModel.audio.textVariants || [] + const version = viewModel.audio.version || 'standard' + const detailText = variants.find((variant) => ( + variant.version === version && variant.languageCode === lang + ))?.text || (lang === 'yue-HK' + ? variants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN')?.text + : undefined) + applyDetailText(lang, detailText || viewModel.summary) } catch (error) { console.warn('讲解正文加载失败:', lang, error) applyDetailText(lang) @@ -517,8 +495,12 @@ const loadExplainDetail = async ( heroImageIndex.value = 0 exhibit.value = toExplainDetailPageViewModel(exhibitData) - const resolvedLanguage = resolveSupportedDetailLanguage(lang) + const recommended = exhibit.value.audio.audioOptions?.find((option) => ( + option.channelCode === exhibit.value.audio.recommendedTrackCode && option.version === detailAudioVersion.value + )) + const resolvedLanguage = resolveSupportedDetailLanguage(recommended?.languageCode as AudioLanguage || lang) selectedAudioLanguage.value = resolvedLanguage + selectedAudioGender.value = recommended?.gender || resolveGuideAudioGender(resolvedLanguage, 'female') if (resolvedLanguage !== lang) { replaceDetailRouteLanguage(resolvedLanguage) @@ -532,28 +514,22 @@ const loadExplainDetail = async ( onLoad(async (options: any = {}) => { detailAudioClosedOnExit = false - selectedAudioGender.value = 'male' + selectedAudioGender.value = 'female' const tab = Array.isArray(options.tab) ? options.tab[0] : options.tab if (isGuideTopTab(tab)) { activeTopTab.value = tab } - const exhibitId = Array.isArray(options.id) ? options.id[0] : options.id - if (!exhibitId) { + const rawStopId = Array.isArray(options.stopId) ? options.stopId[0] : options.stopId + const rawExhibitId = Array.isArray(options.id) ? options.id[0] : options.id + // New detail links are keyed by stopId. id remains optional so bookmarked + // historical routes and surrounding page metadata can still be preserved. + const stopId = rawStopId ? String(rawStopId) : String(rawExhibitId || '') + const exhibitId = rawExhibitId ? String(rawExhibitId) : stopId + if (!stopId) { detailState.value = 'missing' return } - - const rawTargetType = Array.isArray(options.targetType) ? options.targetType[0] : options.targetType - const targetType: AudioPlayTargetType | undefined = rawTargetType === 'ITEM' - ? 'ITEM' - : rawTargetType === 'STOP' || rawTargetType === 'GUIDE_STOP' - ? 'STOP' - : undefined - const rawTargetId = Array.isArray(options.targetId) ? options.targetId[0] : options.targetId - const targetId = rawTargetType === 'GUIDE_STOP' - ? String(exhibitId) - : rawTargetId ? String(rawTargetId) : undefined const rawLang = Array.isArray(options.lang) ? options.lang[0] : options.lang const lang = typeof rawLang === 'string' ? normalizeGuideAudioLanguage(rawLang) @@ -562,8 +538,7 @@ onLoad(async (options: any = {}) => { try { detailEntryRequest.value = { exhibitId, - targetType, - targetId, + stopId, hallId: Array.isArray(options.hallId) ? options.hallId[0] : options.hallId, hallName: Array.isArray(options.hallName) ? options.hallName[0] : options.hallName, floorId: Array.isArray(options.floorId) ? options.floorId[0] : options.floorId, @@ -684,17 +659,27 @@ const handleLanguageChange = async (lang: AudioLanguage, options: { syncAudio?: applyDetailText(lang) return } - - const exhibitData = await explainUseCase.enterExplainDetail({ - ...request, - lang - }) if (requestSequence !== languageSwitchSequence) return - - heroImageIndex.value = 0 - const nextViewModel = toExplainDetailPageViewModel(exhibitData) - exhibit.value = nextViewModel - void loadFullDetailTextForLanguage(request, lang, nextViewModel) + const audioOption = resolveGuideAudioOption( + activeDetailAudioOptions.value, + lang, + selectedAudioGender.value + ) + exhibit.value = { + ...exhibit.value, + audio: { + ...exhibit.value.audio, + language: lang, + url: audioOption?.playUrl, + duration: audioOption?.duration, + status: audioOption?.playUrl ? 'playable' : 'unavailable', + unavailableReason: audioOption?.playUrl ? undefined : '当前语言暂无语音讲解', + hasText: Boolean(exhibit.value.audio.textVariants?.some((variant) => ( + variant.version === detailAudioVersion.value && (variant.languageCode === lang || (lang === 'yue-HK' && variant.languageCode === 'zh-CN')) + ))) + } + } + void loadFullDetailTextForLanguage(request, lang, exhibit.value) if (shouldContinueAudio) { // The player must become actionable before resuming with the new language track. languageSwitchLoading.value = false @@ -742,13 +727,11 @@ watch( () => globalAudioPlayer.currentSource.value, (source) => { const lang = source?.lang - const targetType = exhibit.value.audio.playTargetType || 'ITEM' - const targetId = exhibit.value.audio.playTargetId || exhibit.value.id + const stopId = exhibit.value.audio.stopId || exhibit.value.id if ( isAudioLanguage(lang) - && source?.targetType === targetType - && source.targetId === targetId + && (source?.stopId === stopId || (source?.targetType === 'STOP' && source.targetId === stopId)) ) { void handleLanguageChange(lang, { syncAudio: false }) } @@ -761,12 +744,7 @@ function buildDetailRoute(lang: AudioLanguage = selectedAudioLanguage.value) { id: request?.exhibitId || exhibit.value.id, tab: activeTopTab.value }) - if (currentAudioTarget.value.targetType) { - params.set('targetType', currentAudioTarget.value.targetType) - } - if (currentAudioTarget.value.targetId) { - params.set('targetId', currentAudioTarget.value.targetId) - } + params.set('stopId', request?.stopId || exhibit.value.audio.stopId || exhibit.value.id) if (lang) { params.set('lang', lang) } @@ -782,7 +760,7 @@ const toAudioItem = ( return { id: media.id, - name: selection.playInfo?.title || selection.exhibit.name, + name: selection.exhibit.name, audioUrl, image: heroImage.value, duration: media.duration, @@ -806,11 +784,10 @@ const refreshCurrentAudioOnce = async (message: string) => { audioDuration: exhibit.value.audio.duration, audioStatus: 'READY', audioLanguage: selectedAudioLanguage.value, - audioOptions: exhibit.value.audio.audioOptions, + audioOptions: activeDetailAudioOptions.value, + guideVersion: detailAudioVersion.value, playTargetType: exhibit.value.audio.playTargetType, playTargetId: exhibit.value.audio.playTargetId - }, { - refreshPlayInfo: true }) retryingAudio.value = false @@ -833,11 +810,11 @@ const playDetailAudio = async (audio: AudioItem) => { await globalAudioPlayer.play(audio, { source: { exhibitId: exhibit.value.id, - targetType: currentAudioTarget.value.targetType, - targetId: currentAudioTarget.value.targetId, + stopId: currentAudioTarget.value.stopId, lang: selectedAudioLanguage.value, channelCode: selectedDetailAudioOption.value?.channelCode, voiceGender: currentDetailAudioGender.value, + audioOptions: activeDetailAudioOptions.value, title: audio.name, detailRoute: buildDetailRoute() }, @@ -883,7 +860,8 @@ const handlePlayAudio = async (options: { forceRefresh?: boolean } = {}) => { audioStatus: exhibit.value.audio.status === 'playable' ? 'READY' : 'MISSING', audioLanguage: selectedAudioLanguage.value, audioUnavailableReason: exhibit.value.audio.unavailableReason, - audioOptions: exhibit.value.audio.audioOptions, + audioOptions: activeDetailAudioOptions.value, + guideVersion: detailAudioVersion.value, playTargetType: exhibit.value.audio.playTargetType, playTargetId: exhibit.value.audio.playTargetId }, { @@ -945,12 +923,31 @@ const fallbackToExplainObjectList = () => { }) } +type ExplainDetailPageStackEntry = { + route?: string +} + const returnToExplainObjectList = () => { closeDetailAudioOnExit() - uni.navigateBack({ - delta: 1, - fail: fallbackToExplainObjectList - }) + + // H5 history can omit intermediate uni-app pages after a refresh or deep link. + if (typeof window !== 'undefined' && window.location.hash.startsWith('#/')) { + fallbackToExplainObjectList() + return + } + + const pages = getCurrentPages() + const previousPage = pages[pages.length - 2] as ExplainDetailPageStackEntry | undefined + + if (previousPage?.route === 'pages/explain/guide-stop-list') { + uni.navigateBack({ + delta: 1, + fail: fallbackToExplainObjectList + }) + return + } + + fallbackToExplainObjectList() } const handleBack = returnToExplainObjectList diff --git a/src/pages/explain/guide-stop-list.vue b/src/pages/explain/guide-stop-list.vue index 089602a..49ea6d0 100644 --- a/src/pages/explain/guide-stop-list.vue +++ b/src/pages/explain/guide-stop-list.vue @@ -25,7 +25,6 @@ import GuidePageFrame from '@/components/navigation/GuidePageFrame.vue' import ExplainGuideStopCatalog, { type ExplainGuideStopCatalogItem } from '@/components/explain/ExplainGuideStopCatalog.vue' import { explainUseCase } from '@/usecases/explainUseCase' import type { ExplainGuideStop } from '@/domain/museum' -import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget' import { navigateToGuideTopTab, type GuideTopTab } from '@/utils/guideTopTabs' const PAGE_SIZE = 20 @@ -53,11 +52,8 @@ const toGuideStopItems = (stops: ExplainGuideStop[]): ExplainGuideStopCatalogIte coverImageUrl: stop.imageStatus === 'MISSING' ? undefined : stop.coverImageUrl, description: stop.description, hasAudio: stop.hasAudio, - hasTextRecord: stop.hasTextRecord, audioStatus: stop.audioStatus, - guideLevel: stop.guideLevel, - playTargetType: stop.playTargetType, - playTargetId: stop.playTargetId + guideLevel: stop.guideLevel })) const syncPageTitle = (title: string) => { @@ -130,10 +126,8 @@ onUnmounted(() => { }) const handleExplainGuideStopClick = (stop: ExplainGuideStopCatalogItem) => { - const target = stop.playTargetType && stop.playTargetId - ? { targetType: stop.playTargetType, targetId: stop.playTargetId } - : normalizeExplainDetailTargetFromGuideStop({ id: stop.id }) - const params = new URLSearchParams({ id: stop.id, tab: 'explain', targetType: target.targetType, targetId: target.targetId }) + const stopId = stop.id + const params = new URLSearchParams({ id: stopId, stopId, tab: 'explain' }) // 分页对象可能未回填展厅字段,详情返回必须保留当前列表的展厅上下文。 const hallId = stop.hallId || selectedExplainHallId.value const hallName = stop.hallName || selectedExplainHallName.value diff --git a/src/pages/hall/detail.vue b/src/pages/hall/detail.vue index d0e6edc..1240ca1 100644 --- a/src/pages/hall/detail.vue +++ b/src/pages/hall/detail.vue @@ -209,11 +209,7 @@ const handleExhibitClick = (exhibit: ExplainExhibitViewModel) => { id: exhibit.id, tab: activeTopTab.value }) - - if (exhibit.playTargetType && exhibit.playTargetId) { - params.set('targetType', exhibit.playTargetType) - params.set('targetId', exhibit.playTargetId) - } + params.set('stopId', exhibit.playTargetId || exhibit.id) uni.navigateTo({ url: `/pages/exhibit/detail?${params.toString()}` diff --git a/src/repositories/AudioPlayInfoRepository.ts b/src/repositories/AudioPlayInfoRepository.ts index 207fc00..9d4bc6d 100644 --- a/src/repositories/AudioPlayInfoRepository.ts +++ b/src/repositories/AudioPlayInfoRepository.ts @@ -1,96 +1,74 @@ +import { dataSourceConfig } from '@/config/dataSource' import { - dataSourceConfig -} from '@/config/dataSource' -import { - type AudioPlayTargetType -} from '@/domain/museum' -import { - toGuideAudioPlayInfo, - toGuideAudioTextInfo, - toGuideStopInfo, - type BackendAudioPlayInfo, - type BackendAudioTextInfo, - type BackendGuideStopInfo, - type GuideAudioPlayInfo, - type GuideAudioTextInfo, + toGuideStopDetail, + type BackendGuideStopDetail, type GuideAudioLanguage, - type GuideStopInfo + type GuideAudioVersion, + type GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter' export type AudioLanguage = GuideAudioLanguage -export interface AudioPlayInfoRequest { - targetType: AudioPlayTargetType - targetId: string - lang?: AudioLanguage +export interface GetStopDetailOptions { + version?: GuideAudioVersion refresh?: boolean } -export interface GuideStopInfoRequest { - targetType: AudioPlayTargetType - targetId: string - lang?: AudioLanguage - /** - * H5 detail pages only need the active language's text availability. Keep the - * legacy all-language payload available for older consumers that opt out. - */ - lightweight?: boolean -} - -interface GuideStopInfoResponse { - code: number - msg?: string - data?: BackendGuideStopInfo -} - -export interface AudioPlayInfo extends GuideAudioPlayInfo {} - -interface AudioPlayInfoResponse { - code: number - msg?: string - data?: BackendAudioPlayInfo -} - -export interface AudioTextInfoRequest { - targetType: AudioPlayTargetType - targetId: string - lang?: AudioLanguage -} - -export interface AudioTextInfo extends GuideAudioTextInfo {} - -interface AudioTextInfoResponse { - code: number - msg?: string - data?: BackendAudioTextInfo -} - export interface AudioPlayInfoRepository { - getStopInfo(request: GuideStopInfoRequest): Promise - getPlayInfo(request: AudioPlayInfoRequest): Promise - getTextInfo(request: AudioTextInfoRequest): Promise - clearCache(lang?: AudioLanguage): void + getStopDetail(stopId: string, options?: GetStopDetailOptions): Promise + clearCache(stopId?: string): void } +interface GuideStopDetailResponse { + code: number + msg?: string + data?: BackendGuideStopDetail +} + +interface CacheEntry { + value: T + expiresAt: number +} + +const STOP_DETAIL_TTL_MS = 10 * 60_000 +const CACHE_MAX_ENTRIES = 80 + +const normalizeDetailVersion = (version?: GuideAudioVersion): GuideAudioVersion => ( + version === 'extended' ? 'extended' : 'standard' +) + +const detailCacheKey = (stopId: string, version: GuideAudioVersion) => `${stopId}:version:${version}` + const reasonMessageMap: Record = { NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解', NO_GUIDE_STOP: '该展品暂未配置语音讲解', NO_GUIDE_CONTENT: '该目标暂无讲解内容', NO_TEXT: '当前语言暂无讲解词', UNSUPPORTED_LANGUAGE: '不支持该语言', - UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放', - TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解', + GUIDE_STOP_NOT_EXISTS: '该讲解点不存在或暂不可展示', SERVICE_ERROR: '语音服务暂不可用,请稍后重试' } -const parseJsonPayload = (payload: unknown): T => { - if (typeof payload === 'string') { - return JSON.parse(payload) as T - } +export const audioReasonToText = (reason?: string | null) => ( + reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频' +) +const parseJsonPayload = (payload: unknown): T => { + if (typeof payload === 'string') return JSON.parse(payload) as T return payload as T } +const normalizeBaseUrl = (baseUrl: string) => { + const trimmed = baseUrl.trim().replace(/\/+$/, '') + if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) return trimmed + return `/${trimmed}` +} + +const resolveAppApiBaseUrl = () => { + const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl) + return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api` +} + const requestJson = (url: string): Promise => new Promise((resolve, reject) => { uni.request({ url, @@ -99,10 +77,9 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject success: (response) => { const statusCode = Number(response.statusCode || 0) if (statusCode < 200 || statusCode >= 300) { - reject(new Error(`语音播放解析接口请求失败: ${statusCode}`)) + reject(new Error(`讲解点详情请求失败: ${statusCode}`)) return } - try { resolve(parseJsonPayload(response.data)) } catch (error) { @@ -113,245 +90,81 @@ const requestJson = (url: string): Promise => new Promise((resolve, reject }) }) -const unavailableAudioApiEndpoints = new Set<'stopInfo' | 'playInfo' | 'textInfo'>() - -const normalizeBaseUrl = (baseUrl: string) => { - const trimmed = baseUrl.trim().replace(/\/+$/, '') - if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) { - return trimmed - } - - return `/${trimmed}` -} - -const resolveAudioApiBaseUrl = () => { - // Catalog APIs use apiBaseUrl in BackendExplainContentProvider; stop/detail/play/text APIs use audioApiBaseUrl. - // They are both /app-api today, but audioApiBaseUrl can be split to a dedicated proxy or host later. - const baseUrl = normalizeBaseUrl(dataSourceConfig.audioApiBaseUrl) - return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api` -} - -type RequiredAudioPlayInfoRequest = Required> -type RequiredGuideStopInfoRequest = Required -type RequiredAudioTextInfoRequest = Required - -const audioKey = ({ targetType, targetId, lang }: RequiredAudioPlayInfoRequest) => ( - `${targetType}:${targetId}:${lang}` -) - -const stopInfoKey = ({ targetType, targetId, lang, lightweight }: RequiredGuideStopInfoRequest) => ( - `${targetType}:${targetId}:${lang}:${lightweight ? 'lightweight' : 'full'}` -) - -const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoRequest) => ( - `${targetType}:${targetId}:${lang}` -) - -const STOP_INFO_TTL_MS = 10 * 60_000 -const PLAY_INFO_TTL_MS = 10 * 60_000 -const TEXT_INFO_TTL_MS = 10 * 60_000 -const CACHE_MAX_ENTRIES = 80 -const EXPIRES_AT_SAFETY_MS = 30_000 - -interface CacheEntry { - value: T - expiresAt: number -} - -const isMissingRouteResponse = (response: { code: number; msg?: string }) => ( - response.code === 404 && /请求地址不存在/.test(response.msg || '') -) - -const markGuideAudioApiUnavailable = ( - endpoint: 'stopInfo' | 'playInfo' | 'textInfo', - response: { code: number; msg?: string } -) => { - if (isMissingRouteResponse(response)) { - unavailableAudioApiEndpoints.add(endpoint) - } -} - -export const audioReasonToText = (reason?: string | null) => ( - reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频' -) - export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { - private readonly stopInfoCache = new Map>() - private readonly cache = new Map>() - private readonly textCache = new Map>() - private readonly stopInfoInflight = new Map>() - private readonly playInfoInflight = new Map>() - private readonly textInfoInflight = new Map>() + private readonly detailCache = new Map>() + private readonly detailInflight = new Map>() private epoch = 0 - private getCached(cache: Map>, key: string): T | null { - const entry = cache.get(key) + private getCached(stopId: string, version: GuideAudioVersion) { + const key = detailCacheKey(stopId, version) + const entry = this.detailCache.get(key) if (!entry) return null if (entry.expiresAt <= Date.now()) { - cache.delete(key) + this.detailCache.delete(key) return null } - cache.delete(key) - cache.set(key, entry) + this.detailCache.delete(key) + this.detailCache.set(key, entry) return entry.value } - private setCached(cache: Map>, key: string, value: T, ttlMs: number) { - cache.delete(key) - cache.set(key, { value, expiresAt: Date.now() + ttlMs }) - while (cache.size > CACHE_MAX_ENTRIES) { - const oldestKey = cache.keys().next().value + private setCached(stopId: string, version: GuideAudioVersion, value: GuideStopDetail) { + const key = detailCacheKey(stopId, version) + this.detailCache.delete(key) + this.detailCache.set(key, { value, expiresAt: Date.now() + STOP_DETAIL_TTL_MS }) + while (this.detailCache.size > CACHE_MAX_ENTRIES) { + const oldestKey = this.detailCache.keys().next().value if (!oldestKey) break - cache.delete(oldestKey) + this.detailCache.delete(oldestKey) } } - private async coalesce( - inflight: Map>, - key: string, - loader: () => Promise - ): Promise { - const existing = inflight.get(key) + async getStopDetail(stopId: string, options: GetStopDetailOptions = {}): Promise { + const normalizedStopId = String(stopId || '').trim() + if (!normalizedStopId) throw new Error('讲解点 ID 不能为空') + const version = normalizeDetailVersion(options.version) + const key = detailCacheKey(normalizedStopId, version) + + const cached = this.getCached(normalizedStopId, version) + if (!options.refresh && cached) return cached + + const existing = this.detailInflight.get(key) if (existing) return existing - const promise = loader() - inflight.set(key, promise) + + const epoch = this.epoch + const params = new URLSearchParams({ version }) + const request = requestJson( + `${resolveAppApiBaseUrl()}/gis/guide/stops/${encodeURIComponent(normalizedStopId)}?${params.toString()}` + ).then((response) => { + if (response.code !== 0 || !response.data) { + const error = new Error(response.msg || '讲解点详情加载失败') + ;(error as Error & { code?: number }).code = response.code + throw error + } + const detail = toGuideStopDetail(response.data) + if (epoch === this.epoch) this.setCached(normalizedStopId, version, detail) + return detail + }) + + this.detailInflight.set(key, request) try { - return await promise + return await request } finally { - if (inflight.get(key) === promise) inflight.delete(key) + if (this.detailInflight.get(key) === request) { + this.detailInflight.delete(key) + } } } - async getStopInfo(request: GuideStopInfoRequest): Promise { - if (unavailableAudioApiEndpoints.has('stopInfo')) { - throw new Error('讲解展示信息接口暂不可用') - } - - const normalizedRequest: RequiredGuideStopInfoRequest = { - targetType: request.targetType, - targetId: request.targetId, - lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage), - lightweight: request.lightweight !== false - } - const key = stopInfoKey(normalizedRequest) - const cached = this.getCached(this.stopInfoCache, key) - if (cached) return cached - return this.coalesce(this.stopInfoInflight, key, async () => { - const epoch = this.epoch - const params = new URLSearchParams({ - targetType: normalizedRequest.targetType, - targetId: normalizedRequest.targetId, - lang: normalizedRequest.lang, - lightweight: String(normalizedRequest.lightweight) - }) - const response = await requestJson(`${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}`) - if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable('stopInfo', response) - throw new Error(response.msg || '讲解点展示信息加载失败') - } - const stopInfo = toGuideStopInfo(response.data, normalizedRequest) - if (epoch === this.epoch) this.setCached(this.stopInfoCache, key, stopInfo, STOP_INFO_TTL_MS) - return stopInfo - }) - } - - async getPlayInfo(request: AudioPlayInfoRequest): Promise { - const normalizedRequest: RequiredAudioPlayInfoRequest = { - targetType: request.targetType, - targetId: request.targetId, - lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) - } - const key = audioKey(normalizedRequest) - if (unavailableAudioApiEndpoints.has('playInfo')) { - return { - playable: false, - targetType: normalizedRequest.targetType, - targetId: normalizedRequest.targetId, - lang: normalizedRequest.lang, - hasText: false, - fallback: false, - reason: 'SERVICE_ERROR' - } - } - - const cached = this.getCached(this.cache, key) - if (!request.refresh && cached) return cached - // Force refresh bypasses completed cache, while the same concurrent refresh still shares this Promise. - return this.coalesce(this.playInfoInflight, key, async () => { - const epoch = this.epoch - const params = new URLSearchParams(normalizedRequest) - const response = await requestJson(`${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}`) - if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable('playInfo', response) - throw new Error(response.msg || '语音播放解析失败') - } - const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest) - const serverExpiry = playInfo.expiresAt ? new Date(playInfo.expiresAt).getTime() - EXPIRES_AT_SAFETY_MS : Infinity - const ttlMs = Math.max(0, Math.min(PLAY_INFO_TTL_MS, serverExpiry - Date.now())) - if (epoch === this.epoch && playInfo.playable && ttlMs > 0) this.setCached(this.cache, key, playInfo, ttlMs) - return playInfo - }) - } - - async getTextInfo(request: AudioTextInfoRequest): Promise { - const normalizedRequest: RequiredAudioTextInfoRequest = { - targetType: request.targetType, - targetId: request.targetId, - lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) - } - const key = audioTextKey(normalizedRequest) - if (unavailableAudioApiEndpoints.has('textInfo')) { - return { - available: false, - targetType: normalizedRequest.targetType, - targetId: normalizedRequest.targetId, - lang: normalizedRequest.lang, - reason: 'SERVICE_ERROR' - } - } - - const cached = this.getCached(this.textCache, key) - if (cached) return cached - return this.coalesce(this.textInfoInflight, key, async () => { - const epoch = this.epoch - const params = new URLSearchParams(normalizedRequest) - const response = await requestJson(`${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}`) - if (response.code !== 0 || !response.data) { - markGuideAudioApiUnavailable('textInfo', response) - throw new Error(response.msg || '讲解词正文解析失败') - } - const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest) - if (epoch === this.epoch && textInfo.available) this.setCached(this.textCache, key, textInfo, TEXT_INFO_TTL_MS) - return textInfo - }) - } - - clearCache(lang?: AudioLanguage) { + clearCache(stopId?: string) { this.epoch += 1 - if (!lang) { - this.stopInfoCache.clear() - this.cache.clear() - this.textCache.clear() - unavailableAudioApiEndpoints.clear() + if (stopId?.trim()) { + const normalizedStopId = stopId.trim() + this.detailCache.delete(detailCacheKey(normalizedStopId, 'standard')) + this.detailCache.delete(detailCacheKey(normalizedStopId, 'extended')) return } - - Array.from(this.stopInfoCache.keys()).forEach((key) => { - if (key.endsWith(`:${lang}`)) { - this.stopInfoCache.delete(key) - } - }) - Array.from(this.cache.keys()).forEach((key) => { - if (key.endsWith(`:${lang}`)) { - this.cache.delete(key) - } - }) - Array.from(this.textCache.keys()).forEach((key) => { - if (key.endsWith(`:${lang}`)) { - this.textCache.delete(key) - } - }) - unavailableAudioApiEndpoints.clear() + this.detailCache.clear() } } diff --git a/src/repositories/PublishedExhibitAudioRepository.ts b/src/repositories/PublishedExhibitAudioRepository.ts index 05866c5..fb3fe99 100644 --- a/src/repositories/PublishedExhibitAudioRepository.ts +++ b/src/repositories/PublishedExhibitAudioRepository.ts @@ -1,10 +1,17 @@ import type { - AudioLanguage, - AudioPlayInfoRequest + AudioLanguage } from '@/repositories/AudioPlayInfoRepository' +import type { AudioPlayTargetType } from '@/domain/museum' + +/** Historical lookup-only shape. It is not a runtime API request contract. */ +export interface PublishedExhibitAudioTarget { + targetType: AudioPlayTargetType + targetId: string + lang?: AudioLanguage +} export interface PublishedExhibitAudioResolution { - targets: AudioPlayInfoRequest[] + targets: PublishedExhibitAudioTarget[] } export interface PublishedExhibitAudioRepository { diff --git a/src/usecases/explainUseCase.ts b/src/usecases/explainUseCase.ts index b7e8bd1..5d2e447 100644 --- a/src/usecases/explainUseCase.ts +++ b/src/usecases/explainUseCase.ts @@ -18,20 +18,15 @@ import { audioPlayInfoRepository, audioReasonToText, type AudioLanguage, - type AudioPlayInfo, - type AudioPlayInfoRepository, - type GuideStopInfoRequest, - type AudioTextInfo + type AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository' import type { - GuideStopInfo + GuideAudioVersion, + GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter' import { dataSourceConfig } from '@/config/dataSource' -import { - normalizeExplainDetailTargetFromGuideStop -} from '@/domain/explainDetailTarget' import { resolveGuideAudioLanguages, resolveGuideAudioOption @@ -43,14 +38,15 @@ export interface ExplainAudioSelection { media: MediaAsset | null playable: boolean unavailableMessage?: string - playInfo?: AudioPlayInfo } export interface ExplainDetailEntryRequest { exhibitId: string + stopId?: string targetType?: AudioPlayTargetType targetId?: string lang?: AudioLanguage + version?: GuideAudioVersion // Navigation context is non-authoritative and keeps deep links independent of catalog loading. hallId?: string hallName?: string @@ -61,7 +57,13 @@ export interface ExplainDetailEntryRequest { export interface ExplainTextSelection { exhibit: MuseumExhibit - textInfo: AudioTextInfo + textInfo: { + available: boolean + lang: AudioLanguage + text?: string + textLength?: number + textHash?: string + } available: boolean unavailableMessage?: string } @@ -96,54 +98,17 @@ export class ExplainUseCase { } } - private resolveAudioTarget(exhibit: MuseumExhibit, track?: ExplainTrack | null) { - const targetType: AudioPlayTargetType = track?.playTargetType - || exhibit.playTargetType - || 'ITEM' - const targetId = track?.playTargetId - || exhibit.playTargetId - || exhibit.id - - return { targetType, targetId } - } - - private resolveStopInfoAudioTarget(stopInfo: GuideStopInfo) { - return { - targetType: stopInfo.playTargetType || stopInfo.targetType, - targetId: stopInfo.playTargetId || stopInfo.targetId - } - } - - private async resolveDetailEntryTarget(request: ExplainDetailEntryRequest): Promise> { - const lang = request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) - if (request.targetType && request.targetId) { - return { - targetType: request.targetType, - targetId: request.targetId, - lang, - lightweight: true - } - } - - return { - targetType: 'ITEM', - targetId: request.exhibitId, - lang, - lightweight: true - } + private resolveDetailStopId(request: ExplainDetailEntryRequest) { + return String(request.stopId || request.targetId || request.exhibitId || '').trim() } private async resolveStaticDetailFallback( request: ExplainDetailEntryRequest, - entryTarget: Required + entryTarget: { stopId: string } ): Promise { - if (entryTarget.targetType !== 'ITEM' && entryTarget.targetType !== 'STOP') { - return null - } - const candidateIds = Array.from(new Set([ request.exhibitId, - entryTarget.targetId + entryTarget.stopId ].map((id) => id?.trim()).filter(Boolean))) for (const candidateId of candidateIds) { @@ -156,201 +121,89 @@ export class ExplainUseCase { .catch(() => null) } - private languageLabel(language: AudioLanguage) { - if (language === 'en-US') return '英文' - if (language === 'yue-HK') return '粤语' - return '中文' + private selectTextVariant(detail: GuideStopDetail, language: AudioLanguage, version = detail.version) { + const exact = detail.textVariants.find((variant) => ( + variant.version === version && variant.languageCode === language + )) + if (exact) return exact + if (language === 'yue-HK') { + return detail.textVariants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN') + } + return detail.textVariants.find((variant) => variant.version === version && variant.languageCode === 'zh-CN') } - private applyLanguageVariant(exhibit: MuseumExhibit, language: AudioLanguage): MuseumExhibit { - const variant = exhibit.audioVariants?.[language] - const mandarinText = exhibit.audioVariants?.['zh-CN']?.text - || exhibit.guideText - || exhibit.description - const supportedLanguages = exhibit.supportedLanguages?.length - ? exhibit.supportedLanguages - : exhibit.audioVariants ? Object.keys(exhibit.audioVariants) : [language] - - if (!variant) { - const label = this.languageLabel(language) - const guideText = language === 'yue-HK' - ? mandarinText || '当前讲解词暂未配置。' - : `${label}讲解词暂未配置。` - return { - ...exhibit, - guideText, - audioUrl: undefined, - audioDuration: undefined, - audioLanguage: language, - audioHasText: language === 'yue-HK' && Boolean(mandarinText), - audioAvailable: false, - audioStatus: 'MISSING', - audioUnavailableReason: `当前讲解暂无${label}音频`, - supportedLanguages - } - } - - return { - ...exhibit, - guideTitle: variant.title || exhibit.guideTitle, - guideText: language === 'yue-HK' - ? mandarinText || '当前讲解词暂未配置。' - : variant.text || `${this.languageLabel(language)}讲解词暂未配置。`, - audioUrl: variant.audioUrl, - audioDuration: variant.audioDuration, - audioLanguage: language, - audioHasText: language === 'yue-HK' ? Boolean(mandarinText) : variant.hasText === true, - audioAvailable: variant.available === true && Boolean(variant.audioUrl), - audioStatus: variant.available && variant.audioUrl ? 'READY' : 'MISSING', - audioUnavailableReason: variant.available && variant.audioUrl - ? undefined - : `当前讲解暂无${this.languageLabel(language)}音频`, - supportedLanguages - } - } - - private toExhibitFromStopInfo( - stopInfo: GuideStopInfo, + private toExhibitFromStopDetail( + detail: GuideStopDetail, fallback?: MuseumExhibit | null, - navigationContext?: ExplainDetailEntryRequest + navigationContext?: ExplainDetailEntryRequest, + language: AudioLanguage = 'zh-CN', + gender: GuideAudioGender = 'female' ): MuseumExhibit { - const linkedPrimary = stopInfo.linkedExhibits[0] - const coverImage = stopInfo.imageStatus === 'READY' - ? stopInfo.coverImageUrl - : undefined - const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' - const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) - // Product defaults Chinese and English to male even if the management default is female. - const audioOption = resolveGuideAudioOption(stopInfo.audioOptions, stopInfo.lang, 'male') - const audioAvailable = Boolean(audioOption?.playUrl) || stopInfo.audioStatus === 'READY' + const linkedPrimary = detail.linkedExhibits[0] + const textVariant = this.selectTextVariant(detail, language) + const currentVersionTracks = detail.audioTracks.filter((track) => track.version === detail.version) + const recommendedTrack = currentVersionTracks.find((track) => track.channelCode === detail.recommendedTrackCode) + const preferredGender = language === 'yue-HK' + ? 'female' + : recommendedTrack?.languageCode === language ? recommendedTrack.gender : gender + const audioOption = resolveGuideAudioOption(currentVersionTracks, language, preferredGender) + const audioAvailable = Boolean(audioOption?.playUrl) const supportedLanguages = resolveGuideAudioLanguages( - stopInfo.audioOptions, - stopInfo.supportedLanguages + currentVersionTracks, + currentVersionTracks.map((track) => track.languageCode) ) - return { ...(fallback || {}), - id: fallback?.id || linkedPrimary?.id || stopInfo.targetId, - name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容', + id: detail.id, + name: detail.name || fallback?.name || linkedPrimary?.name || '讲解内容', hallId: fallback?.hallId || navigationContext?.hallId, hallName: fallback?.hallName || navigationContext?.hallName, - floorId: stopInfo.floorId || fallback?.floorId || navigationContext?.floorId, + floorId: detail.floorId || fallback?.floorId || navigationContext?.floorId, floorLabel: fallback?.floorLabel || navigationContext?.floorLabel, - image: coverImage, - description, - poiId: stopInfo.poiId || fallback?.poiId || navigationContext?.poiId, - sourcePoiId: stopInfo.poiId || fallback?.sourcePoiId, - mapX: stopInfo.mapX ?? fallback?.mapX, - mapY: stopInfo.mapY ?? fallback?.mapY, - location: fallback?.location, - year: fallback?.year, - material: fallback?.material, - size: fallback?.size, - tags: fallback?.tags, - guideTitle: stopInfo.title || fallback?.guideTitle, - guideText: stopInfo.description || fallback?.guideText || fallback?.description, + image: detail.imageStatus === 'READY' ? detail.coverImageUrl : undefined, + description: detail.description || fallback?.description || '该讲解暂无简介。', + poiId: detail.poiId || fallback?.poiId || navigationContext?.poiId, + sourcePoiId: detail.poiId || fallback?.sourcePoiId, + mapX: detail.mapX ?? fallback?.mapX, + mapY: detail.mapY ?? fallback?.mapY, + guideTitle: detail.name, + guideText: textVariant?.text || detail.description || fallback?.guideText || fallback?.description, audioUrl: audioOption?.playUrl, audioDuration: audioOption?.duration, - audioLanguage: stopInfo.lang, - audioHasText: stopInfo.hasText, - audioText: undefined, - audioTextLength: undefined, - audioTextHash: undefined, - audioNarrationTier: undefined, - audioUnavailableReason: audioAvailable - ? undefined - : audioReasonToText(stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), + audioLanguage: language, + audioHasText: Boolean(textVariant?.text), + audioText: textVariant?.text, + audioTextLength: textVariant?.textLength, + audioTextHash: textVariant?.textHash, + audioUnavailableReason: audioAvailable ? undefined : audioReasonToText(detail.hasAudio ? undefined : 'NO_PUBLISHED_AUDIO'), audioAvailable, - audioStatus: audioAvailable ? 'READY' : stopInfo.audioStatus, + audioStatus: audioAvailable ? 'READY' : 'MISSING', supportedLanguages, - audioOptions: stopInfo.audioOptions, - imageStatus: stopInfo.imageStatus, - imageSource: stopInfo.imageSource, - galleryUrls: stopInfo.imageStatus === 'READY' ? stopInfo.galleryUrls : [], - linkedExhibitCount: stopInfo.linkedExhibitCount, - isSharedStop: stopInfo.isSharedStop, - linkedExhibits: stopInfo.linkedExhibits, - stopInfoAvailable: stopInfo.available, - stopInfoReason: stopInfo.reason, - resolvedStopId: stopInfo.resolvedStopId, - playTargetType: audioTarget.targetType, - playTargetId: audioTarget.targetId + audioOptions: detail.audioTracks, + textVariants: detail.textVariants, + recommendedTrackCode: detail.recommendedTrackCode, + guideVersion: detail.version, + textVariantCount: detail.textVariantCount, + audioTrackCount: detail.audioTrackCount, + imageStatus: detail.imageStatus, + galleryUrls: detail.imageStatus === 'READY' ? detail.galleryUrls : [], + linkedExhibitCount: detail.linkedExhibitCount, + isSharedStop: detail.isSharedStop, + linkedExhibits: detail.linkedExhibits, + stopInfoAvailable: true, + resolvedStopId: detail.id, + playTargetType: 'STOP', + playTargetId: detail.id } } - private applyPlayInfo( - exhibit: MuseumExhibit, - playInfo: AudioPlayInfo - ): MuseumExhibit { - const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl) - const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : undefined - const nextAudioDuration = typeof playInfo.duration === 'number' - ? playInfo.duration - : exhibit.audioDuration - - return { - ...exhibit, - guideTitle: playInfo.title || exhibit.guideTitle, - audioUrl: nextAudioUrl, - audioDuration: nextAudioDuration, - audioLanguage: playInfo.lang || exhibit.audioLanguage, - audioSubtitleUrl: playInfo.subtitleUrl || exhibit.audioSubtitleUrl, - audioHasText: playInfo.hasText === true || exhibit.audioHasText, - audioNarrationTier: playInfo.narrationTier || exhibit.audioNarrationTier, - audioUnavailableReason: apiPlayable ? undefined : audioReasonToText(playInfo.reason), - audioAvailable: apiPlayable, - audioStatus: apiPlayable ? 'READY' : 'MISSING' - } - } - - private applyTextInfo(exhibit: MuseumExhibit, textInfo: AudioTextInfo | null): MuseumExhibit { - if (!textInfo?.available || !textInfo.text) return exhibit - - return { - ...exhibit, - guideTitle: textInfo.title || exhibit.guideTitle, - guideText: textInfo.text, - audioText: textInfo.text, - audioTextLength: textInfo.textLength || exhibit.audioTextLength, - audioTextHash: textInfo.textHash || exhibit.audioTextHash, - audioLanguage: textInfo.lang || exhibit.audioLanguage, - audioNarrationTier: textInfo.narrationTier || exhibit.audioNarrationTier, - audioHasText: true - } - } - - private async enrichExhibitAudio( - exhibit: MuseumExhibit, - options: { - includeText?: boolean - track?: ExplainTrack | null - } = {} - ): Promise { - const track = options.track ?? await this.explain.getTrackByExhibitId(exhibit.id).catch(() => null) - const baseExhibit = this.applyTrackAudioState(exhibit, track) - const { targetType, targetId } = this.resolveAudioTarget(baseExhibit, track) - + private async enrichExhibitAudio(exhibit: MuseumExhibit): Promise { + if (exhibit.audioOptions?.length || !exhibit.id) return exhibit try { - const playInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId }) - let nextExhibit = this.applyPlayInfo(baseExhibit, playInfo) - - if (options.includeText && playInfo.hasText) { - try { - const textInfo = await this.audioPlayInfo.getTextInfo({ - targetType, - targetId, - lang: playInfo.lang - }) - nextExhibit = this.applyTextInfo(nextExhibit, textInfo) - } catch (error) { - console.warn('讲解词正文加载失败,将使用静态讲解文稿:', error) - } - } - - return nextExhibit - } catch (error) { - console.warn('讲解播放信息加载失败,将使用静态讲解数据:', error) - return baseExhibit + const detail = await this.audioPlayInfo.getStopDetail(exhibit.resolvedStopId || exhibit.id, { version: 'standard' }) + return this.toExhibitFromStopDetail(detail, exhibit) + } catch { + return exhibit } } @@ -406,10 +259,7 @@ export class ExplainUseCase { return baseExhibit } - return this.enrichExhibitAudio(exhibit, { - includeText: options.includeText, - track - }) + return this.enrichExhibitAudio(this.applyTrackAudioState(exhibit, track)) } catch (error) { console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error) return exhibit @@ -424,63 +274,30 @@ export class ExplainUseCase { } async enterExplainDetail(request: ExplainDetailEntryRequest) { - const entryTarget = await this.resolveDetailEntryTarget(request) - // Remote stop-info is the authoritative detail source. Never make its first paint wait for catalog. + const stopId = this.resolveDetailStopId(request) + if (!stopId) throw new Error('讲解点 ID 不能为空') + const detail = await this.audioPlayInfo.getStopDetail(stopId, { version: request.version || 'standard' }) + const language = request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) if (dataSourceConfig.explainContentMode === 'remote') { - const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget) - return this.toExhibitFromStopInfo(stopInfo, null, request) + return this.toExhibitFromStopDetail(detail, null, request, language) } - - const fallbackExhibit = await this.resolveStaticDetailFallback(request, entryTarget) - - if (fallbackExhibit) { - return this.applyLanguageVariant(fallbackExhibit, entryTarget.lang) - } - - const stopInfoResult = await this.audioPlayInfo.getStopInfo(entryTarget) - .then((stopInfo) => ({ stopInfo, error: null })) - .catch((error) => ({ stopInfo: null, error })) - - if (stopInfoResult.stopInfo) { - return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit, request) - } - - throw stopInfoResult.error || new Error('讲解详情加载失败') + const fallbackExhibit = await this.resolveStaticDetailFallback(request, { stopId }) + return this.toExhibitFromStopDetail(detail, fallbackExhibit, request, language) } async loadExplainDetailText(exhibit: MuseumExhibit): Promise { - const targetType = exhibit.playTargetType || 'ITEM' - const targetId = exhibit.playTargetId || exhibit.id const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) - - try { - const textInfo = await this.audioPlayInfo.getTextInfo({ - targetType, - targetId, - lang - }) - const nextExhibit = this.applyTextInfo(exhibit, textInfo) - - return { - exhibit: nextExhibit, - textInfo, - available: textInfo.available === true && Boolean(textInfo.text), - unavailableMessage: textInfo.available ? undefined : audioReasonToText(textInfo.reason || 'NO_TEXT') - } - } catch (error) { - console.error('讲解词正文解析失败:', error) - return { - exhibit, - textInfo: { - available: false, - targetType, - targetId, - lang, - reason: 'SERVICE_ERROR' - }, - available: false, - unavailableMessage: '讲解词服务暂不可用,请稍后重试' - } + const variants = exhibit.textVariants || [] + const version = exhibit.guideVersion || 'standard' + const variant = variants.find((item) => item.version === version && item.languageCode === lang) + || (lang === 'yue-HK' ? variants.find((item) => item.version === version && item.languageCode === 'zh-CN') : undefined) + const text = variant?.text || (lang === 'yue-HK' ? exhibit.textVariants?.find((item) => item.languageCode === 'zh-CN')?.text : undefined) + const nextExhibit = text ? { ...exhibit, guideText: text, audioText: text, audioHasText: true } : exhibit + return { + exhibit: nextExhibit, + textInfo: { available: Boolean(text), lang, text, textLength: variant?.textLength, textHash: variant?.textHash }, + available: Boolean(text), + unavailableMessage: text ? undefined : audioReasonToText('NO_TEXT') } } @@ -554,11 +371,9 @@ export class ExplainUseCase { } openGuideStopDetail(stopId: string) { - const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) return this.enterExplainDetail({ - exhibitId: target.targetId, - targetType: target.targetType, - targetId: target.targetId + exhibitId: stopId, + stopId }) } @@ -578,86 +393,40 @@ export class ExplainUseCase { const exhibit = detailExhibit || summaryExhibit if (!exhibit) return null - const targetType: AudioPlayTargetType = track?.playTargetType - || exhibit.playTargetType - || 'ITEM' - const targetId = track?.playTargetId - || exhibit.playTargetId - || exhibit.id - - const toPlayableSelection = ( - playInfo: AudioPlayInfo, - requestTargetType: AudioPlayTargetType, - requestTargetId: string - ): ExplainAudioSelection => { - const media: MediaAsset = { - id: `play-${playInfo.audioId || `${requestTargetType}-${requestTargetId}`}`, - type: 'audio', - url: playInfo.playUrl || undefined, - duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined, - language: playInfo.lang, - available: true - } - - return { - exhibit, - track, - media, - playable: true, - playInfo - } + const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) + const option = resolveGuideAudioOption(exhibit.audioOptions, language, 'female') + if (!option?.playUrl) { + return { exhibit, track, media: null, playable: false, unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO') } } - - try { - const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId }) - - if (initialPlayInfo.playable && initialPlayInfo.playUrl) { - return toPlayableSelection(initialPlayInfo, targetType, targetId) - } - - return { - exhibit, - track, - media: null, - playable: false, - playInfo: initialPlayInfo, - unavailableMessage: audioReasonToText(initialPlayInfo.reason) - } - } catch (error) { - console.error('语音播放解析失败:', error) - const serviceErrorPlayInfo: AudioPlayInfo = { - playable: false, - targetType, - targetId, - lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'], - hasText: false, - fallback: false, - reason: 'SERVICE_ERROR' - } - return { - exhibit, - track, - media: null, - playable: false, - playInfo: serviceErrorPlayInfo, - unavailableMessage: '语音服务暂不可用,当前提供图文讲解' - } + return { + exhibit, + track, + media: { + id: `channel-${option.channelCode}`, + type: 'audio', + url: option.playUrl, + duration: option.duration, + language, + available: true + }, + playable: true } } async selectAudioForExplainDetail( exhibit: MuseumExhibit, options: { - refreshPlayInfo?: boolean voiceGender?: GuideAudioGender } = {} ): Promise { - const targetType = exhibit.playTargetType || 'ITEM' - const targetId = exhibit.playTargetId || exhibit.id const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) - const audioOption = options.refreshPlayInfo - ? undefined - : resolveGuideAudioOption(exhibit.audioOptions, language, options.voiceGender || 'male') + const version = exhibit.guideVersion || 'standard' + const optionsForVersion = (exhibit.audioOptions || []).filter((option) => !option.version || option.version === version) + const recommendedTrack = optionsForVersion.find((option) => option.channelCode === exhibit.recommendedTrackCode) + const preferredGender = language === 'yue-HK' + ? 'female' + : options.voiceGender || (recommendedTrack?.languageCode === language ? recommendedTrack.gender : 'female') + const audioOption = resolveGuideAudioOption(optionsForVersion, language, preferredGender) if (audioOption) { const audioExhibit: MuseumExhibit = { @@ -683,12 +452,12 @@ export class ExplainUseCase { } } - if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) { + if (exhibit.audioUrl?.trim()) { return { exhibit, track: null, media: { - id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`, + id: `static-${exhibit.resolvedStopId || exhibit.id}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`, type: 'audio', url: exhibit.audioUrl, duration: exhibit.audioDuration, @@ -699,58 +468,12 @@ export class ExplainUseCase { } } - if (exhibit.audioStatus && exhibit.audioStatus !== 'READY') { - return { - exhibit, - track: null, - media: null, - playable: false, - unavailableMessage: audioReasonToText(exhibit.stopInfoReason || 'NO_PUBLISHED_AUDIO') - } - } - - try { - const playInfo = await this.audioPlayInfo.getPlayInfo({ - targetType, - targetId, - lang: language, - refresh: options.refreshPlayInfo === true - }) - - if (!playInfo.playable || !playInfo.playUrl) { - return { - exhibit, - track: null, - media: null, - playable: false, - playInfo, - unavailableMessage: audioReasonToText(playInfo.reason) - } - } - - return { - exhibit, - track: null, - media: { - id: `play-${playInfo.audioId || `${targetType}-${targetId}`}`, - type: 'audio', - url: playInfo.playUrl, - duration: typeof playInfo.duration === 'number' ? playInfo.duration : undefined, - language: playInfo.lang, - available: true - }, - playable: true, - playInfo - } - } catch (error) { - console.error('语音播放解析失败:', error) - return { - exhibit, - track: null, - media: null, - playable: false, - unavailableMessage: '语音服务暂不可用,请稍后重试' - } + return { + exhibit, + track: null, + media: null, + playable: false, + unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO') } } } diff --git a/src/usecases/poiDetailUseCase.ts b/src/usecases/poiDetailUseCase.ts index 1cb84d6..8d650e0 100644 --- a/src/usecases/poiDetailUseCase.ts +++ b/src/usecases/poiDetailUseCase.ts @@ -33,6 +33,7 @@ export interface PoiDetailTarget { detailId: string hallId?: string exhibitId?: string + stopId?: string url: string failureMessage: string } @@ -114,14 +115,24 @@ const resolveHallByControlledNameFallback = ( export class PoiDetailUseCase { async resolve(poi: PoiDetailSourcePoi): Promise { if (poi.exhibitId) { + // POI search data identifies the exhibit, while the unified detail API only + // accepts a guide-stop ID. Resolve it from the catalog before navigation. + const exhibit = await explainUseCase.getExhibitById(poi.exhibitId).catch(() => null) + const stopId = exhibit?.resolvedStopId + const detailUrl = new URLSearchParams({ id: poi.exhibitId }) + if (stopId) detailUrl.set('stopId', stopId) + return { poiId: poi.id, floorId: poi.floorId, detailType: 'exhibit', detailAvailable: true, - detailId: poi.exhibitId, + detailId: stopId || poi.exhibitId, exhibitId: poi.exhibitId, - url: createCanonicalDetailUrl(`/pages/exhibit/detail?id=${encodeURIComponent(poi.exhibitId)}`, poi), + stopId, + // Keep the legacy ID only for history/static-data compatibility when an + // older POI record cannot be associated with a guide stop. + url: createCanonicalDetailUrl(`/pages/exhibit/detail?${detailUrl.toString()}`, poi), failureMessage: '展品详情打开失败,请重试' } } diff --git a/src/view-models/explainViewModels.ts b/src/view-models/explainViewModels.ts index 55297c3..f764c3b 100644 --- a/src/view-models/explainViewModels.ts +++ b/src/view-models/explainViewModels.ts @@ -68,6 +68,10 @@ export interface ExplainDetailPageViewModel { supportedLanguages?: string[] languageLabel?: string audioOptions?: MuseumGuideAudioOption[] + textVariants?: import('@/domain/museum').MuseumGuideTextVariant[] + recommendedTrackCode?: string + version?: 'standard' | 'extended' + stopId?: string } imageStatus?: string galleryStatusText?: string @@ -238,7 +242,11 @@ export const toExplainDetailPageViewModel = (exhibit: MuseumExhibit): ExplainDet statusText: hasPlayableAudio ? '可播放' : '当前语言暂无语音讲解', supportedLanguages: exhibit.supportedLanguages, languageLabel: languageLabelFor(exhibit.audioLanguage), - audioOptions: exhibit.audioOptions + audioOptions: exhibit.audioOptions, + textVariants: exhibit.textVariants, + recommendedTrackCode: exhibit.recommendedTrackCode, + version: exhibit.guideVersion, + stopId: exhibit.resolvedStopId || exhibit.playTargetId || exhibit.id }, imageStatus: exhibit.imageStatus, galleryStatusText: coverImages.length > 1 ? `共 ${coverImages.length} 张讲解图片` : undefined, diff --git a/tests/unit/AudioPlayInfoRepository.spec.ts b/tests/unit/AudioPlayInfoRepository.spec.ts index 60af7f9..7c6629b 100644 --- a/tests/unit/AudioPlayInfoRepository.spec.ts +++ b/tests/unit/AudioPlayInfoRepository.spec.ts @@ -1,47 +1,81 @@ import { afterEach, describe, expect, it, vi } from 'vitest' import { DefaultAudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository' -const stopInfoResponse = { +const detailResponse = { code: 0, data: { - available: true, - targetType: 'STOP', - targetId: 'stop-1', - resolvedStopId: 'stop-1', - lang: 'zh-CN', - title: '测试讲解点', - description: '测试简介', - imageStatus: 'READY', - imageSource: 'STOP', - galleryUrls: '[]', - playTargetType: 'STOP', - playTargetId: 'stop-1', - hasAudio: false, + id: '865546647764037632', + version: 'standard', + name: '测试讲解点', + imageStatus: 'MISSING', + textVariants: [{ version: 'standard', languageCode: 'zh-CN', text: '测试正文' }], + audioTracks: [{ + version: 'standard', languageCode: 'zh-CN', gender: 'female', + playUrl: '/museum-assets/audio/zh-female.mp3' + }], hasText: true, - supportedLanguages: ['zh-CN'], - audioStatus: 'MISSING', - audioOptions: [] + hasAudio: true } } -describe('DefaultAudioPlayInfoRepository stop-info loading mode', () => { +describe('DefaultAudioPlayInfoRepository unified stop detail', () => { afterEach(() => vi.unstubAllGlobals()) - it('defaults H5 detail requests to lightweight mode and keeps its cache separate from legacy mode', async () => { + it('requests the versioned endpoint and caches each detail version independently', async () => { const requestUrls: string[] = [] vi.stubGlobal('uni', { request: ({ url, success }: { url: string, success: (response: unknown) => void }) => { requestUrls.push(url) - success({ statusCode: 200, data: stopInfoResponse }) + success({ + statusCode: 200, + data: { + ...detailResponse, + data: { + ...detailResponse.data, + version: String(url).includes('version=extended') ? 'extended' : 'standard' + } + } + }) } }) const repository = new DefaultAudioPlayInfoRepository() - await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN' }) - await repository.getStopInfo({ targetType: 'STOP', targetId: 'stop-1', lang: 'zh-CN', lightweight: false }) + const first = await repository.getStopDetail('865546647764037632') + const second = await repository.getStopDetail('865546647764037632') + const extended = await repository.getStopDetail('865546647764037632', { version: 'extended' }) - expect(requestUrls).toHaveLength(2) - expect(new URL(requestUrls[0], 'http://localhost').searchParams.get('lightweight')).toBe('true') - expect(new URL(requestUrls[1], 'http://localhost').searchParams.get('lightweight')).toBe('false') + expect(first.id).toBe('865546647764037632') + expect(second).toBe(first) + expect(extended).not.toBe(first) + expect(extended.version).toBe('extended') + expect(requestUrls).toEqual([ + '/app-api/gis/guide/stops/865546647764037632?version=standard', + '/app-api/gis/guide/stops/865546647764037632?version=extended' + ]) + expect(first.audioTracks[0]?.channelCode).toBe('standard.zh-CN.female') + }) + + it('bypasses a completed entry only when refresh is explicitly requested', async () => { + const request = vi.fn(({ success }: { success: (response: unknown) => void }) => { + success({ statusCode: 200, data: detailResponse }) + }) + vi.stubGlobal('uni', { request }) + const repository = new DefaultAudioPlayInfoRepository() + + await repository.getStopDetail('stop-1') + await repository.getStopDetail('stop-1', { refresh: true }) + + expect(request).toHaveBeenCalledTimes(2) + }) + + it('surfaces a business missing-detail response as a detail error', async () => { + vi.stubGlobal('uni', { + request: ({ success }: { success: (response: unknown) => void }) => { + success({ statusCode: 200, data: { code: 1020005000, msg: '导览讲解点不存在' } }) + } + }) + const repository = new DefaultAudioPlayInfoRepository() + + await expect(repository.getStopDetail('not-found')).rejects.toThrow('导览讲解点不存在') }) }) diff --git a/tests/unit/BackendExplainContentProvider.spec.ts b/tests/unit/BackendExplainContentProvider.spec.ts index 35b2d9c..199f435 100644 --- a/tests/unit/BackendExplainContentProvider.spec.ts +++ b/tests/unit/BackendExplainContentProvider.spec.ts @@ -10,7 +10,10 @@ const installRequest = (handler: (url: string) => unknown) => { } describe('BackendExplainContentProvider hall stop paging', () => { - afterEach(() => vi.unstubAllGlobals()) + afterEach(() => { + vi.restoreAllMocks() + vi.unstubAllGlobals() + }) it('uses the paged hall endpoint and maps independent pages without outlines', async () => { const requests: string[] = [] @@ -23,8 +26,8 @@ describe('BackendExplainContentProvider hall stop paging', () => { data: { total: 30, list: pageNo === '1' - ? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', playTargetType: 'STOP', playTargetId: 'target-1' }] - : [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', playTargetType: 'STOP', playTargetId: 'target-2' }] + ? [{ stopId: 'stop-1', name: '对象一', imageStatus: 'READY', coverImageUrl: '/one.jpg', outlineId: 'outline-1', linkedExhibits: [] }] + : [{ stopId: 'stop-2', name: '对象二', imageStatus: 'MISSING', outlineId: 'outline-1', linkedExhibits: [] }] } } }) @@ -35,7 +38,7 @@ describe('BackendExplainContentProvider hall stop paging', () => { ]) expect(first).toMatchObject({ total: 30, pageNo: 1, pageSize: 20, hasMore: true }) - expect(first.items[0]).toMatchObject({ id: 'stop-1', hallName: '恐龙厅', playTargetId: 'target-1' }) + expect(first.items[0]).toMatchObject({ id: 'stop-1', stopId: 'stop-1', hallName: '恐龙厅', outlineId: 'outline-1' }) expect(second.items.map((item) => item.id)).toEqual(['stop-2']) expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(2) expect(requests.some((url) => url.includes('/outlines'))).toBe(false) @@ -79,4 +82,25 @@ describe('BackendExplainContentProvider hall stop paging', () => { expect(requests.filter((url) => url.includes('/catalog/halls?'))).toHaveLength(1) expect(requests.filter((url) => url.includes('/stops/page'))).toHaveLength(1) }) + + it('warns when a hall has guide stops but every compatibility outline reports zero', async () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => undefined) + installRequest((url) => { + if (url.includes('/catalog/halls?')) { + return { code: 0, data: [{ id: 'hall-1', name: '恐龙厅', stopCount: 2 }] } + } + if (url.includes('/catalog/halls/hall-1/outlines?')) { + return { code: 0, data: [{ id: 'outline-1', name: '单元一', hallId: 'hall-1', stopCount: 0 }] } + } + throw new Error(`Unexpected request: ${url}`) + }) + + const units = await new BackendExplainContentProvider().listTemporaryBusinessUnitsByHall('hall-1') + + expect(units).toHaveLength(1) + expect(warn).toHaveBeenCalledWith( + '一级单元与讲解点关联异常,一级单元仅作为兼容入口:', + expect.objectContaining({ hallId: 'hall-1', hallStopCount: 2 }) + ) + }) }) diff --git a/tests/unit/ExhibitDetailAudioFirst.spec.ts b/tests/unit/ExhibitDetailAudioFirst.spec.ts index 966363b..2669a22 100644 --- a/tests/unit/ExhibitDetailAudioFirst.spec.ts +++ b/tests/unit/ExhibitDetailAudioFirst.spec.ts @@ -295,7 +295,7 @@ describe('讲解详情音频优先布局', () => { expect(wrapper.find('.detail-audio-status').exists()).toBe(false) }) - it('播放失败显示错误并通过既有刷新链路重试', async () => { + it('播放失败显示错误并从当前详情音轨重试', async () => { audioState.currentAudio.value = { id: 'audio-1', audioUrl: exhibit.audioUrl } audioState.currentSource.value = { targetType: 'STOP', targetId: 'stop-1', lang: 'en-US' } audioState.error.value = '音频暂时无法播放' @@ -316,7 +316,10 @@ describe('讲解详情音频优先布局', () => { await wrapper.get('.detail-audio-play').trigger('tap') await flushPromises() - expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.any(Object), { refreshPlayInfo: true }) + expect(mocks.selectAudioForExplainDetail).toHaveBeenCalledWith(expect.objectContaining({ + id: exhibit.id, + guideVersion: 'standard' + })) expect(mocks.play).toHaveBeenCalledWith(expect.objectContaining({ id: 'audio-refresh' }), expect.objectContaining({ retryOnError: expect.any(Function), displayMode: 'mini' @@ -349,13 +352,13 @@ describe('讲解详情音频优先布局', () => { await flushPromises() expect(wrapper.get('.detail-audio-speed').text()).toBe('1.0') - expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为女声讲解') + expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为男声讲解') await wrapper.get('.detail-audio-speed').trigger('tap') await wrapper.get('.detail-audio-voice').trigger('tap') expect(mocks.setPlaybackRate).toHaveBeenCalledWith(1.25) - expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为男声讲解') + expect(wrapper.get('.detail-audio-voice').attributes('aria-label')).toBe('切换为女声讲解') expect(mocks.toggleMute).not.toHaveBeenCalled() }) @@ -407,9 +410,6 @@ describe('讲解详情音频优先布局', () => { }) await flushPromises() - vi.mocked(uni.navigateBack).mockImplementationOnce((options: any) => { - options.fail?.() - }) wrapper.getComponent(GuidePageFrameStub).vm.$emit('back') await flushPromises() @@ -418,6 +418,6 @@ describe('讲解详情音频优先布局', () => { expect(url.split('?')[0]).toBe('/pages/explain/guide-stop-list') expect(params.get('hallId')).toBe('hall-1') expect(params.get('hallName')).toBe('恐龙厅') - expect(uni.navigateBack).toHaveBeenCalledWith(expect.objectContaining({ delta: 1 })) + expect(uni.navigateBack).not.toHaveBeenCalled() }) }) diff --git a/tests/unit/ExplainGuideStopCatalog.spec.ts b/tests/unit/ExplainGuideStopCatalog.spec.ts index d6ddbb8..fadcb26 100644 --- a/tests/unit/ExplainGuideStopCatalog.spec.ts +++ b/tests/unit/ExplainGuideStopCatalog.spec.ts @@ -22,7 +22,7 @@ describe('ExplainGuideStopCatalog', () => { expect(wrapper.findAll('.stop-status')).toHaveLength(3) expect(wrapper.text()).toContain('讲解') expect(wrapper.text()).toContain('图文') - expect(wrapper.text()).toContain('暂无内容') + expect(wrapper.text()).not.toContain('暂无内容') expect(wrapper.find('.hall-arrow').exists()).toBe(false) expect(wrapper.find('.stop-name').classes()).toContain('stop-name') }) diff --git a/tests/unit/ExplainGuideStopListNavigation.spec.ts b/tests/unit/ExplainGuideStopListNavigation.spec.ts index c375dc8..e2bc435 100644 --- a/tests/unit/ExplainGuideStopListNavigation.spec.ts +++ b/tests/unit/ExplainGuideStopListNavigation.spec.ts @@ -110,8 +110,7 @@ describe('讲解对象列表返回', () => { expect(url.split('?')[0]).toBe('/pages/exhibit/detail') expect(params.get('hallId')).toBe('hall-1') expect(params.get('hallName')).toBe('恐龙厅') - expect(params.get('targetType')).toBe('STOP') - expect(params.get('targetId')).toBe('target-1') + expect(params.get('stopId')).toBe('stop-1') wrapper.unmount() }) diff --git a/tests/unit/ExplainUseCase.spec.ts b/tests/unit/ExplainUseCase.spec.ts index 3a07dd4..4a6f684 100644 --- a/tests/unit/ExplainUseCase.spec.ts +++ b/tests/unit/ExplainUseCase.spec.ts @@ -1,102 +1,89 @@ import { describe, expect, it, vi } from 'vitest' -import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter' +import type { GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter' import type { AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository' import type { ExplainRepository } from '@/repositories/ExplainRepository' import { ExplainUseCase } from '@/usecases/explainUseCase' import { dataSourceConfig } from '@/config/dataSource' -const createStopInfo = (overrides: Partial = {}): GuideStopInfo => ({ - available: true, - targetType: 'STOP', - targetId: 'stop-1', - lang: 'zh-CN', - title: 'Guide stop', +const createDetail = (overrides: Partial = {}): GuideStopDetail => ({ + id: 'stop-1', + version: 'standard', + name: 'Guide stop', galleryUrls: [], imageStatus: 'MISSING', - linkedExhibits: [ - { - id: 'exhibit-1', - name: 'Linked exhibit', - coverImageUrl: '/linked-exhibit.jpg' - } + linkedExhibits: [{ id: 'exhibit-1', name: 'Linked exhibit', coverImageUrl: '/linked-exhibit.jpg' }], + isSharedStop: false, + textVariants: [ + { version: 'standard', languageCode: 'zh-CN', text: '普通话正文' }, + { version: 'standard', languageCode: 'en-US', text: 'English text' } ], - playTargetType: 'STOP', - playTargetId: 'stop-1', - hasAudio: false, - hasText: false, - supportedLanguages: [], - audioStatus: 'MISSING', + audioTracks: [ + { channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' }, + { channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/yue-female.mp3' } + ], + hasText: true, + hasAudio: true, + textVariantCount: 2, + audioTrackCount: 2, ...overrides }) -const createUseCase = (stopInfo: GuideStopInfo) => { +const createUseCase = (detail: GuideStopDetail) => { const explain = { getExhibitById: vi.fn().mockResolvedValue(null), listExplainExhibits: vi.fn().mockResolvedValue([]) } as unknown as ExplainRepository - const audioPlayInfo = { - getStopInfo: vi.fn().mockResolvedValue(stopInfo) - } as unknown as AudioPlayInfoRepository - - return new ExplainUseCase(explain, audioPlayInfo) + const audio = { getStopDetail: vi.fn().mockResolvedValue(detail) } as unknown as AudioPlayInfoRepository + return { useCase: new ExplainUseCase(explain, audio), explain, audio } } -describe('ExplainUseCase stop image policy', () => { - it('uses stop-info first in remote mode without requesting a catalog fallback', async () => { +describe('ExplainUseCase unified stop detail', () => { + it('uses stopId once in remote mode without requesting a catalog fallback', async () => { const originalMode = dataSourceConfig.explainContentMode Object.assign(dataSourceConfig, { explainContentMode: 'remote' }) - const explain = { - getExhibitById: vi.fn(), - listExplainExhibits: vi.fn(), - listExplainExhibitsByHall: vi.fn() - } as unknown as ExplainRepository - const audio = { getStopInfo: vi.fn().mockResolvedValue(createStopInfo()) } as unknown as AudioPlayInfoRepository + const { useCase, explain, audio } = createUseCase(createDetail()) - await new ExplainUseCase(explain, audio).enterExplainDetail({ - exhibitId: 'stop-1', targetType: 'STOP', targetId: 'stop-1', hallName: '宇宙厅' - }) + const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', hallName: '宇宙厅' }) - expect(audio.getStopInfo).toHaveBeenCalledTimes(1) + expect(audio.getStopDetail).toHaveBeenCalledWith('stop-1', { version: 'standard' }) expect(explain.getExhibitById).not.toHaveBeenCalled() - expect(explain.listExplainExhibits).not.toHaveBeenCalled() + expect(result.hallName).toBe('宇宙厅') Object.assign(dataSourceConfig, { explainContentMode: originalMode }) }) - it('delegates hall exhibits and business-unit stops without loading full exhibits', async () => { - const explain = { - listExplainExhibitsByHall: vi.fn().mockResolvedValue([]), - listGuideStopsByBusinessUnit: vi.fn().mockResolvedValue([]) - } as unknown as ExplainRepository - const useCase = new ExplainUseCase(explain, {} as AudioPlayInfoRepository) - await useCase.listExhibitsByHallId('hall-1') - await useCase.listGuideStopsByBusinessUnit('hall-1', 'outline-1') - expect(explain.listExplainExhibitsByHall).toHaveBeenCalledWith('hall-1') - expect(explain.listGuideStopsByBusinessUnit).toHaveBeenCalledWith('hall-1', 'outline-1') - }) - it('does not use linked exhibit images when stop-info reports MISSING', async () => { - const detail = await createUseCase(createStopInfo()).enterExplainDetail({ - exhibitId: 'stop-1', - targetType: 'STOP', - targetId: 'stop-1' - }) + it('does not use linked exhibit images when detail reports MISSING', async () => { + const { useCase } = createUseCase(createDetail()) + const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' }) - expect(detail.image).toBeUndefined() - expect(detail.galleryUrls).toEqual([]) - expect(detail.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg') + expect(result.image).toBeUndefined() + expect(result.galleryUrls).toEqual([]) + expect(result.linkedExhibits?.[0]?.coverImageUrl).toBe('/linked-exhibit.jpg') }) - it('uses the stop image when stop-info reports READY', async () => { - const detail = await createUseCase(createStopInfo({ - imageStatus: 'READY', - coverImageUrl: '/guide-stop.jpg', - galleryUrls: ['/guide-stop-gallery.jpg'] - })).enterExplainDetail({ - exhibitId: 'stop-1', - targetType: 'STOP', - targetId: 'stop-1' - }) + it('uses local standard text and audio variants, with Chinese text fallback for Cantonese', async () => { + const { useCase, audio } = createUseCase(createDetail()) + const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1', lang: 'yue-HK' }) + const text = await useCase.loadExplainDetailText(result) + const selection = await useCase.selectAudioForExplainDetail(result, { voiceGender: 'male' }) - expect(detail.image).toBe('/guide-stop.jpg') - expect(detail.galleryUrls).toEqual(['/guide-stop-gallery.jpg']) + expect(result.guideText).toBe('普通话正文') + expect(text.textInfo.text).toBe('普通话正文') + expect(selection.media?.url).toBe('/yue-female.mp3') + expect(audio.getStopDetail).toHaveBeenCalledTimes(1) + }) + + it('uses the recommended track for the current detail version by default', async () => { + const { useCase } = createUseCase(createDetail({ + recommendedTrackCode: 'standard.zh-CN.female', + audioTracks: [ + { channelCode: 'standard.zh-CN.female', version: 'standard', languageCode: 'zh-CN', gender: 'female', playUrl: '/zh-female.mp3' }, + { channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/zh-male.mp3' } + ] + })) + + const result = await useCase.enterExplainDetail({ exhibitId: 'stop-1', stopId: 'stop-1' }) + const selection = await useCase.selectAudioForExplainDetail(result) + + expect(selection.media?.url).toBe('/zh-female.mp3') }) }) diff --git a/tests/unit/GlobalAudioPlayerLanguage.spec.ts b/tests/unit/GlobalAudioPlayerLanguage.spec.ts index f63c804..638ecf0 100644 --- a/tests/unit/GlobalAudioPlayerLanguage.spec.ts +++ b/tests/unit/GlobalAudioPlayerLanguage.spec.ts @@ -1,18 +1,6 @@ // @vitest-environment happy-dom import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' - -const repositoryMocks = vi.hoisted(() => ({ - getPlayInfo: vi.fn() -})) - -vi.mock('@/repositories/AudioPlayInfoRepository', () => ({ - audioPlayInfoRepository: { - getPlayInfo: repositoryMocks.getPlayInfo - }, - audioReasonToText: () => '当前语言暂无语音讲解' -})) - import { useGlobalAudioPlayer } from '@/composables/useGlobalAudioPlayer' describe('全局讲解播放器语言切换', () => { @@ -20,10 +8,7 @@ describe('全局讲解播放器语言切换', () => { beforeEach(() => { player.close() - repositoryMocks.getPlayInfo.mockReset() - vi.stubGlobal('uni', { - showToast: vi.fn() - }) + vi.stubGlobal('uni', { showToast: vi.fn() }) vi.spyOn(window.HTMLMediaElement.prototype, 'play').mockResolvedValue(undefined) vi.spyOn(window.HTMLMediaElement.prototype, 'pause').mockImplementation(() => undefined) vi.spyOn(window.HTMLMediaElement.prototype, 'load').mockImplementation(() => undefined) @@ -35,18 +20,9 @@ describe('全局讲解播放器语言切换', () => { vi.unstubAllGlobals() }) - it('切换到粤语时立即中断旧音频,并使用 yue-HK 播放地址', async () => { - repositoryMocks.getPlayInfo.mockResolvedValue({ - playable: true, - targetType: 'STOP', - targetId: '1823450596800289', - lang: 'yue-HK', - audioId: 'cantonese-audio', - playUrl: '/museum-assets/audio/cantonese.mp3', - hasText: true, - fallback: false - }) - + it('切换到粤语时只从当前统一详情的音轨数组选择,不产生额外 HTTP 请求', async () => { + const request = vi.fn() + vi.stubGlobal('uni', { request, showToast: vi.fn() }) await player.play({ id: 'mandarin-audio', name: '测试讲解', @@ -54,28 +30,25 @@ describe('全局讲解播放器语言切换', () => { language: 'zh-CN' }, { source: { - targetType: 'STOP', - targetId: '1823450596800289', - lang: 'zh-CN' + stopId: '1823450596800289', + lang: 'zh-CN', + voiceGender: 'male', + audioOptions: [ + { channelCode: 'standard.zh-CN.male', version: 'standard', languageCode: 'zh-CN', gender: 'male', playUrl: '/museum-assets/audio/mandarin.mp3' }, + { channelCode: 'standard.yue-HK.female', version: 'standard', languageCode: 'yue-HK', gender: 'female', playUrl: '/museum-assets/audio/cantonese.mp3' } + ] } }) - const pauseSpy = vi.mocked(window.HTMLMediaElement.prototype.pause) - pauseSpy.mockClear() const switching = player.switchLanguage('yue-HK') - expect(pauseSpy).toHaveBeenCalledTimes(1) - expect(player.playing.value).toBe(false) - expect(player.currentTime.value).toBe(0) - expect(repositoryMocks.getPlayInfo).toHaveBeenCalledWith(expect.objectContaining({ - targetType: 'STOP', - targetId: '1823450596800289', - lang: 'yue-HK', - refresh: true - })) - await expect(switching).resolves.toBe(true) - expect(player.currentSource.value?.lang).toBe('yue-HK') + expect(request).not.toHaveBeenCalled() + expect(player.currentSource.value).toEqual(expect.objectContaining({ + stopId: '1823450596800289', + lang: 'yue-HK', + voiceGender: 'female' + })) expect(player.currentAudio.value).toEqual(expect.objectContaining({ language: 'yue-HK', audioUrl: '/museum-assets/audio/cantonese.mp3' diff --git a/tests/unit/GuideStopInfoAdapter.spec.ts b/tests/unit/GuideStopInfoAdapter.spec.ts index e33fcd5..a8bd1e3 100644 --- a/tests/unit/GuideStopInfoAdapter.spec.ts +++ b/tests/unit/GuideStopInfoAdapter.spec.ts @@ -1,137 +1,67 @@ import { describe, expect, it } from 'vitest' -import { toGuideStopInfo } from '@/data/adapters/guideStopInfoAdapter' +import { toGuideStopDetail } from '@/data/adapters/guideStopInfoAdapter' -describe('guide stop-info adapter', () => { - it('normalizes aggregated language variants and exposes only displayable languages', () => { - const result = toGuideStopInfo({ - available: true, - targetType: 'STOP', - targetId: '9001', - lang: 'zh-CN', +const languages = ['zh-CN', 'en-US', 'yue-HK'] as const + +describe('unified guide stop detail adapter', () => { + it('preserves snowflake IDs, synthesizes stable track codes, and ignores images marked missing', () => { + const textVariants = languages.map((languageCode) => ({ + version: 'standard', + languageCode, + text: `standard-${languageCode}` + })) + const audioTracks = languages.flatMap((languageCode) => ( + languageCode === 'yue-HK' + ? [{ version: 'standard', languageCode, gender: 'female', playUrl: `/standard-${languageCode}-female.mp3` }] + : ['female', 'male'].map((gender) => ({ version: 'standard', languageCode, gender, playUrl: `/standard-${languageCode}-${gender}.mp3` })) + )) + const detail = toGuideStopDetail({ + id: '865546647764037632', + version: 'standard', + outlineId: '7467940240901013505', + linkedExhibits: [{ id: '865546647764037633', name: '关联展品' }], + name: '青铜神树', + coverImageUrl: '/should-not-be-used.webp', + galleryUrls: '["/should-not-be-used-gallery.webp"]', + imageStatus: 'MISSING', + recommendedTrackCode: 'standard.zh-CN.female', + textVariants, + audioTracks, + textVariantCount: 3, + audioTrackCount: 5 + }) + + expect(detail.id).toBe('865546647764037632') + expect(detail.version).toBe('standard') + expect(detail.outlineId).toBe('7467940240901013505') + expect(detail.linkedExhibits[0]?.id).toBe('865546647764037633') + expect(detail.coverImageUrl).toBeUndefined() + expect(detail.galleryUrls).toEqual([]) + expect(detail.textVariants).toHaveLength(3) + expect(detail.audioTracks).toHaveLength(5) + expect(detail.recommendedTrackCode).toBe('standard.zh-CN.female') + expect(detail.audioTracks.find((track) => track.channelCode === 'standard.zh-CN.female')?.playUrl) + .toBe('/standard-zh-CN-female.mp3') + }) + + it('drops unusable tracks while retaining a Chinese text variant for Cantonese fallback', () => { + const detail = toGuideStopDetail({ + id: 'stop-1', + version: 'extended', + name: '测试', imageStatus: 'READY', - coverImageUrl: '/museum-assets/cover.webp', - languageVariants: [ - { - lang: 'zh', - enabled: true, - playable: true, - audioStatus: 'READY', - playUrl: '/museum-assets/audio/zh.mp3', - duration: '50', - audioId: 12, - hasText: true, - textAvailable: true, - text: '普通话讲解词', - textLength: '6', - textHash: 'zh-hash' - }, - { - lang: 'yue-HK', - enabled: true, - playable: false, - audioStatus: 'MISSING', - textAvailable: true, - text: '粤语使用普通话文案', - reason: 'NO_PUBLISHED_AUDIO' - }, - { - lang: 'en-US', - enabled: false, - playable: true, - playUrl: '/museum-assets/audio/en.mp3' - } + textVariants: [{ version: 'extended', languageCode: 'zh-CN', text: '普通话正文' }], + audioTracks: [ + { version: 'extended', languageCode: 'yue-HK', gender: 'female', playUrl: '/cantonese.mp3' }, + { version: 'extended', languageCode: 'zh-CN', gender: 'female' } ] - }, { - targetType: 'STOP', - targetId: '9001', - lang: 'zh-CN' }) - expect(result.supportedLanguages).toEqual(['zh-CN', 'yue-HK']) - expect(result.languageVariants['zh-CN']).toMatchObject({ - playable: true, - playUrl: '/museum-assets/audio/zh.mp3', - duration: 50, - audioId: '12', - text: '普通话讲解词', - textLength: 6 - }) - expect(result.languageVariants['yue-HK']).toMatchObject({ - playable: false, - textAvailable: true, - reason: 'NO_PUBLISHED_AUDIO' - }) - }) - - it('keeps supportedLanguages as the compatibility fallback when variants are absent', () => { - const result = toGuideStopInfo({ - supportedLanguages: ['zh-CN', 'en-US'] - }, { - targetType: 'STOP', - targetId: '9001', - lang: 'zh-CN' - }) - - expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US']) - expect(result.languageVariants).toEqual({}) - }) - - it('uses playable audio options as the primary language and voice contract', () => { - const result = toGuideStopInfo({ - supportedLanguages: ['zh-CN', 'yue-HK'], - audioOptions: [ - { - channelCode: 'standard.zh-CN.male', - displayName: '普通话男声', - languageCode: 'zh-CN', - gender: 'male', - playUrl: '/museum-assets/audio/zh-male.mp3', - duration: '51', - isDefault: false, - sortOrder: 11 - }, - { - channelCode: 'standard.en-US.female', - displayName: '英文女声', - languageCode: 'en-US', - gender: 'female', - playUrl: '/museum-assets/audio/en-female.mp3', - duration: 49, - isDefault: true, - sortOrder: 20 - }, - { - channelCode: 'standard.zh-CN.female', - displayName: '普通话女声', - languageCode: 'zh-CN', - gender: 'female', - playUrl: '/museum-assets/audio/zh-female.mp3', - isDefault: true, - sortOrder: 10 - }, - { - channelCode: 'invalid', - languageCode: 'zh-CN', - gender: 'female' - } - ] - }, { - targetType: 'STOP', - targetId: '9001', - lang: 'zh-CN' - }) - - expect(result.supportedLanguages).toEqual(['zh-CN', 'en-US']) - expect(result.audioOptions.map((option) => option.channelCode)).toEqual([ - 'standard.zh-CN.female', - 'standard.zh-CN.male', - 'standard.en-US.female' - ]) - expect(result.audioOptions[0]).toMatchObject({ - displayName: '普通话女声', - gender: 'female', - playUrl: '/museum-assets/audio/zh-female.mp3', - isDefault: true - }) + expect(detail.version).toBe('extended') + expect(detail.textVariants[0]?.text).toBe('普通话正文') + expect(detail.audioTracks).toEqual([expect.objectContaining({ + channelCode: 'extended.yue-HK.female', + gender: 'female' + })]) }) }) diff --git a/tests/unit/PoiDetailUseCase.spec.ts b/tests/unit/PoiDetailUseCase.spec.ts new file mode 100644 index 0000000..f8ff03c --- /dev/null +++ b/tests/unit/PoiDetailUseCase.spec.ts @@ -0,0 +1,37 @@ +import { beforeEach, describe, expect, it, vi } from 'vitest' +import { explainUseCase } from '@/usecases/explainUseCase' +import { PoiDetailUseCase } from '@/usecases/poiDetailUseCase' + +vi.mock('@/usecases/explainUseCase', () => ({ + explainUseCase: { + getExhibitById: vi.fn(), + listHalls: vi.fn() + } +})) + +describe('PoiDetailUseCase', () => { + beforeEach(() => { + vi.mocked(explainUseCase.getExhibitById).mockReset() + }) + + it('resolves a POI exhibit reference to a guide stop before opening the unified detail route', async () => { + vi.mocked(explainUseCase.getExhibitById).mockResolvedValue({ + id: 'stop-865546647764037632', + resolvedStopId: '865546647764037632' + } as never) + + const target = await new PoiDetailUseCase().resolve({ + id: 'poi-1', + name: '青铜神树', + floorId: 'floor-1', + exhibitId: 'exhibit-1' + }) + + const params = new URLSearchParams(target.url.split('?')[1]) + expect(explainUseCase.getExhibitById).toHaveBeenCalledWith('exhibit-1') + expect(target.detailId).toBe('865546647764037632') + expect(target.stopId).toBe('865546647764037632') + expect(params.get('id')).toBe('exhibit-1') + expect(params.get('stopId')).toBe('865546647764037632') + }) +})