Some checks failed
CI / verify (push) Has been cancelled
- 详情页路由入参统一为 stopId,废弃 targetType/targetId - ExplainDetailEntryRequest 与 GlobalAudioSource 移除 targetType/targetId 字段 - 播放器源匹配仅按 stopId 判定,移除 targetType 兜底 - 删除 explainDetailTarget 死代码 - 清理 guideStopInfoAdapter 中 stop/info、play-info、text-info 旧契约类型与转换函数 - 补充测试服务器 Nginx SSL 部署手册 - 同步更新单测与 e2e 用例 Made-with: Proma
479 lines
17 KiB
TypeScript
479 lines
17 KiB
TypeScript
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<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 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()
|