Files
frontend-miniapp/src/usecases/explainUseCase.ts
lyf 8fed715235
Some checks failed
CI / verify (push) Has been cancelled
chore: sync latest project updates
2026-07-03 14:42:38 +08:00

705 lines
23 KiB
TypeScript

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<Required<GuideStopInfoRequest>> {
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<GuideStopInfoRequest>
): 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<MuseumExhibit> {
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<string, ExplainTrack>()
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<ExplainTextSelection> {
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<MuseumHall[]> {
return this.explain.listHalls()
}
loadExplainHalls(): Promise<MuseumHall[]> {
return this.listHalls()
}
async loadExplainHallSummaries(hallIds: string[]): Promise<Record<string, ExplainHallSummary>> {
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<ExplainBusinessUnit[]> {
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<ExplainGuideStop[]> {
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<SearchIndexItem[]> {
return this.explain.searchExplain(keyword)
}
async selectAudioForExhibit(exhibitId: string): Promise<ExplainAudioSelection | null> {
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<ExplainAudioSelection> {
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()