feat: 新增 SGS SDK 数据适配器与腾讯地图服务

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
lyf
2026-06-26 04:21:46 +08:00
parent 15fbbec12d
commit cc7c21a285
13 changed files with 2938 additions and 0 deletions

View 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
}
}