import type { AudioPlayTargetType, ExplainBusinessUnit, ExplainGuideStop, ExplainTrack, MediaAsset, MuseumExhibit, MuseumHall, SearchIndexItem } from '@/domain/museum' import { explainRepository, type ExplainRepository } from '@/repositories/ExplainRepository' import { mediaRepository, type MediaRepository } from '@/repositories/MediaRepository' import { audioPlayInfoRepository, audioReasonToText, type AudioLanguage, type AudioPlayInfo, type AudioPlayInfoRepository, type GuideStopInfoRequest, type AudioTextInfo } from '@/repositories/AudioPlayInfoRepository' import type { GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter' import { publishedExhibitAudioRepository, type PublishedExhibitAudioRepository } from '@/repositories/PublishedExhibitAudioRepository' 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 } export interface ExplainTextSelection { exhibit: MuseumExhibit textInfo: AudioTextInfo available: boolean unavailableMessage?: string } export interface ExhibitDetailAudioOptions { enrichAudio?: boolean includeText?: boolean } export interface ExplainHallSummary { hallId: string businessUnitCount: number guideStopCount: number } export class ExplainUseCase { constructor( private readonly explain: ExplainRepository = explainRepository, private readonly media: MediaRepository = mediaRepository, private readonly audioPlayInfo: AudioPlayInfoRepository = audioPlayInfoRepository, private readonly publishedAudio: PublishedExhibitAudioRepository = publishedExhibitAudioRepository ) {} 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 = 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 toExhibitFromStopInfo( stopInfo: GuideStopInfo, fallback?: MuseumExhibit | null ): MuseumExhibit { const linkedPrimary = stopInfo.linkedExhibits[0] const coverImage = stopInfo.coverImageUrl || fallback?.image const description = stopInfo.description || fallback?.description || '该讲解暂无简介。' const audioTarget = this.resolveStopInfoAudioTarget(stopInfo) const audioAvailable = stopInfo.audioStatus === 'READY' return { ...(fallback || {}), id: fallback?.id || linkedPrimary?.id || stopInfo.targetId, name: stopInfo.title || fallback?.name || linkedPrimary?.name || '讲解内容', hallId: fallback?.hallId, hallName: fallback?.hallName, floorId: fallback?.floorId, floorLabel: fallback?.floorLabel, image: coverImage, description, poiId: fallback?.poiId, sourcePoiId: fallback?.sourcePoiId, 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, audioUrl: undefined, audioDuration: undefined, 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)), audioAvailable, audioStatus: stopInfo.audioStatus, supportedLanguages: stopInfo.supportedLanguages, imageStatus: stopInfo.imageStatus, imageSource: stopInfo.imageSource, galleryUrls: stopInfo.galleryUrls, linkedExhibits: stopInfo.linkedExhibits, stopInfoAvailable: stopInfo.available, stopInfoReason: stopInfo.reason, resolvedStopId: stopInfo.resolvedStopId, playTargetType: audioTarget.targetType, playTargetId: audioTarget.targetId } } private toExhibitFromUnavailableStopInfo( fallback: MuseumExhibit, entryTarget: Required ): MuseumExhibit { return { ...fallback, guideText: fallback.guideText || fallback.description, audioUrl: undefined, audioDuration: undefined, audioLanguage: entryTarget.lang, audioHasText: false, audioText: undefined, audioTextLength: undefined, audioTextHash: undefined, audioNarrationTier: undefined, audioUnavailableReason: '讲解详情服务暂不可用,当前提供图文讲解', audioAvailable: false, audioStatus: 'SERVICE_ERROR', stopInfoAvailable: false, stopInfoReason: 'SERVICE_ERROR', playTargetType: entryTarget.targetType, playTargetId: entryTarget.targetId } } private applyPlayInfo( exhibit: MuseumExhibit, playInfo: AudioPlayInfo, options: { preserveStaticFallback?: boolean } = {} ): MuseumExhibit { const apiPlayable = playInfo.playable === true && Boolean(playInfo.playUrl) const canUseStaticAudioFallback = !playInfo.reason || playInfo.reason === 'TARGET_NOT_FOUND' const fallbackAudioUrl = options.preserveStaticFallback && canUseStaticAudioFallback && exhibit.audioUrl ? exhibit.audioUrl : undefined const nextAudioUrl = apiPlayable ? playInfo.playUrl || undefined : fallbackAudioUrl 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 || Boolean(fallbackAudioUrl) } } 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 preserveStaticFallback?: 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, { preserveStaticFallback: options.preserveStaticFallback }) 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, preserveStaticFallback: true, 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) const shouldLoadFallbackExhibit = entryTarget.targetType === 'ITEM' || entryTarget.targetType === 'STOP' const [stopInfoResult, fallbackExhibit] = await Promise.all([ this.audioPlayInfo.getStopInfo(entryTarget) .then((stopInfo) => ({ stopInfo, error: null })) .catch((error) => ({ stopInfo: null, error })), shouldLoadFallbackExhibit ? this.explain.getExhibitById(request.exhibitId) .catch(() => this.explain.listExplainExhibits() .then((items) => items.find((item) => item.id === request.exhibitId) || null) .catch(() => null)) : Promise.resolve(null) ]) if (stopInfoResult.stopInfo) { return this.toExhibitFromStopInfo(stopInfoResult.stopInfo, fallbackExhibit) } if (fallbackExhibit) { return this.toExhibitFromUnavailableStopInfo(fallbackExhibit, entryTarget) } throw stopInfoResult.error || new Error('讲解详情加载失败') } async loadExplainDetailText(exhibit: MuseumExhibit): Promise { const targetType = exhibit.playTargetType || 'ITEM' const targetId = exhibit.playTargetId || exhibit.id try { const textInfo = await this.audioPlayInfo.getTextInfo({ targetType, targetId, lang: (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) }) 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: dataSourceConfig.audioLanguage as AudioLanguage, 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 entries = await Promise.all(uniqueHallIds.map(async (hallId) => { try { const units = await this.loadTemporaryBusinessUnitsByHall(hallId) const guideStopCount = units.reduce((total, unit) => total + unit.guideStopCount, 0) return [hallId, { hallId, businessUnitCount: units.length, guideStopCount }] as const } catch (error) { console.warn('讲解展厅统计加载失败:', hallId, error) return [hallId, { hallId, businessUnitCount: 0, guideStopCount: 0 }] as const } })) return Object.fromEntries(entries) } 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 { const unit = await this.selectBusinessUnit(hallId, unitId) return unit?.stops || [] } openGuideStopDetail(stopId: string) { const target = normalizeExplainDetailTargetFromGuideStop({ id: stopId }) return this.enterExplainDetail({ exhibitId: target.targetId, targetType: target.targetType, targetId: target.targetId }) } async listExhibitsByHallId(hallId: string) { const exhibits = await this.listExhibits() return exhibits.filter((exhibit) => exhibit.hallId === 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 fallbackMedia = await (track?.mediaId ? this.media.getMediaById(track.mediaId) : exhibit.guideContentId ? this.media.getMediaById(`media-${exhibit.guideContentId}`) : Promise.resolve(null) ).catch((error) => { console.warn('静态讲解媒体读取失败,将继续尝试播放接口:', error) 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 : fallbackMedia?.duration, language: playInfo.lang, available: true } return { exhibit, track, media, playable: true, playInfo } } const toDirectMediaSelection = (playInfo: AudioPlayInfo): ExplainAudioSelection | null => { const directMedia = fallbackMedia?.available && fallbackMedia.url ? fallbackMedia : exhibit.audioUrl ? { id: `media-${exhibit.guideContentId || exhibit.id}`, type: 'audio' as const, url: exhibit.audioUrl, duration: exhibit.audioDuration, language: playInfo.lang, available: true } : null if (!directMedia?.url) return null return { exhibit, track, media: directMedia, playable: true, playInfo: { ...playInfo, playable: true, audioId: directMedia.id, title: track?.title || exhibit.guideTitle || exhibit.name, duration: directMedia.duration || playInfo.duration, playUrl: directMedia.url, fallback: true, fallbackReason: playInfo.reason || 'DIRECT_MEDIA_FALLBACK' } } } const directMediaSelection = toDirectMediaSelection({ playable: false, targetType, targetId, lang: dataSourceConfig.audioLanguage as AudioPlayInfo['lang'], reason: 'DIRECT_MEDIA_FALLBACK' }) if (directMediaSelection) return directMediaSelection try { const initialPlayInfo = await this.audioPlayInfo.getPlayInfo({ targetType, targetId }) if (initialPlayInfo.playable && initialPlayInfo.playUrl) { return toPlayableSelection(initialPlayInfo, targetType, targetId) } if (initialPlayInfo.reason === 'TARGET_NOT_FOUND') { const publishedResolution = await this.publishedAudio.resolveByExhibitName(exhibit.name, initialPlayInfo.lang) if (publishedResolution?.targets.length) { for (const candidate of publishedResolution.targets) { if (candidate.targetType === targetType && candidate.targetId === targetId) continue const playInfo = await this.audioPlayInfo.getPlayInfo(candidate) if (playInfo.playable && playInfo.playUrl) { return toPlayableSelection(playInfo, candidate.targetType, candidate.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'], reason: 'SERVICE_ERROR' } const directMediaSelection = toDirectMediaSelection(serviceErrorPlayInfo) if (directMediaSelection) return directMediaSelection return { exhibit, track, media: null, playable: false, playInfo: serviceErrorPlayInfo, unavailableMessage: '语音服务暂不可用,当前提供图文讲解' } } } async selectAudioForExplainDetail(exhibit: MuseumExhibit): Promise { const targetType = exhibit.playTargetType || 'ITEM' const targetId = exhibit.playTargetId || exhibit.id 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) }) 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()