import { dataSourceConfig } from '@/config/dataSource' import { type AudioPlayTargetType } from '@/domain/museum' import { toGuideAudioPlayInfo, toGuideAudioTextInfo, toGuideStopInfo, type BackendAudioPlayInfo, type BackendAudioTextInfo, type BackendGuideStopInfo, type GuideAudioPlayInfo, type GuideAudioTextInfo, type GuideStopInfo } from '@/data/adapters/guideStopInfoAdapter' export type AudioLanguage = 'zh-CN' | 'en-US' export interface AudioPlayInfoRequest { targetType: AudioPlayTargetType targetId: string lang?: AudioLanguage refresh?: boolean } export interface GuideStopInfoRequest { targetType: AudioPlayTargetType targetId: string lang?: AudioLanguage } interface GuideStopInfoResponse { code: number msg?: string data?: BackendGuideStopInfo } export interface AudioPlayInfo extends GuideAudioPlayInfo {} interface AudioPlayInfoResponse { code: number msg?: string data?: BackendAudioPlayInfo } export interface AudioTextInfoRequest { targetType: AudioPlayTargetType targetId: string lang?: AudioLanguage } export interface AudioTextInfo extends GuideAudioTextInfo {} interface AudioTextInfoResponse { code: number msg?: string data?: BackendAudioTextInfo } export interface AudioPlayInfoRepository { getStopInfo(request: GuideStopInfoRequest): Promise getPlayInfo(request: AudioPlayInfoRequest): Promise getTextInfo(request: AudioTextInfoRequest): Promise clearCache(lang?: AudioLanguage): void } const reasonMessageMap: Record = { NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解', NO_GUIDE_STOP: '该展品暂未配置语音讲解', NO_GUIDE_CONTENT: '该目标暂无讲解内容', NO_TEXT: '当前语言暂无讲解词', UNSUPPORTED_LANGUAGE: '不支持该语言', UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放', TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解', SERVICE_ERROR: '语音服务暂不可用,请稍后重试' } const parseJsonPayload = (payload: unknown): T => { if (typeof payload === 'string') { return JSON.parse(payload) as T } return payload as T } const requestJson = (url: string): Promise => new Promise((resolve, reject) => { uni.request({ url, method: 'GET', timeout: 8000, success: (response) => { const statusCode = Number(response.statusCode || 0) if (statusCode < 200 || statusCode >= 300) { reject(new Error(`语音播放解析接口请求失败: ${statusCode}`)) return } try { resolve(parseJsonPayload(response.data)) } catch (error) { reject(error) } }, fail: reject }) }) const unavailableAudioApiEndpoints = new Set<'stopInfo' | 'playInfo' | 'textInfo'>() const normalizeBaseUrl = (baseUrl: string) => { const trimmed = baseUrl.trim().replace(/\/+$/, '') if (!trimmed || /^https?:\/\//i.test(trimmed) || trimmed.startsWith('/')) { return trimmed } return `/${trimmed}` } const resolveAudioApiBaseUrl = () => { const baseUrl = normalizeBaseUrl(dataSourceConfig.apiBaseUrl) return baseUrl.endsWith('/app-api') ? baseUrl : `${baseUrl}/app-api` } type RequiredAudioPlayInfoRequest = Required> type RequiredGuideStopInfoRequest = Required type RequiredAudioTextInfoRequest = Required const audioKey = ({ targetType, targetId, lang }: RequiredAudioPlayInfoRequest) => ( `${targetType}:${targetId}:${lang}` ) const stopInfoKey = ({ targetType, targetId, lang }: RequiredGuideStopInfoRequest) => ( `${targetType}:${targetId}:${lang}` ) const audioTextKey = ({ targetType, targetId, lang }: RequiredAudioTextInfoRequest) => ( `${targetType}:${targetId}:${lang}` ) const isExpired = (expiresAt?: string | null) => ( Boolean(expiresAt) && new Date(expiresAt as string).getTime() <= Date.now() ) const isMissingRouteResponse = (response: { code: number; msg?: string }) => ( response.code === 404 && /请求地址不存在/.test(response.msg || '') ) const markGuideAudioApiUnavailable = ( endpoint: 'stopInfo' | 'playInfo' | 'textInfo', response: { code: number; msg?: string } ) => { if (isMissingRouteResponse(response)) { unavailableAudioApiEndpoints.add(endpoint) } } export const audioReasonToText = (reason?: string | null) => ( reason ? reasonMessageMap[reason] || '该讲解暂无可播放音频' : '该讲解暂无可播放音频' ) export class DefaultAudioPlayInfoRepository implements AudioPlayInfoRepository { private readonly stopInfoCache = new Map() private readonly cache = new Map() private readonly textCache = new Map() async getStopInfo(request: GuideStopInfoRequest): Promise { if (unavailableAudioApiEndpoints.has('stopInfo')) { throw new Error('讲解展示信息接口暂不可用') } const normalizedRequest: RequiredGuideStopInfoRequest = { targetType: request.targetType, targetId: request.targetId, lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) } const key = stopInfoKey(normalizedRequest) const cached = this.stopInfoCache.get(key) if (cached) { return cached } const params = new URLSearchParams({ targetType: normalizedRequest.targetType, targetId: normalizedRequest.targetId, lang: normalizedRequest.lang }) const url = `${resolveAudioApiBaseUrl()}/gis/guide/stop/info?${params.toString()}` const response = await requestJson(url) if (response.code !== 0 || !response.data) { markGuideAudioApiUnavailable('stopInfo', response) throw new Error(response.msg || '讲解点展示信息加载失败') } const stopInfo = toGuideStopInfo(response.data, normalizedRequest) this.stopInfoCache.set(key, stopInfo) return stopInfo } async getPlayInfo(request: AudioPlayInfoRequest): Promise { const normalizedRequest: RequiredAudioPlayInfoRequest = { targetType: request.targetType, targetId: request.targetId, lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) } const key = audioKey(normalizedRequest) if (unavailableAudioApiEndpoints.has('playInfo')) { return { playable: false, targetType: normalizedRequest.targetType, targetId: normalizedRequest.targetId, lang: normalizedRequest.lang, hasText: false, fallback: false, reason: 'SERVICE_ERROR' } } const cached = this.cache.get(key) if (!request.refresh && cached && !isExpired(cached.expiresAt)) { return cached } const params = new URLSearchParams({ targetType: normalizedRequest.targetType, targetId: normalizedRequest.targetId, lang: normalizedRequest.lang }) const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/play-info?${params.toString()}` const response = await requestJson(url) if (response.code !== 0 || !response.data) { markGuideAudioApiUnavailable('playInfo', response) throw new Error(response.msg || '语音播放解析失败') } const playInfo = toGuideAudioPlayInfo(response.data, normalizedRequest) if (playInfo.playable || playInfo.reason !== 'TARGET_NOT_FOUND') { this.cache.set(key, playInfo) } return playInfo } async getTextInfo(request: AudioTextInfoRequest): Promise { const normalizedRequest: RequiredAudioTextInfoRequest = { targetType: request.targetType, targetId: request.targetId, lang: request.lang || (dataSourceConfig.audioLanguage as AudioLanguage) } const key = audioTextKey(normalizedRequest) if (unavailableAudioApiEndpoints.has('textInfo')) { return { available: false, targetType: normalizedRequest.targetType, targetId: normalizedRequest.targetId, lang: normalizedRequest.lang, reason: 'SERVICE_ERROR' } } const cached = this.textCache.get(key) if (cached) { return cached } const params = new URLSearchParams({ targetType: normalizedRequest.targetType, targetId: normalizedRequest.targetId, lang: normalizedRequest.lang }) const url = `${resolveAudioApiBaseUrl()}/gis/guide/audio/text-info?${params.toString()}` const response = await requestJson(url) if (response.code !== 0 || !response.data) { markGuideAudioApiUnavailable('textInfo', response) throw new Error(response.msg || '讲解词正文解析失败') } const textInfo = toGuideAudioTextInfo(response.data, normalizedRequest) if (textInfo.available) { this.textCache.set(key, textInfo) } return textInfo } clearCache(lang?: AudioLanguage) { if (!lang) { this.stopInfoCache.clear() this.cache.clear() this.textCache.clear() unavailableAudioApiEndpoints.clear() return } Array.from(this.stopInfoCache.keys()).forEach((key) => { if (key.endsWith(`:${lang}`)) { this.stopInfoCache.delete(key) } }) Array.from(this.cache.keys()).forEach((key) => { if (key.endsWith(`:${lang}`)) { this.cache.delete(key) } }) Array.from(this.textCache.keys()).forEach((key) => { if (key.endsWith(`:${lang}`)) { this.textCache.delete(key) } }) } } export const audioPlayInfoRepository = new DefaultAudioPlayInfoRepository()