refactor: 重构数据层支持 SGS SDK 集成
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -4,6 +4,18 @@ import type {
|
||||
GuideModelSource,
|
||||
GuideRenderPoi
|
||||
} from '@/domain/guideModel'
|
||||
import {
|
||||
dataSourceConfig
|
||||
} from '@/config/dataSource'
|
||||
import {
|
||||
formatSgsFloorLabel,
|
||||
toMuseumPoiFromSgs
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
import {
|
||||
defaultSgsSdkApiProvider,
|
||||
type SgsSdkApiProvider,
|
||||
type SgsSdkFloorSummaryPayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import {
|
||||
formatNavFloorLabel
|
||||
} from '@/data/adapters/navAssetsAdapter'
|
||||
@@ -24,6 +36,38 @@ const toRenderPoi = (poi: StaticNavPoiPayload): GuideRenderPoi => ({
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
})
|
||||
|
||||
const toSgsRenderPoi = (
|
||||
poi: ReturnType<typeof toMuseumPoiFromSgs>
|
||||
): GuideRenderPoi => ({
|
||||
id: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
primaryCategory: poi.primaryCategory.id,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
iconType: poi.primaryCategory.iconType || poi.primaryCategory.id,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
})
|
||||
|
||||
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
|
||||
|
||||
const resolveSgsAssetUrl = (url?: string | null) => {
|
||||
const normalizedUrl = url?.trim()
|
||||
if (!normalizedUrl) return ''
|
||||
if (/^https?:\/\//i.test(normalizedUrl)) return normalizedUrl
|
||||
if (!normalizedUrl.startsWith('/')) return normalizedUrl
|
||||
|
||||
const baseUrl = trimTrailingSlash(dataSourceConfig.sgsApiBaseUrl || dataSourceConfig.apiBaseUrl || '')
|
||||
const origin = baseUrl.endsWith('/app-api')
|
||||
? baseUrl.slice(0, -'/app-api'.length)
|
||||
: baseUrl
|
||||
|
||||
return origin ? `${origin}${normalizedUrl}` : normalizedUrl
|
||||
}
|
||||
|
||||
const sortSgsFloors = (floors: SgsSdkFloorSummaryPayload[]) => [...floors]
|
||||
.sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0))
|
||||
|
||||
export interface GuideModelRepository extends GuideModelSource {}
|
||||
|
||||
export class StaticGuideModelRepository implements GuideModelRepository {
|
||||
@@ -63,4 +107,67 @@ export class StaticGuideModelRepository implements GuideModelRepository {
|
||||
}
|
||||
}
|
||||
|
||||
export const guideModelRepository = new StaticGuideModelRepository()
|
||||
export class SgsSdkGuideModelRepository implements GuideModelRepository {
|
||||
private floorsCache: SgsSdkFloorSummaryPayload[] | null = null
|
||||
|
||||
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
|
||||
|
||||
async loadPackage(): Promise<GuideModelRenderPackage> {
|
||||
const manifest = await this.provider.getManifest()
|
||||
const floors = sortSgsFloors(manifest.floors)
|
||||
this.floorsCache = floors
|
||||
|
||||
const bundles = await Promise.all(
|
||||
floors.map((floor) => this.provider.getFloorBundle(String(floor.floorId)))
|
||||
)
|
||||
|
||||
const floorAssets: GuideModelFloorAsset[] = floors
|
||||
.map((floor, index) => {
|
||||
const bundle = bundles[index]
|
||||
const modelUrl = resolveSgsAssetUrl(bundle.model?.modelUrl || bundle.model?.fallbackModelUrl)
|
||||
|
||||
return {
|
||||
floorId: String(floor.floorId),
|
||||
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
|
||||
order: Number(floor.sortOrder || 0),
|
||||
modelUrl,
|
||||
sharedModelAsset: false
|
||||
}
|
||||
})
|
||||
.filter((asset) => asset.modelUrl)
|
||||
|
||||
const overviewFloorIndex = floors.findIndex((floor) => floor.floorCode === 'EXTERIOR')
|
||||
const overviewModelUrl = floorAssets[overviewFloorIndex]?.modelUrl || floorAssets[0]?.modelUrl || ''
|
||||
|
||||
return {
|
||||
overviewModelUrl,
|
||||
floors: floorAssets
|
||||
}
|
||||
}
|
||||
|
||||
async loadFloorPois(floorId: string) {
|
||||
const manifest = await this.provider.getManifest()
|
||||
this.floorsCache = sortSgsFloors(manifest.floors)
|
||||
const matchedFloor = this.floorsCache.find((floor) => (
|
||||
String(floor.floorId) === floorId
|
||||
|| floor.floorCode === floorId
|
||||
|| formatSgsFloorLabel(floor.floorCode, floor.floorName) === floorId
|
||||
))
|
||||
if (!matchedFloor) return []
|
||||
|
||||
const pois = await this.provider.getFloorPois(String(matchedFloor.floorId))
|
||||
return pois
|
||||
.map((poi) => toSgsRenderPoi(toMuseumPoiFromSgs(poi, manifest.floors)))
|
||||
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
|
||||
}
|
||||
}
|
||||
|
||||
export const createGuideModelRepository = (): GuideModelRepository => {
|
||||
if (dataSourceConfig.mode === 'api' || dataSourceConfig.mode === 'sdk') {
|
||||
return new SgsSdkGuideModelRepository()
|
||||
}
|
||||
|
||||
return new StaticGuideModelRepository()
|
||||
}
|
||||
|
||||
export const guideModelRepository = createGuideModelRepository()
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
import type {
|
||||
GuideDataIntegrityReport,
|
||||
GuideFloorDetail,
|
||||
GuideLocationPreview,
|
||||
GuideMapDiagnostics,
|
||||
GuideRouteReadiness,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
@@ -16,6 +19,20 @@ import {
|
||||
defaultStaticNavAssetsProvider,
|
||||
type StaticNavAssetsProvider
|
||||
} from '@/data/providers/staticNavAssetsProvider'
|
||||
import {
|
||||
defaultSgsSdkApiProvider,
|
||||
type SgsSdkApiProvider
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
import {
|
||||
buildSgsFloorAliases,
|
||||
createGuideDataIntegrityReport,
|
||||
toGuideFloorDetailFromSgs,
|
||||
toGuideMapDiagnostics,
|
||||
toLocationPreviewFromPoi,
|
||||
toMuseumFloorFromSgs,
|
||||
toMuseumPoiFromSgs,
|
||||
toRouteReadinessFromSgsDiagnostics
|
||||
} from '@/data/adapters/sgsSdkGuideAdapter'
|
||||
|
||||
const searchableCategories = new Set([
|
||||
'touring_poi',
|
||||
@@ -60,6 +77,9 @@ export interface GuideRepository {
|
||||
searchPois(keyword?: string): Promise<MuseumPoi[]>
|
||||
getLocationPreview(poiId: string): Promise<GuideLocationPreview | null>
|
||||
getRouteReadiness(): Promise<GuideRouteReadiness>
|
||||
getFloorDetail?(floorId: string): Promise<GuideFloorDetail | null>
|
||||
getMapDiagnostics?(): Promise<GuideMapDiagnostics>
|
||||
checkDataIntegrity?(): Promise<GuideDataIntegrityReport>
|
||||
}
|
||||
|
||||
export class StaticGuideRepository implements GuideRepository {
|
||||
@@ -120,6 +140,161 @@ export class StaticGuideRepository implements GuideRepository {
|
||||
return this.provider.loadRouteReadiness()
|
||||
.catch(() => NAV_ROUTE_READINESS)
|
||||
}
|
||||
|
||||
async getFloorDetail(floorId: string) {
|
||||
const floors = await this.getFloors()
|
||||
const normalizedFloorId = this.normalizeFloorId(floorId)
|
||||
const floor = floors.find((item) => item.id === normalizedFloorId)
|
||||
return floor
|
||||
? {
|
||||
...floor,
|
||||
warnings: []
|
||||
}
|
||||
: null
|
||||
}
|
||||
|
||||
async getMapDiagnostics(): Promise<GuideMapDiagnostics> {
|
||||
const [floors, pois] = await Promise.all([
|
||||
this.getFloors(),
|
||||
this.listPois()
|
||||
])
|
||||
|
||||
return {
|
||||
mapId: 'static',
|
||||
mapName: '本地静态导览数据',
|
||||
status: 'WARN',
|
||||
floors: floors.map((floor) => ({
|
||||
...floor,
|
||||
poiCount: pois.filter((poi) => poi.floorId === floor.id).length,
|
||||
warnings: []
|
||||
})),
|
||||
summary: {
|
||||
floorCount: floors.length,
|
||||
modelReadyFloorCount: floors.length,
|
||||
poiCount: pois.length,
|
||||
spaceCount: 0,
|
||||
guideStopCount: 0,
|
||||
navigablePlaceCount: 0,
|
||||
routeNodeCount: 0,
|
||||
routeEdgeCount: 0
|
||||
},
|
||||
warnings: ['static 模式仅提供本地导览资源基础检查']
|
||||
}
|
||||
}
|
||||
|
||||
async checkDataIntegrity() {
|
||||
const [floors, pois, diagnostics] = await Promise.all([
|
||||
this.getFloors(),
|
||||
this.listPois(),
|
||||
this.getMapDiagnostics()
|
||||
])
|
||||
|
||||
return createGuideDataIntegrityReport(diagnostics, floors, pois)
|
||||
}
|
||||
}
|
||||
|
||||
export const guideRepository = new StaticGuideRepository()
|
||||
export class SgsSdkGuideRepository implements GuideRepository {
|
||||
private floorsCache: MuseumFloor[] | null = null
|
||||
private poiCache: MuseumPoi[] | null = null
|
||||
private floorAliases: Map<string, string> | null = null
|
||||
|
||||
constructor(private readonly provider: SgsSdkApiProvider = defaultSgsSdkApiProvider) {}
|
||||
|
||||
getAssetBaseUrl() {
|
||||
return ''
|
||||
}
|
||||
|
||||
async getFloors() {
|
||||
if (this.floorsCache) return this.floorsCache
|
||||
|
||||
const manifest = await this.provider.getManifest()
|
||||
this.floorAliases = buildSgsFloorAliases(manifest.floors)
|
||||
this.floorsCache = [...manifest.floors]
|
||||
.sort((a, b) => Number(a.sortOrder || 0) - Number(b.sortOrder || 0))
|
||||
.map(toMuseumFloorFromSgs)
|
||||
|
||||
return this.floorsCache
|
||||
}
|
||||
|
||||
normalizeFloorId(labelOrId: string) {
|
||||
const normalized = labelOrId.trim()
|
||||
const aliases = this.floorAliases
|
||||
if (!aliases) return normalized
|
||||
|
||||
return aliases.get(normalized) || aliases.get(normalized.toLowerCase()) || normalized
|
||||
}
|
||||
|
||||
async listPois() {
|
||||
if (this.poiCache) return this.poiCache
|
||||
|
||||
const manifest = await this.provider.getManifest()
|
||||
this.floorAliases = buildSgsFloorAliases(manifest.floors)
|
||||
const poiGroups = await Promise.all(
|
||||
manifest.floors.map((floor) => this.provider.getFloorPois(String(floor.floorId)))
|
||||
)
|
||||
|
||||
this.poiCache = poiGroups
|
||||
.flat()
|
||||
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
|
||||
.filter((poi) => poi.id && poi.name)
|
||||
|
||||
return this.poiCache
|
||||
}
|
||||
|
||||
async getPoiById(id: string) {
|
||||
const pois = await this.listPois()
|
||||
return pois.find((poi) => poi.id === id) || null
|
||||
}
|
||||
|
||||
async searchPois(keyword = '') {
|
||||
const pois = await this.listPois()
|
||||
const normalizedKeyword = keyword.trim().toLowerCase()
|
||||
|
||||
if (!normalizedKeyword) return pois
|
||||
|
||||
return pois.filter((poi) => toSearchText(poi).includes(normalizedKeyword))
|
||||
}
|
||||
|
||||
async getLocationPreview(poiId: string) {
|
||||
const poi = await this.getPoiById(poiId)
|
||||
return poi ? toLocationPreviewFromPoi(poi) : null
|
||||
}
|
||||
|
||||
async getRouteReadiness() {
|
||||
return this.provider.getMapDiagnostics()
|
||||
.then(toRouteReadinessFromSgsDiagnostics)
|
||||
.catch(() => NAV_ROUTE_READINESS)
|
||||
}
|
||||
|
||||
async getFloorDetail(floorId: string) {
|
||||
await this.getFloors()
|
||||
const normalizedFloorId = this.normalizeFloorId(floorId)
|
||||
try {
|
||||
const diagnostics = await this.provider.getFloorDiagnostics(normalizedFloorId)
|
||||
return toGuideFloorDetailFromSgs(diagnostics)
|
||||
} catch {
|
||||
const manifest = await this.provider.getManifest()
|
||||
const floor = manifest.floors.find((item) => String(item.floorId) === normalizedFloorId)
|
||||
return floor ? toGuideFloorDetailFromSgs(floor) : null
|
||||
}
|
||||
}
|
||||
|
||||
async getMapDiagnostics() {
|
||||
const [manifest, diagnostics] = await Promise.all([
|
||||
this.provider.getManifest(),
|
||||
this.provider.getMapDiagnostics()
|
||||
])
|
||||
|
||||
return toGuideMapDiagnostics(diagnostics, manifest)
|
||||
}
|
||||
|
||||
async checkDataIntegrity() {
|
||||
const [floors, pois, diagnostics] = await Promise.all([
|
||||
this.getFloors(),
|
||||
this.listPois(),
|
||||
this.getMapDiagnostics()
|
||||
])
|
||||
|
||||
return createGuideDataIntegrityReport(diagnostics, floors, pois)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,9 +5,11 @@ import type {
|
||||
SearchIndexItem
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
guideRepository,
|
||||
type GuideRepository
|
||||
} from '@/repositories/GuideRepository'
|
||||
import {
|
||||
guideRepository
|
||||
} from '@/repositories/createGuideRepository'
|
||||
import {
|
||||
staticMuseumContentProvider,
|
||||
type MuseumContentProvider
|
||||
|
||||
Reference in New Issue
Block a user