322 lines
9.5 KiB
TypeScript
322 lines
9.5 KiB
TypeScript
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<GuideStopInfo>
|
|
getPlayInfo(request: AudioPlayInfoRequest): Promise<AudioPlayInfo>
|
|
getTextInfo(request: AudioTextInfoRequest): Promise<AudioTextInfo>
|
|
clearCache(lang?: AudioLanguage): void
|
|
}
|
|
|
|
const reasonMessageMap: Record<string, string> = {
|
|
NO_PUBLISHED_AUDIO: '当前语言暂无语音讲解',
|
|
NO_GUIDE_STOP: '该展品暂未配置语音讲解',
|
|
NO_GUIDE_CONTENT: '该目标暂无讲解内容',
|
|
NO_TEXT: '当前语言暂无讲解词',
|
|
UNSUPPORTED_LANGUAGE: '不支持该语言',
|
|
UNSUPPORTED_TARGET_TYPE: '该目标类型暂不支持播放',
|
|
TARGET_NOT_FOUND: '该讲解音频暂不可用,当前提供图文讲解',
|
|
SERVICE_ERROR: '语音服务暂不可用,请稍后重试'
|
|
}
|
|
|
|
const parseJsonPayload = <T>(payload: unknown): T => {
|
|
if (typeof payload === 'string') {
|
|
return JSON.parse(payload) as T
|
|
}
|
|
|
|
return payload as T
|
|
}
|
|
|
|
const requestJson = <T>(url: string): Promise<T> => 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<T>(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<Omit<AudioPlayInfoRequest, 'refresh'>>
|
|
type RequiredGuideStopInfoRequest = Required<GuideStopInfoRequest>
|
|
type RequiredAudioTextInfoRequest = Required<AudioTextInfoRequest>
|
|
|
|
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<string, GuideStopInfo>()
|
|
private readonly cache = new Map<string, AudioPlayInfo>()
|
|
private readonly textCache = new Map<string, AudioTextInfo>()
|
|
|
|
async getStopInfo(request: GuideStopInfoRequest): Promise<GuideStopInfo> {
|
|
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<GuideStopInfoResponse>(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<AudioPlayInfo> {
|
|
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<AudioPlayInfoResponse>(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<AudioTextInfo> {
|
|
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<AudioTextInfoResponse>(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()
|