升级 SDK API 数据契约,补齐 guideStops、业务 POI、诊断与路线数据接口。 修复 SDK 模式下 POI/模型/楼层混用静态数据的问题,并为 ThreeMap 模型加载增加短退避重试。
285 lines
9.7 KiB
TypeScript
285 lines
9.7 KiB
TypeScript
import type {
|
||
GuideRouteReadiness,
|
||
GuideRouteResult,
|
||
GuideRouteTarget
|
||
} from '@/domain/museum'
|
||
import {
|
||
NAV_ROUTE_READINESS
|
||
} from '@/domain/guideReadiness'
|
||
import {
|
||
isIndoorNavigableFloor
|
||
} from '@/domain/guideFloor'
|
||
import {
|
||
GuideRouteError
|
||
} from '@/repositories/GuideRouteRepository'
|
||
import {
|
||
defaultSgsSdkApiProvider,
|
||
type SgsFloorDiagnosticsPayload,
|
||
type SgsNavigablePlacePayload,
|
||
type SgsSdkApiProvider
|
||
} from '@/data/providers/sgsSdkApiProvider'
|
||
import {
|
||
toGuideRouteResultFromSgsPlan
|
||
} from '@/data/adapters/sgsSdkRouteAdapter'
|
||
import { stringifyId, formatSgsFloorLabel } from '@/data/adapters/sgsSdkGuideAdapter'
|
||
|
||
const createUnavailableReadiness = (message: string): GuideRouteReadiness => ({
|
||
ready: false,
|
||
message,
|
||
requiredData: ['route_graph', 'nav_data']
|
||
})
|
||
|
||
const logSdkRouteReadinessIssue = (message: string, payload: Record<string, unknown>) => {
|
||
console.warn(`[SDKRouteReadiness] ${message}`, payload)
|
||
}
|
||
|
||
const toNumber = (value: unknown) => (
|
||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||
)
|
||
|
||
const isPositiveNumber = (value: unknown) => {
|
||
const numberValue = toNumber(value)
|
||
return typeof numberValue === 'number' && numberValue > 0
|
||
}
|
||
|
||
const hasRouteNodeId = (place: SgsNavigablePlacePayload) => (
|
||
place.nodeId !== null && place.nodeId !== undefined && place.nodeId !== ''
|
||
)
|
||
|
||
const hasRouteGraphEvidence = (floor: Pick<SgsFloorDiagnosticsPayload, 'routePlanningReady' | 'routeNodeCount' | 'routeEdgeCount'>) => (
|
||
floor.routePlanningReady === true
|
||
|| (isPositiveNumber(floor.routeNodeCount) && isPositiveNumber(floor.routeEdgeCount))
|
||
)
|
||
|
||
const hasRouteTargetEvidence = (
|
||
floor: Pick<SgsFloorDiagnosticsPayload, 'navigablePlaceCount'>,
|
||
routeTargetCount?: number
|
||
) => {
|
||
if (typeof routeTargetCount === 'number') return routeTargetCount > 0
|
||
return floor.navigablePlaceCount === undefined || isPositiveNumber(floor.navigablePlaceCount)
|
||
}
|
||
|
||
const isRouteReadyFromDiagnostics = (
|
||
floor: Pick<SgsFloorDiagnosticsPayload, 'routePlanningReady' | 'routeNodeCount' | 'routeEdgeCount' | 'navigablePlaceCount'>,
|
||
routeTargetCount?: number
|
||
) => hasRouteGraphEvidence(floor) && hasRouteTargetEvidence(floor, routeTargetCount)
|
||
|
||
const positionFromPlace = (place: SgsNavigablePlacePayload): [number, number, number] | undefined => {
|
||
const source = place.position || {
|
||
x: place.x,
|
||
y: place.y,
|
||
z: place.z
|
||
}
|
||
const x = toNumber(source.x)
|
||
const z = toNumber(source.z)
|
||
|
||
if (typeof x !== 'number' || typeof z !== 'number') return undefined
|
||
|
||
return [
|
||
x,
|
||
toNumber(source.y) || 0,
|
||
z
|
||
]
|
||
}
|
||
|
||
export class SdkGuideRouteRepository {
|
||
private targetsCache: GuideRouteTarget[] | null = null
|
||
|
||
constructor(
|
||
private readonly sdkApiProvider: SgsSdkApiProvider = defaultSgsSdkApiProvider
|
||
) {}
|
||
|
||
async getRouteReadiness(): Promise<GuideRouteReadiness> {
|
||
try {
|
||
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
|
||
|
||
if (!floors.length) {
|
||
return createUnavailableReadiness('SDK 地图楼层数据为空')
|
||
}
|
||
|
||
// 地图级 diagnostics 的楼层摘要只保证 routePlanningReady/navigablePlaceCount;
|
||
// routeNodeCount/routeEdgeCount 可能只出现在 floor diagnostics 或 summary 中。
|
||
let readyFloors = floors.filter((floor) => isRouteReadyFromDiagnostics(floor))
|
||
let notReadyFloors = floors.filter((floor) => !isRouteReadyFromDiagnostics(floor))
|
||
|
||
if (readyFloors.length === 0) {
|
||
const floorEvidence = await Promise.all(
|
||
floors.map((floor) => this.loadFloorRouteEvidence(floor))
|
||
)
|
||
readyFloors = floorEvidence
|
||
.filter((item) => item.ready)
|
||
.map((item) => item.floor)
|
||
notReadyFloors = floorEvidence
|
||
.filter((item) => !item.ready)
|
||
.map((item) => item.floor)
|
||
}
|
||
|
||
// 没有楼层有路线数据时不可用
|
||
if (readyFloors.length === 0) {
|
||
logSdkRouteReadinessIssue('SDK 路线数据未达到位置关系生成条件', {
|
||
floorCount: floors.length,
|
||
readyFloorCount: readyFloors.length,
|
||
summary: diagnostics.summary,
|
||
floors: floors.map((floor) => ({
|
||
floorId: floor.floorId,
|
||
floorName: floor.floorName,
|
||
floorCode: floor.floorCode,
|
||
routePlanningReady: floor.routePlanningReady,
|
||
routeNodeCount: floor.routeNodeCount,
|
||
routeEdgeCount: floor.routeEdgeCount,
|
||
navigablePlaceCount: floor.navigablePlaceCount
|
||
}))
|
||
})
|
||
return createUnavailableReadiness('无法定位,当前仅支持点位位置预览')
|
||
}
|
||
|
||
// 如果有楼层未就绪,给出警告但仍然可用
|
||
if (notReadyFloors.length > 0) {
|
||
const floorLabels = notReadyFloors
|
||
.map((f) => f.floorName || f.floorCode || String(f.floorId))
|
||
.join('、')
|
||
return {
|
||
ready: true,
|
||
message: `部分楼层位置关系数据未就绪:${floorLabels},可查看已就绪楼层的位置关系`,
|
||
requiredData: []
|
||
}
|
||
}
|
||
|
||
return {
|
||
ready: true,
|
||
message: 'SDK 位置关系数据已接入,可进行位置预览',
|
||
requiredData: []
|
||
}
|
||
} catch (error) {
|
||
console.error('[SDKRouteReadiness] 读取 SDK 路线状态失败', error)
|
||
return createUnavailableReadiness(
|
||
error instanceof Error ? error.message : 'SDK 位置关系数据加载失败'
|
||
)
|
||
}
|
||
}
|
||
|
||
private async loadFloorRouteEvidence(floor: SgsFloorDiagnosticsPayload) {
|
||
const rawFloorId = stringifyId(floor.floorId)
|
||
const [floorDiagnostics, navigablePlaces] = await Promise.all([
|
||
this.sdkApiProvider.getFloorDiagnostics(rawFloorId).catch(() => null),
|
||
this.sdkApiProvider.getNavigablePlaces(rawFloorId).catch(() => [])
|
||
])
|
||
const routeTargetCount = navigablePlaces.filter(hasRouteNodeId).length
|
||
const evidence = floorDiagnostics || floor
|
||
|
||
return {
|
||
floor: {
|
||
...floor,
|
||
...floorDiagnostics
|
||
},
|
||
ready: isRouteReadyFromDiagnostics(evidence, routeTargetCount),
|
||
routeTargetCount
|
||
}
|
||
}
|
||
|
||
async listRouteTargets(): Promise<GuideRouteTarget[]> {
|
||
if (this.targetsCache) return this.targetsCache
|
||
|
||
const diagnostics = await this.sdkApiProvider.getMapDiagnostics()
|
||
const floors = (diagnostics.floors || []).filter(isIndoorNavigableFloor)
|
||
|
||
const targets: GuideRouteTarget[] = []
|
||
|
||
for (const floor of floors) {
|
||
// 使用原始 floorId 请求 API(SDK API 需要原始 ID 如 2065808921272119298)
|
||
const rawFloorId = stringifyId(floor.floorId)
|
||
try {
|
||
const places = await this.sdkApiProvider.getNavigablePlaces(rawFloorId)
|
||
|
||
for (const place of places) {
|
||
// 过滤掉没有 nodeId 的点位,这些无法用于路线规划
|
||
if (!hasRouteNodeId(place)) {
|
||
continue
|
||
}
|
||
|
||
const placeId = stringifyId(place.id)
|
||
const placeName = place.name || `目的地 ${placeId}`
|
||
const categoryLabel = place.category || place.typeName || place.typeCode || place.type || undefined
|
||
const positionGltf = positionFromPlace(place)
|
||
|
||
// 使用统一的楼层标签格式化函数,确保与 nav-assets 格式一致
|
||
const floorLabel = formatSgsFloorLabel(
|
||
place.floorCode || undefined,
|
||
place.floorName || floor.floorName || undefined
|
||
)
|
||
|
||
targets.push({
|
||
poiId: placeId,
|
||
name: placeName,
|
||
// 保留后端原始 floorId,确保与 SDK 模型楼层和 ThreeMap 当前楼层一致。
|
||
floorId: stringifyId(place.floorId ?? rawFloorId),
|
||
floorLabel,
|
||
categoryLabel,
|
||
positionGltf,
|
||
// 保存原始 nodeId 用于 API 调用
|
||
routeNodeId: stringifyId(place.nodeId)
|
||
})
|
||
}
|
||
} catch {
|
||
// 忽略单个楼层的加载错误
|
||
}
|
||
}
|
||
|
||
this.targetsCache = targets
|
||
return targets
|
||
}
|
||
|
||
async findRoute(startPoiId: string, endPoiId: string): Promise<GuideRouteResult> {
|
||
const readiness = await this.getRouteReadiness()
|
||
if (!readiness.ready) {
|
||
throw new GuideRouteError(
|
||
'ROUTE_DATA_UNAVAILABLE',
|
||
readiness.message || NAV_ROUTE_READINESS.message
|
||
)
|
||
}
|
||
|
||
if (!startPoiId || !endPoiId) {
|
||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '请选择起点和终点')
|
||
}
|
||
|
||
if (startPoiId === endPoiId) {
|
||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点和终点不能相同')
|
||
}
|
||
|
||
const targets = await this.listRouteTargets()
|
||
const startTarget = targets.find((target) => target.poiId === startPoiId)
|
||
const endTarget = targets.find((target) => target.poiId === endPoiId)
|
||
|
||
if (!startTarget || !endTarget) {
|
||
throw new GuideRouteError('ROUTE_TARGET_MISSING', '起点或终点不在 SDK 可预览目的地列表中')
|
||
}
|
||
|
||
try {
|
||
if (!startTarget.positionGltf || !endTarget.positionGltf) {
|
||
throw new GuideRouteError('ROUTE_TARGET_UNANCHORED', '起点或终点缺少可规划坐标')
|
||
}
|
||
|
||
const route = await this.sdkApiProvider.planRoute({
|
||
startFloorId: startTarget.floorId,
|
||
startX: startTarget.positionGltf[0],
|
||
startY: startTarget.positionGltf[2],
|
||
startNodeId: startTarget.routeNodeId || null,
|
||
endFloorId: endTarget.floorId,
|
||
endX: endTarget.positionGltf[0],
|
||
endY: endTarget.positionGltf[2],
|
||
endNodeId: endTarget.routeNodeId || null,
|
||
wheelchair: 0
|
||
})
|
||
|
||
return toGuideRouteResultFromSgsPlan(route, startTarget, endTarget)
|
||
} catch (error) {
|
||
if (error instanceof GuideRouteError) throw error
|
||
throw new GuideRouteError(
|
||
'ROUTE_NOT_FOUND',
|
||
error instanceof Error ? error.message : 'SGS 位置关系接口生成失败'
|
||
)
|
||
}
|
||
}
|
||
}
|