import type { ExplainBusinessUnit, ExplainGuideStop, ExplainGuideStopPage, ExplainTrack, GuideAudioGender, MediaAsset, MuseumExhibit, MuseumHall, SearchIndexItem } from '@/domain/museum' import { explainRepository, type ExplainRepository } from '@/repositories/ExplainRepository' import { audioPlayInfoRepository, audioReasonToText, type AudioLanguage, type AudioPlayInfoRepository } from '@/repositories/AudioPlayInfoRepository' import type { GuideAudioVersion, GuideStopDetail } from '@/data/adapters/guideStopInfoAdapter' import { dataSourceConfig } from '@/config/dataSource' import { resolveGuideAudioLanguages, resolveGuideAudioOption } from '@/domain/guideAudioOptions' export interface ExplainAudioSelection { exhibit: MuseumExhibit track: ExplainTrack | null media: MediaAsset | null playable: boolean unavailableMessage?: string } export interface ExplainDetailEntryRequest { exhibitId: string stopId?: string lang?: AudioLanguage version?: GuideAudioVersion // 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: { available: boolean lang: AudioLanguage text?: string textLength?: number textHash?: string } 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 resolveDetailStopId(request: ExplainDetailEntryRequest) { return String(request.stopId || request.exhibitId || '').trim() } private async resolveStaticDetailFallback( request: ExplainDetailEntryRequest, entryTarget: { stopId: string } ): Promise { const candidateIds = Array.from(new Set([ request.exhibitId, entryTarget.stopId ].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 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 toExhibitFromStopDetail( detail: GuideStopDetail, fallback?: MuseumExhibit | null, navigationContext?: ExplainDetailEntryRequest, language: AudioLanguage = 'zh-CN', gender: GuideAudioGender = 'female' ): MuseumExhibit { 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( currentVersionTracks, currentVersionTracks.map((track) => track.languageCode) ) return { ...(fallback || {}), id: detail.id, name: detail.name || fallback?.name || linkedPrimary?.name || '讲解内容', hallId: fallback?.hallId || navigationContext?.hallId, hallName: fallback?.hallName || navigationContext?.hallName, floorId: detail.floorId || fallback?.floorId || navigationContext?.floorId, floorLabel: fallback?.floorLabel || navigationContext?.floorLabel, 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: 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' : 'MISSING', supportedLanguages, 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 async enrichExhibitAudio(exhibit: MuseumExhibit): Promise { if (exhibit.audioOptions?.length || !exhibit.id) return exhibit try { const detail = await this.audioPlayInfo.getStopDetail(exhibit.resolvedStopId || exhibit.id, { version: 'standard' }) return this.toExhibitFromStopDetail(detail, exhibit) } catch { return exhibit } } 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(this.applyTrackAudioState(exhibit, 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 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') { return this.toExhibitFromStopDetail(detail, null, request, language) } const fallbackExhibit = await this.resolveStaticDetailFallback(request, { stopId }) return this.toExhibitFromStopDetail(detail, fallbackExhibit, request, language) } async loadExplainDetailText(exhibit: MuseumExhibit): Promise { const lang = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) 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') } } 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) { return this.enterExplainDetail({ exhibitId: stopId, stopId }) } 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 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') } } 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: { voiceGender?: GuideAudioGender } = {} ): Promise { const language = (exhibit.audioLanguage as AudioLanguage) || (dataSourceConfig.audioLanguage as AudioLanguage) 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 = { ...exhibit, audioUrl: audioOption.playUrl, audioDuration: audioOption.duration, audioLanguage: language, audioAvailable: true, audioStatus: 'READY' } return { exhibit: audioExhibit, track: null, media: { id: `channel-${audioOption.channelCode}`, type: 'audio', url: audioOption.playUrl, duration: audioOption.duration, language, available: true }, playable: true } } if (exhibit.audioUrl?.trim()) { return { exhibit, track: null, media: { id: `static-${exhibit.resolvedStopId || exhibit.id}-${exhibit.audioLanguage || dataSourceConfig.audioLanguage}`, type: 'audio', url: exhibit.audioUrl, duration: exhibit.audioDuration, language: exhibit.audioLanguage, available: true }, playable: true } } return { exhibit, track: null, media: null, playable: false, unavailableMessage: audioReasonToText('NO_PUBLISHED_AUDIO') } } } export const explainUseCase = new ExplainUseCase()