feat: 新增 SGS SDK 数据适配器与腾讯地图服务
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
415
src/data/adapters/sgsSdkGuideAdapter.ts
Normal file
@@ -0,0 +1,415 @@
|
||||
import type {
|
||||
GuideDataIntegrityIssue,
|
||||
GuideDataIntegrityReport,
|
||||
GuideDiagnosticsStatus,
|
||||
GuideFloorDetail,
|
||||
GuideLocationPreview,
|
||||
GuideMapDiagnostics,
|
||||
GuideRouteReadiness,
|
||||
MuseumCategory,
|
||||
MuseumFloor,
|
||||
MuseumPoi
|
||||
} from '@/domain/museum'
|
||||
import {
|
||||
NAV_ROUTE_UNAVAILABLE_MESSAGE
|
||||
} from '@/domain/guideReadiness'
|
||||
import type {
|
||||
SgsFloorDiagnosticsPayload,
|
||||
SgsMapDiagnosticsPayload,
|
||||
SgsPoiPayload,
|
||||
SgsPositionPayload,
|
||||
SgsSdkFloorSummaryPayload,
|
||||
SgsSdkManifestPayload
|
||||
} from '@/data/providers/sgsSdkApiProvider'
|
||||
|
||||
const defaultCategory: MuseumCategory = {
|
||||
id: 'operation_experience',
|
||||
label: '导览点位',
|
||||
iconType: 'poi'
|
||||
}
|
||||
|
||||
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
|
||||
toilet: {
|
||||
id: 'basic_service_facility',
|
||||
label: '卫生间',
|
||||
iconType: 'toilet'
|
||||
},
|
||||
accessible_toilet: {
|
||||
id: 'accessibility_special_service',
|
||||
label: '无障碍卫生间',
|
||||
iconType: 'accessible_toilet',
|
||||
accessible: true
|
||||
},
|
||||
elevator: {
|
||||
id: 'transport_circulation',
|
||||
label: '电梯',
|
||||
iconType: 'elevator',
|
||||
accessible: true
|
||||
},
|
||||
stairs: {
|
||||
id: 'transport_circulation',
|
||||
label: '楼梯',
|
||||
iconType: 'stairs'
|
||||
},
|
||||
escalator: {
|
||||
id: 'transport_circulation',
|
||||
label: '扶梯',
|
||||
iconType: 'escalator'
|
||||
},
|
||||
entrance_exit: {
|
||||
id: 'transport_circulation',
|
||||
label: '出入口',
|
||||
iconType: 'entrance_exit'
|
||||
},
|
||||
service_desk: {
|
||||
id: 'basic_service_facility',
|
||||
label: '服务台',
|
||||
iconType: 'service_desk'
|
||||
},
|
||||
mother_baby_room: {
|
||||
id: 'basic_service_facility',
|
||||
label: '母婴室',
|
||||
iconType: 'mother_baby_room',
|
||||
accessible: true
|
||||
},
|
||||
locker: {
|
||||
id: 'basic_service_facility',
|
||||
label: '存包处',
|
||||
iconType: 'locker'
|
||||
},
|
||||
rental_service: {
|
||||
id: 'basic_service_facility',
|
||||
label: '租赁服务',
|
||||
iconType: 'rental_service',
|
||||
accessible: true
|
||||
},
|
||||
ticket_office: {
|
||||
id: 'basic_service_facility',
|
||||
label: '售票处',
|
||||
iconType: 'ticket_office'
|
||||
}
|
||||
}
|
||||
|
||||
export const stringifyId = (value: string | number | null | undefined) => (
|
||||
value === null || typeof value === 'undefined' ? '' : String(value)
|
||||
)
|
||||
|
||||
const toNumber = (value: number | null | undefined, fallback = 0) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : fallback
|
||||
)
|
||||
|
||||
const optionalNumber = (value: number | null | undefined) => (
|
||||
Number.isFinite(Number(value)) ? Number(value) : undefined
|
||||
)
|
||||
|
||||
const normalizeStatus = (status?: string): GuideDiagnosticsStatus => {
|
||||
if (status === 'OK' || status === 'WARN' || status === 'ERROR') return status
|
||||
return 'WARN'
|
||||
}
|
||||
|
||||
export const formatSgsFloorLabel = (floorCode?: string | null, floorName?: string | null) => {
|
||||
if (floorCode === 'EXTERIOR') return '室外'
|
||||
|
||||
const basementMatch = floorCode?.match(/^L-(\d+(?:\.\d+)?)$/)
|
||||
if (basementMatch) return `B${basementMatch[1]}`
|
||||
|
||||
const floorMatch = floorCode?.match(/^L(\d+(?:\.\d+)?)$/)
|
||||
if (floorMatch) return `${floorMatch[1]}F`
|
||||
|
||||
return floorName || floorCode || '未知楼层'
|
||||
}
|
||||
|
||||
export const toMuseumFloorFromSgs = (floor: SgsSdkFloorSummaryPayload): MuseumFloor => ({
|
||||
id: stringifyId(floor.floorId),
|
||||
label: formatSgsFloorLabel(floor.floorCode, floor.floorName),
|
||||
order: toNumber(floor.sortOrder)
|
||||
})
|
||||
|
||||
export const buildSgsFloorAliases = (floors: SgsSdkFloorSummaryPayload[]) => {
|
||||
const aliases = new Map<string, string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
const floorId = stringifyId(floor.floorId)
|
||||
if (!floorId) return
|
||||
|
||||
const label = formatSgsFloorLabel(floor.floorCode, floor.floorName)
|
||||
aliases.set(floorId, floorId)
|
||||
if (floor.floorCode) aliases.set(String(floor.floorCode), floorId)
|
||||
if (floor.floorName) aliases.set(String(floor.floorName), floorId)
|
||||
aliases.set(label, floorId)
|
||||
aliases.set(label.toLowerCase(), floorId)
|
||||
})
|
||||
|
||||
return aliases
|
||||
}
|
||||
|
||||
const normalizePosition = (poi: SgsPoiPayload): [number, number, number] | undefined => {
|
||||
const source: SgsPositionPayload = poi.position || {
|
||||
x: poi.x,
|
||||
y: poi.y,
|
||||
z: poi.z
|
||||
}
|
||||
const x = Number(source.x)
|
||||
const y = Number(source.y)
|
||||
const z = Number(source.z)
|
||||
|
||||
if (![x, y, z].every(Number.isFinite)) return undefined
|
||||
return [x, y, z]
|
||||
}
|
||||
|
||||
const categoryFor = (poi: SgsPoiPayload): MuseumCategory & { accessible?: boolean } => {
|
||||
const normalizedType = poi.type?.trim()
|
||||
if (normalizedType && categoryBySgsType[normalizedType]) {
|
||||
return categoryBySgsType[normalizedType]
|
||||
}
|
||||
|
||||
if (poi.typeName) {
|
||||
return {
|
||||
...defaultCategory,
|
||||
label: poi.typeName,
|
||||
iconType: normalizedType || defaultCategory.iconType
|
||||
}
|
||||
}
|
||||
|
||||
return defaultCategory
|
||||
}
|
||||
|
||||
export const toMuseumPoiFromSgs = (
|
||||
poi: SgsPoiPayload,
|
||||
floors: SgsSdkFloorSummaryPayload[]
|
||||
): MuseumPoi => {
|
||||
const floorId = stringifyId(poi.floorId)
|
||||
const matchedFloor = floors.find((floor) => stringifyId(floor.floorId) === floorId || floor.floorCode === poi.floorCode)
|
||||
const category = categoryFor(poi)
|
||||
|
||||
return {
|
||||
id: stringifyId(poi.id),
|
||||
name: poi.name?.trim() || `未命名点位 ${stringifyId(poi.id)}`,
|
||||
floorId,
|
||||
floorLabel: formatSgsFloorLabel(matchedFloor?.floorCode || poi.floorCode, matchedFloor?.floorName),
|
||||
primaryCategory: {
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
},
|
||||
categories: [
|
||||
{
|
||||
id: category.id,
|
||||
label: category.label,
|
||||
iconType: category.iconType
|
||||
}
|
||||
],
|
||||
positionGltf: normalizePosition(poi),
|
||||
sourceObjectName: poi.anchorNodeName || undefined,
|
||||
sourceConfidence: 'backend-sgs-sdk',
|
||||
navigationReadiness: '位置预览',
|
||||
accessible: category.accessible === true
|
||||
}
|
||||
}
|
||||
|
||||
export const toLocationPreviewFromPoi = (poi: MuseumPoi): GuideLocationPreview => ({
|
||||
poiId: poi.id,
|
||||
name: poi.name,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
primaryCategoryZh: poi.primaryCategory.label,
|
||||
positionGltf: poi.positionGltf,
|
||||
sourceObjectName: poi.sourceObjectName
|
||||
})
|
||||
|
||||
export const toGuideFloorDetailFromSgs = (
|
||||
floor: SgsSdkFloorSummaryPayload | SgsFloorDiagnosticsPayload
|
||||
): GuideFloorDetail => ({
|
||||
...toMuseumFloorFromSgs(floor),
|
||||
sourceCode: floor.floorCode || undefined,
|
||||
sourceName: floor.floorName || undefined,
|
||||
modelReady: 'modelReady' in floor ? floor.modelReady === true : undefined,
|
||||
poiCount: optionalNumber(floor.poiCount),
|
||||
spaceCount: optionalNumber(floor.spaceCount),
|
||||
guideStopCount: 'guideStopCount' in floor ? toNumber(floor.guideStopCount, 0) : undefined,
|
||||
navigablePlaceCount: 'navigablePlaceCount' in floor ? toNumber(floor.navigablePlaceCount, 0) : undefined,
|
||||
routeNodeCount: 'routeNodeCount' in floor ? toNumber(floor.routeNodeCount, 0) : undefined,
|
||||
routeEdgeCount: 'routeEdgeCount' in floor ? toNumber(floor.routeEdgeCount, 0) : undefined,
|
||||
routePlanningReady: 'routePlanningReady' in floor ? floor.routePlanningReady === true : undefined,
|
||||
warnings: 'warnings' in floor ? floor.warnings || [] : []
|
||||
})
|
||||
|
||||
export const toGuideMapDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload,
|
||||
manifest?: SgsSdkManifestPayload
|
||||
): GuideMapDiagnostics => {
|
||||
const summary = diagnostics.summary || {}
|
||||
const floors = diagnostics.floors?.length
|
||||
? diagnostics.floors.map(toGuideFloorDetailFromSgs)
|
||||
: (manifest?.floors || []).map(toGuideFloorDetailFromSgs)
|
||||
|
||||
return {
|
||||
mapId: stringifyId(diagnostics.mapId || manifest?.mapId),
|
||||
mapName: diagnostics.mapName || manifest?.mapName || 'SGS 地图',
|
||||
sdkVersion: diagnostics.sdkVersion || manifest?.sdkVersion || undefined,
|
||||
status: normalizeStatus(diagnostics.status),
|
||||
floors,
|
||||
summary: {
|
||||
floorCount: toNumber(summary.floorCount, floors.length),
|
||||
modelReadyFloorCount: toNumber(summary.modelReadyFloorCount),
|
||||
poiCount: toNumber(summary.poiCount),
|
||||
spaceCount: toNumber(summary.spaceCount),
|
||||
guideStopCount: toNumber(summary.guideStopCount),
|
||||
navigablePlaceCount: toNumber(summary.navigablePlaceCount),
|
||||
routeNodeCount: toNumber(summary.routeNodeCount),
|
||||
routeEdgeCount: toNumber(summary.routeEdgeCount)
|
||||
},
|
||||
warnings: diagnostics.warnings || []
|
||||
}
|
||||
}
|
||||
|
||||
export const toRouteReadinessFromSgsDiagnostics = (
|
||||
diagnostics: SgsMapDiagnosticsPayload
|
||||
): GuideRouteReadiness => {
|
||||
const warnings = diagnostics.warnings || []
|
||||
const floors = diagnostics.floors || []
|
||||
const notReadyFloors = floors.filter((floor) => (
|
||||
floor.modelReady !== true
|
||||
|| floor.routePlanningReady !== true
|
||||
|| toNumber(floor.routeNodeCount) <= 0
|
||||
|| toNumber(floor.routeEdgeCount) <= 0
|
||||
|| toNumber(floor.navigablePlaceCount) <= 0
|
||||
))
|
||||
|
||||
const ready = diagnostics.status === 'OK' && notReadyFloors.length === 0 && warnings.length === 0
|
||||
|
||||
return {
|
||||
ready,
|
||||
message: ready
|
||||
? 'SGS 后端路线数据已就绪'
|
||||
: NAV_ROUTE_UNAVAILABLE_MESSAGE,
|
||||
requiredData: ready
|
||||
? []
|
||||
: [
|
||||
...warnings,
|
||||
...notReadyFloors.map((floor) => `${formatSgsFloorLabel(floor.floorCode, floor.floorName)} 数据未完全就绪`)
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
const addIssue = (
|
||||
issues: GuideDataIntegrityIssue[],
|
||||
issue: Omit<GuideDataIntegrityIssue, 'id'>
|
||||
) => {
|
||||
issues.push({
|
||||
id: `issue-${issues.length + 1}`,
|
||||
...issue
|
||||
})
|
||||
}
|
||||
|
||||
const missingFieldsForPoi = (poi: MuseumPoi) => {
|
||||
const fields: string[] = []
|
||||
if (!poi.id) fields.push('id')
|
||||
if (!poi.name) fields.push('name')
|
||||
if (!poi.floorId) fields.push('floorId')
|
||||
if (!poi.positionGltf) fields.push('position')
|
||||
return fields
|
||||
}
|
||||
|
||||
export const createGuideDataIntegrityReport = (
|
||||
diagnostics: GuideMapDiagnostics,
|
||||
floors: MuseumFloor[],
|
||||
pois: MuseumPoi[]
|
||||
): GuideDataIntegrityReport => {
|
||||
const issues: GuideDataIntegrityIssue[] = []
|
||||
const floorIds = new Set(floors.map((floor) => floor.id))
|
||||
const poiIds = new Set<string>()
|
||||
|
||||
floors.forEach((floor) => {
|
||||
if (!floor.id || !floor.label) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'error',
|
||||
message: '楼层缺少稳定 ID 或显示名称',
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['id', 'label'].filter((field) => !floor[field as keyof MuseumFloor])
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
diagnostics.floors.forEach((floor) => {
|
||||
if (floor.modelReady === false) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 模型未就绪`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['modelReady']
|
||||
})
|
||||
}
|
||||
|
||||
if (floor.navigablePlaceCount === 0) {
|
||||
addIssue(issues, {
|
||||
scope: 'floor',
|
||||
severity: 'warn',
|
||||
message: `${floor.label} 缺少可导航目的地`,
|
||||
floorId: floor.id,
|
||||
floorLabel: floor.label,
|
||||
fields: ['navigablePlaceCount']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
pois.forEach((poi) => {
|
||||
const missingFields = missingFieldsForPoi(poi)
|
||||
if (missingFields.length) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id || '未知点位'} 缺少必要字段`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: missingFields
|
||||
})
|
||||
}
|
||||
|
||||
if (poi.id && poiIds.has(poi.id)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 点位 ID 重复`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['id']
|
||||
})
|
||||
}
|
||||
if (poi.id) poiIds.add(poi.id)
|
||||
|
||||
if (poi.floorId && !floorIds.has(poi.floorId)) {
|
||||
addIssue(issues, {
|
||||
scope: 'poi',
|
||||
severity: 'error',
|
||||
message: `${poi.name || poi.id} 关联的楼层不存在`,
|
||||
floorId: poi.floorId,
|
||||
floorLabel: poi.floorLabel,
|
||||
poiId: poi.id,
|
||||
fields: ['floorId']
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
const errorCount = issues.filter((issue) => issue.severity === 'error').length
|
||||
const warningCount = issues.filter((issue) => issue.severity === 'warn').length
|
||||
|
||||
return {
|
||||
status: errorCount > 0 ? 'ERROR' : warningCount > 0 || diagnostics.status === 'WARN' ? 'WARN' : 'OK',
|
||||
summary: {
|
||||
floorCount: floors.length,
|
||||
poiCount: pois.length,
|
||||
issueCount: issues.length,
|
||||
errorCount,
|
||||
warningCount
|
||||
},
|
||||
issues,
|
||||
warnings: diagnostics.warnings
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user