Files
frontend-miniapp/src/usecases/explainUseCase.ts
lyf 130f155bdf
Some checks failed
CI / verify (push) Has been cancelled
接入编号讲解后端查询
- 使用真实讲解编号解析接口替换本地演示映射
- 成功后复用现有详情页并自动播放讲解
- 增加查询状态、业务错误提示和相关测试

Made-with: Proma
2026-08-25 18:01:20 +08:00

496 lines
17 KiB
TypeScript

import type {
AudioPlayTargetType,
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
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
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 interface ExplainCodeResolution {
code: string
stopId: string
}
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.targetId || request.exhibitId || '').trim()
}
private async resolveStaticDetailFallback(
request: ExplainDetailEntryRequest,
entryTarget: { stopId: string }
): Promise<MuseumExhibit | null> {
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<MuseumExhibit> {
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<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(this.applyTrackAudioState(exhibit, track))
} catch (error) {
console.warn('讲解详情音频状态加载失败,将使用展项原始音频状态:', error)
return exhibit
}
}
async enrichExhibitDetailAudio(id: string) {
return this.getExhibitById(id, {
enrichAudio: true,
includeText: true
})
}
async resolveExplainCode(code: string, lang: AudioLanguage = dataSourceConfig.audioLanguage as AudioLanguage): Promise<ExplainCodeResolution> {
const stopInfo = await this.audioPlayInfo.getStopInfoByCode(code, lang)
const stopId = stopInfo.resolvedStopId || stopInfo.playTargetId || stopInfo.targetId
if (!stopInfo.available || !stopId) {
throw new Error('该编号的讲解内容暂不可用')
}
return { code: code.trim(), stopId }
}
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<ExplainTextSelection> {
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<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 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<ExplainGuideStopPage> {
return this.explain.listGuideStopsPageByHall(hallId, pageNo, pageSize)
}
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[]> {
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<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 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<ExplainAudioSelection> {
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()