import type { AudioPlayTargetType, ExplainBusinessUnit, ExplainGuideStop, ExplainGuideStopPage, ExplainTrack, MediaAsset, MuseumExhibit, MuseumHall, SearchIndexItem } from '@/domain/museum' import { explainRepository, type ExplainRepository } from '@/repositories/ExplainRepository' import { audioPlayInfoRepository, audioReasonToText, type AudioLanguage, type AudioPlayInfo, type AudioPlayInfoRepository, type GuideStopInfoRequest, type AudioTextInfo } from '@/repositories/AudioPlayInfoRepository' import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter' import { dataSourceConfig } from '@/config/dataSource' import { normalizeExplainDetailTargetFromGuideStop } from '@/domain/explainDetailTarget' export interface ExplainAudioSelection { exhibit: MuseumExhibit track: ExplainTrack | null media: MediaAsset | null playable: boolean unavailableMessage?: string playInfo?: AudioPlayInfo } export interface ExplainDetailEntryRequest { exhibitId: string targetType?: AudioPlayTargetType targetId?: string lang?: AudioLanguage // Navigation context is non-authoritative and keeps deep links independent of catalog loading. hallId?: string hallName?: string floorId?: string floorLabel?: string poiId?: string } export interface ExplainTextSelection { exhibit: MuseumExhibit textInfo: AudioTextInfo available: boolean unavailableMessage?: string } export interface ExhibitDetailAudioOptions { enrichAudio?: boolean includeText?: boolean } export interface ExplainHallSummary { hallId: string guideStopCount: number } export class ExplainUseCase { constructor( private readonly explain: ExplainRepository = explainRepository, private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository ) {} private applyTrackAudioState(exhibit: MuseumExhibit, track?: ExplainTrack | null): MuseumExhibit { const hasPlayableAudioUrl = Boolean(exhibit.audioUrl?.trim()) const hasTrackTarget = Boolean(track?.playTargetType || track?.playTargetId) if (!hasPlayableAudioUrl && !hasTrackTarget) return exhibit return { ...exhibit, audioAvailable: hasPlayableAudioUrl, playTargetType: track?.playTargetType || exhibit.playTargetType, playTargetId: track?.playTargetId || exhibit.playTargetId } } 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 } } return { targetType: 'ITEM', targetId: request.exhibitId, lang } } private async resolveStaticDetailFallback( request: ExplainDetailEntryRequest, entryTarget: Required ): Promise { if (entryTarget.targetType !== 'ITEM' && entryTarget.targetType !== 'STOP') { return null } const candidateIds = Array.from(new Set([ request.exhibitId, entryTarget.targetId ].map((id) => id?.trim()).filter(Boolean))) for (const candidateId of candidateIds) { const exhibit = await this.explain.getExhibitById(candidateId).catch(() => null) if (exhibit) return exhibit } return this.explain.listExplainExhibits() .then((items) => items.find((item) => candidateIds.includes(item.id)) || null) .catch(() => null) } private languageLabel(language: AudioLanguage) { if (language === 'en-US') return '英文' if (language === 'yue-HK') return '粤语' return '中文' } 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 } } const playable = (variant.playable ?? variant.available) === true && Boolean(variant.audioUrl) const textAvailable = variant.textAvailable ?? (variant.hasText === true || Boolean(variant.text)) const guideText = variant.text || (language === 'yue-HK' ? mandarinText : undefined) || `${this.languageLabel(language)}讲解词暂未配置。` return { ...exhibit, guideTitle: variant.title || exhibit.guideTitle, guideText, audioUrl: playable ? variant.audioUrl : undefined, audioDuration: variant.audioDuration, audioLanguage: language, audioHasText: textAvailable, audioText: variant.text || undefined, audioTextLength: variant.textLength, audioTextHash: variant.textHash, audioNarrationTier: variant.narrationTier, audioAvailable: playable, audioStatus: variant.audioStatus || (playable ? 'READY' : 'MISSING'), audioUnavailableReason: playable ? undefined : audioReasonToText(variant.reason || 'NO_PUBLISHED_AUDIO'), supportedLanguages } } private toExhibitFromStopInfo( stopInfo: GuideStopInfo, fallback?: MuseumExhibit | null, navigationContext?: ExplainDetailEntryRequest ): 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) const audioVariants = Object.fromEntries(Object.values(stopInfo.languageVariants).map((variant) => [variant.lang, { title: stopInfo.title, text: variant.text, textAvailable: variant.textAvailable, textLength: variant.textLength, textHash: variant.textHash, audioUrl: variant.playUrl, audioDuration: variant.duration, format: variant.format, audioId: variant.audioId, narrationTier: variant.narrationTier, hasText: variant.hasText, enabled: variant.enabled, playable: variant.playable, available: variant.playable, audioStatus: variant.audioStatus, fallback: variant.fallback, reason: variant.reason }])) const currentVariant = audioVariants[stopInfo.lang] const audioAvailable = currentVariant?.playable === true && Boolean(currentVariant.audioUrl) return { ...(fallback || {}), id: fallback?.id || linkedPrimary?.id || stopInfo.targetId, name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容', hallId: fallback?.hallId || navigationContext?.hallId, hallName: fallback?.hallName || navigationContext?.hallName, floorId: stopInfo.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: currentVariant?.text || stopInfo.description || fallback?.guideText || fallback?.description, audioUrl: audioAvailable ? currentVariant?.audioUrl : undefined, audioDuration: currentVariant?.audioDuration, audioLanguage: stopInfo.lang, audioHasText: currentVariant?.textAvailable || stopInfo.hasText, audioText: currentVariant?.text, audioTextLength: currentVariant?.textLength, audioTextHash: currentVariant?.textHash, audioNarrationTier: currentVariant?.narrationTier, audioUnavailableReason: audioAvailable ? undefined : audioReasonToText(currentVariant?.reason || stopInfo.reason || (stopInfo.audioStatus === 'MISSING' ? 'NO_PUBLISHED_AUDIO' : undefined)), audioAvailable, audioStatus: currentVariant?.audioStatus || stopInfo.audioStatus, supportedLanguages: stopInfo.supportedLanguages, audioVariants, 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 } } 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) 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 } } private async getTrackByExhibitIdMap() { const tracks = await this.explain.listTracks() const trackByExhibitId = new Map() tracks.forEach((track) => { if (track.exhibitId && !trackByExhibitId.has(track.exhibitId)) { trackByExhibitId.set(track.exhibitId, track) } }) return trackByExhibitId } async listExhibits() { return this.listExplainExhibits() } async listExplainExhibits() { const exhibits = await this.explain.listExplainExhibits() try { const trackByExhibitId = await this.getTrackByExhibitIdMap() return exhibits.map((exhibit) => this.applyTrackAudioState(exhibit, trackByExhibitId.get(exhibit.id))) } catch (error) { console.warn('讲解音频状态加载失败,将使用讲解列表原始音频状态:', error) return exhibits } } async listFullExhibits() { const exhibits = await this.explain.listExhibits() try { const trackByExhibitId = await this.getTrackByExhibitIdMap() return exhibits.map((exhibit) => this.applyTrackAudioState(exhibit, trackByExhibitId.get(exhibit.id))) } catch (error) { console.warn('讲解音频状态加载失败,将使用展项原始音频状态:', error) return exhibits } } async getExhibitById(id: string, options: ExhibitDetailAudioOptions = {}) { const exhibit = await this.explain.getExhibitById(id) if (!exhibit) return null try { const track = await this.explain.getTrackByExhibitId(id) const baseExhibit = this.applyTrackAudioState(exhibit, track) if (!options.enrichAudio) { return baseExhibit } return this.enrichExhibitAudio(exhibit, { includeText: options.includeText, track }) } catch (error) { console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error) return exhibit } } async enrichExhibitDetailAudio(id: string) { return this.getExhibitById(id, { enrichAudio: true, includeText: true }) } 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. if (dataSourceConfig.explainContentMode === 'remote') { const stopInfo = await this.audioPlayInfo.getStopInfo(entryTarget) return this.toExhibitFromStopInfo(stopInfo, null, request) } 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('讲解详情加载失败') } selectExplainDetailLanguage(exhibit: MuseumExhibit, language: AudioLanguage) { return this.applyLanguageVariant(exhibit, 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: '讲解词服务暂不可用,请稍后重试' } } } listHalls(): Promise { return this.explain.listHalls() } loadExplainHalls(): Promise { return this.listHalls() } async loadExplainHallSummaries(hallIds: string[]): Promise> { const uniqueHallIds = Array.from(new Set(hallIds.map((id) => id.trim()).filter(Boolean))) const halls = await this.listHalls().catch(() => []) const summariesFromHallList = new Map(halls.map((hall) => [hall.id, hall])) const entries = await Promise.all(uniqueHallIds.map(async (hallId) => { const hall = summariesFromHallList.get(hallId) if (hall && (typeof hall.outlineCount === 'number' || typeof hall.stopCount === 'number')) { return [hallId, { hallId, guideStopCount: hall.stopCount || 0 }] as const } try { const page = await this.listGuideStopsPageByHall(hallId, 1, 1) return [hallId, { hallId, guideStopCount: page.total }] as const } catch (error) { console.warn('讲解展厅统计加载失败:', hallId, error) return [hallId, { hallId, guideStopCount: 0 }] as const } })) return Object.fromEntries(entries) } listGuideStopsPageByHall(hallId: string, pageNo: number, pageSize: number): Promise { return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize) } getHallById(id: string) { return this.explain.getHallById(id) } loadTemporaryBusinessUnitsByHall(hallId: string): Promise { return this.explain.listTemporaryBusinessUnitsByHall(hallId) } async selectHall(hallId: string) { const [hall, units] = await Promise.all([ this.getHallById(hallId), this.loadTemporaryBusinessUnitsByHall(hallId) ]) return { hall, units } } async selectBusinessUnit(hallId: string, unitId: string) { const units = await this.loadTemporaryBusinessUnitsByHall(hallId) return units.find((unit) => unit.id === unitId) || null } async listGuideStopsByBusinessUnit(hallId: string, unitId: string): Promise { return this.explain.listGuideStopsByBusinessUnit(hallId, unitId) } openGuideStopDetail(stopId: string) { const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) return this.enterExplainDetail({ exhibitId: target.targetId, targetType: target.targetType, targetId: target.targetId }) } async listExhibitsByHallId(hallId: string) { return this.explain.listExplainExhibitsByHall(hallId) } searchExplain(keyword?: string): Promise { return this.explain.searchExplain(keyword) } async selectAudioForExhibit(exhibitId: string): Promise { const track = await this.explain.getTrackByExhibitId(exhibitId) const explainExhibits = await this.explain.listExplainExhibits() const summaryExhibit = explainExhibits.find((item) => item.id === exhibitId) const detailExhibit = await this.explain.getExhibitById(exhibitId).catch(() => null) 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 } } 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: '语音服务暂不可用,当前提供图文讲解' } } } async selectAudioForExplainDetail( exhibit: MuseumExhibit, options: { refreshPlayInfo?: boolean } = {} ): Promise { const targetType = exhibit.playTargetType || 'ITEM' const targetId = exhibit.playTargetId || exhibit.id if (!options.refreshPlayInfo && exhibit.audioUrl?.trim()) { return { exhibit, track: null, media: { id: `static-${targetType}-${targetId}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`, type: 'audio', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: exhibit.audioLanguage, available: true }, playable: true } } 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: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage), 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: '语音服务暂不可用,请稍后重试' } } } } export const explainUseCase = new ExplainUseCase()