修复导览点位可见性与构建门禁

This commit is contained in:
cxk
2026-07-20 23:54:19 +08:00
parent 735c5cd1ee
commit 1f89e8b3e0
14 changed files with 499 additions and 168 deletions

View File

@@ -10,6 +10,7 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isVisitorRestrictedPlaceName,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
@@ -193,6 +194,16 @@ const isPoiAccessible = (poi: StaticNavPoiPayload) => (
|| poi.categories?.some((category) => category.topCategory === 'accessibility_special_service') === true
)
const resolveStaticPoiVisitorVisible = (
poi: StaticNavPoiPayload,
semanticType: string
) => (
typeof poi.visitorVisible === 'boolean'
? poi.visitorVisible
: semanticType !== 'service_space'
&& ![poi.name, poi.sourceObjectName].some(isVisitorRestrictedPlaceName)
)
export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
const categoryFallbackIconType = poi.categories?.[0]?.iconType
const iconType = getStaticPoiSemanticType(poi, poi.iconType || categoryFallbackIconType)
@@ -214,6 +225,7 @@ export const toMuseumPoi = (poi: StaticNavPoiPayload): MuseumPoi => {
sourceObjectName: poi.sourceObjectName,
sourceConfidence: poi.sourceConfidence,
navigationReadiness: poi.navigationReadiness,
visitorVisible: resolveStaticPoiVisitorVisible(poi, iconType),
accessible: isPoiAccessible(poi),
kind,
hallName: kind === 'hall' ? poi.name : undefined

View File

@@ -19,6 +19,7 @@ import {
isIndoorNavigableFloor
} from '@/domain/guideFloor'
import {
isVisitorRestrictedPlaceName,
isPoiSearchCategorySupported,
normalizePoiSemanticValue
} from '@/domain/poiCategories'
@@ -146,6 +147,7 @@ const spaceCategoryBySgsType: Record<string, MuseumCategory> = {
const categoryBySgsType: Record<string, MuseumCategory & { accessible?: boolean }> = {
exhibition_hall: hallCategory,
theater: spaceCategoryBySgsType.theater,
service_space: spaceCategoryBySgsType.service_space,
commercial: spaceCategoryBySgsType.commercial,
restaurant: spaceCategoryBySgsType.restaurant,
cafe: spaceCategoryBySgsType.cafe,
@@ -414,6 +416,25 @@ const categoryForBusinessType = (businessType?: string | null): MuseumCategory =
}
}
const resolveSgsPoiVisitorVisible = (
source: {
visitorVisible?: boolean | null
name?: string | null
sourceNodeName?: string | null
anchorNodeName?: string | null
},
category: MuseumCategory
) => (
typeof source.visitorVisible === 'boolean'
? source.visitorVisible
: category.id !== 'space_service'
&& ![
source.name,
source.sourceNodeName,
source.anchorNodeName
].some(isVisitorRestrictedPlaceName)
)
interface SgsHallPoiBuildOptions {
fallbackY?: number
}
@@ -658,9 +679,10 @@ export const toMuseumPoiFromSgs = (
}
],
positionGltf: normalizePosition(poi),
sourceObjectName: poi.anchorNodeName || undefined,
sourceObjectName: poi.anchorNodeName || undefined,
sourceConfidence: 'backend-sgs-sdk',
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(poi, category),
accessible: category.accessible === true,
kind,
hallId: kind === 'hall' ? spatialAreaId || stringifyId(poi.id) : undefined,
@@ -825,6 +847,7 @@ export const toMuseumSpacePointFromSgs = (
sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false,
kind: 'space',
hallId: category.id === hallCategory.id ? spaceId : undefined,
@@ -895,8 +918,9 @@ const createSpaceFallbackHallPoi = (
positionGltf: position,
sourceObjectName: space.sourceNodeName || undefined,
sourceConfidence: spacePosition.sourceConfidence,
navigationReadiness: '位置预览',
accessible: false,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(space, category),
accessible: false,
kind: 'hall',
hallId,
hallName: normalizedText(space.name),
@@ -983,6 +1007,7 @@ export const toMuseumHallPoisFromSgs = (
? spacePosition?.sourceConfidence || sgsSpaceCenterConfidence
: sgsHallEntranceConfidence,
navigationReadiness: '位置预览',
visitorVisible: resolveSgsPoiVisitorVisible(matchedSpace, category),
accessible: false,
kind: 'hall',
hallId,

View File

@@ -65,6 +65,7 @@ export interface SgsPoiPayload {
y?: number | null
z?: number | null
status?: string | null
visitorVisible?: boolean | null
anchorNodeName?: string | null
description?: string | null
iconUrl?: string | null
@@ -87,6 +88,7 @@ export interface SgsSpacePayload {
center?: SgsPositionPayload | null
sourceNodeName?: string | null
status?: string | null
visitorVisible?: boolean | null
colorHex?: string | null
}

View File

@@ -27,6 +27,7 @@ export interface StaticNavPoiPayload {
sourceObjectName?: string
navigationReadiness?: string
sourceConfidence?: string
visitorVisible?: boolean | null
}
export interface StaticNavManifestFloorModelPayload {

View File

@@ -96,6 +96,8 @@ export interface MuseumPoi {
sourceObjectName?: string
sourceConfidence?: string
navigationReadiness?: string
/** Explicit visitor-search eligibility supplied by the source or adapter policy. */
visitorVisible?: boolean
accessible: boolean
kind?: MuseumPoiKind
hallId?: string

View File

@@ -183,6 +183,7 @@ export type PoiCategorySource = Pick<
| 'sourcePlaceId'
| 'sourceSpaceId'
| 'sourceObjectName'
| 'visitorVisible'
>
const normalizeValue = (value?: string | null) => (value || '')
@@ -192,6 +193,13 @@ const normalizeValue = (value?: string | null) => (value || '')
.replace(/[\s-]+/g, '_')
.replace(/^_+|_+$/g, '')
const visitorRestrictedPlaceNamePattern = /(?:贵宾|vip|员工|职工|后勤|办公|行政|库房|仓库|机房|设备间|配电|弱电|强电|保洁|值班|消防控制|监控室|staff|employee|back[_ -]?of[_ -]?house|maintenance)/i
/** Legacy sources without an explicit flag must not expose staff-only places. */
export const isVisitorRestrictedPlaceName = (value?: string | null) => (
visitorRestrictedPlaceNamePattern.test(normalizeValue(value))
)
const poiSemanticAliases: Readonly<Record<string, string>> = {
exhibition: 'exhibition_hall',
exhibition_hall: 'exhibition_hall',
@@ -397,7 +405,7 @@ export const resolvePoiCategory = (poi: PoiCategorySource) => (
POI_CATEGORIES.find((category) => matchesPoiCategory(poi, category)) || null
)
const hiddenVisitorPrimaryTypes = new Set([
const hiddenVisitorTypes = new Set([
'entrance_exit',
'hall_entrance',
'entrance_anchor',
@@ -406,7 +414,7 @@ const hiddenVisitorPrimaryTypes = new Set([
'operation_experience'
])
const primaryTypeValues = (poi: PoiCategorySource) => [
const visitorTypeValues = (poi: PoiCategorySource) => [
poi.primaryCategory.id,
poi.primaryCategory.label,
poi.primaryCategory.iconType || ''
@@ -414,10 +422,18 @@ const primaryTypeValues = (poi: PoiCategorySource) => [
.map(normalizePoiSemanticValue)
.filter(Boolean)
const isHiddenVisitorType = (value: string) => {
const normalizedValue = normalizePoiSemanticValue(value)
const sourceWrappedValue = normalizedValue.replace(/^(?:space|poi|facility|business)_/, '')
return hiddenVisitorTypes.has(normalizedValue)
|| hiddenVisitorTypes.has(sourceWrappedValue)
}
/** A visitor result must never be a guide point, door, entrance anchor, or route node. */
export const isVisitorSearchPoi = (poi: PoiCategorySource) => {
if (poi.visitorVisible === false) return false
if (poi.kind === 'guide' || poi.kind === 'hall_entrance') return false
return !primaryTypeValues(poi).some((value) => hiddenVisitorPrimaryTypes.has(value))
return !visitorTypeValues(poi).some(isHiddenVisitorType)
}
/** Default floor browse only contains canonical halls and valid destination spaces. */
@@ -516,8 +532,6 @@ export const getPoiDataIssues = (poi: MuseumPoi): PoiDataIssue[] => {
} else if (!hasFinitePosition(poi.positionGltf)) {
issues.push({ code: 'invalid-position', ...issueBase })
}
if (!isPoiSearchCategorySupported(poi)) issues.push({ code: 'unsupported-category', ...issueBase })
return issues
}
@@ -548,6 +562,6 @@ export const warnPoiCollectionIssues = (source: string, pois: MuseumPoi[]) => {
const inspection = inspectPoiCollection(pois)
if (!inspection.issues.length && !inspection.duplicateIds.length) return
// 开发期集中输出数据契约问题,避免在页面组件中散落源数据校验。
console.warn(`[POI 数据校验] ${source}`, inspection)
// This is an aggregate development diagnostic, not a visitor-facing failure.
console.debug(`[POI 数据诊断] ${source}`, inspection)
}

View File

@@ -22,10 +22,7 @@ import {
} from '@/domain/guideFloor'
import {
buildSgsFloorAliases,
createSgsHallPoiDiagnostics,
formatSgsFloorLabel,
toMuseumHallPoisFromSgs,
toMuseumPoiFromSgs
formatSgsFloorLabel
} from '@/data/adapters/sgsSdkGuideAdapter'
import {
defaultSgsSdkApiProvider,
@@ -47,6 +44,9 @@ import {
import type {
GuideRepository
} from '@/repositories/GuideRepository'
import {
canonicalizeVisitorSearchPois
} from '@/repositories/GuideRepository'
import {
guideRepository
} from '@/repositories/createGuideRepository'
@@ -84,37 +84,6 @@ const toGuideRenderPoi = (poi: MuseumPoi): GuideRenderPoi => ({
})
})
const toSgsRenderPoi = (poi: ReturnType<typeof toMuseumPoiFromSgs>) => toGuideRenderPoi(poi)
const getRenderPoiDedupeKeys = (poi: GuideRenderPoi) => {
// A facility can be located inside the same spatial area as other facilities.
// That relationship must not collapse distinct visitor markers (for example,
// a restroom and an elevator in one service zone). Space identity is only a
// canonical-place key for halls, spaces, and business-place representations.
const canDedupeBySpace = poi.kind === 'hall'
|| poi.kind === 'space'
|| poi.primaryCategory === 'business_poi'
const stableKeys = [
poi.id ? `id:${poi.id}` : '',
canDedupeBySpace && poi.sourceSpaceId ? `space:${poi.floorId}:${poi.sourceSpaceId}` : '',
canDedupeBySpace && poi.spaceId ? `space:${poi.floorId}:${poi.spaceId}` : '',
poi.sourcePlaceId ? `place:${poi.floorId}:${poi.sourcePlaceId}` : ''
].filter(Boolean)
if (stableKeys.length) return stableKeys
return poi.sourceObjectName ? [`object:${poi.floorId}:${poi.sourceObjectName}`] : []
}
const dedupeRenderPoisById = (pois: GuideRenderPoi[]) => {
const seen = new Set<string>()
return pois.filter((poi) => {
const keys = getRenderPoiDedupeKeys(poi)
if (!keys.length || keys.some((key) => seen.has(key))) return false
keys.forEach((key) => seen.add(key))
return true
})
}
const countByValue = <T>(
items: T[],
selector: (item: T) => string | number | null | undefined
@@ -124,17 +93,6 @@ const countByValue = <T>(
return counts
}, {})
const countDroppedRenderPoiCategories = (
sourcePois: GuideRenderPoi[],
keptPois: GuideRenderPoi[]
) => {
const keptIds = new Set(keptPois.map((poi) => poi.id))
return countByValue(
sourcePois.filter((poi) => !keptIds.has(poi.id)),
(poi) => poi.primaryCategory
)
}
const trimTrailingSlash = (value: string) => value.replace(/\/+$/, '')
const resolveSgsAssetUrl = (url?: string | null) => {
@@ -234,20 +192,6 @@ const summarizeYValues = (pois: GuideRenderPoi[]) => {
}
}
const getMedianPoiY = (pois: GuideRenderPoi[]) => {
const values = pois
.map((poi) => poi.positionGltf?.[1])
.filter((value): value is number => Number.isFinite(value))
.sort((left, right) => left - right)
if (!values.length) return undefined
const middle = Math.floor(values.length / 2)
return values.length % 2
? values[middle]
: (values[middle - 1] + values[middle]) / 2
}
const getSgsFloorModelMatchKeys = (floor: SgsSdkFloorSummaryPayload, label: string) => (
[
String(floor.floorId),
@@ -442,7 +386,7 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
const resolvedFloorId = String(matchedFloor.floorId)
const loadFloorData = async <T>(
endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints',
endpoint: 'guideSearch',
loader: () => Promise<T[]>
) => {
try {
@@ -457,88 +401,36 @@ export class SgsSdkGuideModelRepository implements GuideModelRepository {
}
}
const [pois, spaces, navigablePlaces, guidePois, guideSpacePoints] = await Promise.all([
loadFloorData('pois', () => this.provider.getFloorPois(resolvedFloorId)),
loadFloorData('spaces', () => this.provider.getFloorSpaces(resolvedFloorId)),
loadFloorData('navigablePlaces', () => this.provider.getNavigablePlaces(resolvedFloorId)),
loadFloorData('guidePois', () => this.guide.listPois()),
loadFloorData('guideSpacePoints', () => this.guide.listSpacePoints())
])
const ordinaryPois = pois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const repositoryBusinessPois = guidePois
.filter((poi) => (
poi.floorId === resolvedFloorId
&& poi.primaryCategory.id === 'business_poi'
&& isVisitorSearchPoi(poi)
))
const visitorSearchPois = await loadFloorData(
'guideSearch',
() => this.guide.searchPois('', resolvedFloorId)
)
const canonicalPois = canonicalizeVisitorSearchPois(visitorSearchPois)
const renderPois = canonicalPois
.filter((poi) => poi.floorId === resolvedFloorId)
.map(toGuideRenderPoi)
const repositorySpacePois = guideSpacePoints
.filter((poi) => poi.floorId === resolvedFloorId && isVisitorSearchPoi(poi))
.map(toGuideRenderPoi)
const floorPoiMedianY = getMedianPoiY(ordinaryPois)
const museumHallPois = toMuseumHallPoisFromSgs(spaces, navigablePlaces, manifest.floors, resolvedFloorId, {
fallbackY: floorPoiMedianY
})
const hallDiagnostics = createSgsHallPoiDiagnostics(spaces, navigablePlaces, museumHallPois)
if (hallDiagnostics.eligibleSpaceCount > 0 && hallDiagnostics.hallPoiWithPositionCount === 0) {
warnSgsGuideModelDiagnostics('eligible hall spaces produced no renderable hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics
})
}
const hallPois = museumHallPois
.filter(isVisitorSearchPoi)
.map(toSgsRenderPoi)
const adaptedPois = dedupeRenderPoisById([
...hallPois,
...ordinaryPois,
...repositorySpacePois,
...repositoryBusinessPois
])
const floorMatchedPois = adaptedPois.filter((poi) => poi.floorId === resolvedFloorId)
const renderPois = floorMatchedPois
.filter((poi) => Array.isArray(poi.positionGltf) && poi.positionGltf.length === 3)
const renderHallPois = renderPois.filter((poi) => poi.kind === 'hall' || poi.primaryCategory === 'exhibition_hall')
logSgsGuideModelDiagnostics('floor POI category diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
rawPoiCount: pois.length,
rawRepositoryBusinessPoiCount: repositoryBusinessPois.length,
rawRepositorySpacePoiCount: repositorySpacePois.length,
rawPoiTypeCounts: countByValue(pois, (poi) => poi.type),
rawPoiGroupCounts: countByValue(pois, (poi) => poi.poiGroup),
adaptedPoiCount: adaptedPois.length,
adaptedCategoryCounts: countByValue(adaptedPois, (poi) => poi.primaryCategory),
adaptedPoiCategoryCount: adaptedPois.filter((poi) => poi.primaryCategory === 'poi').length,
canonicalPoiCount: canonicalPois.length,
canonicalCategoryCounts: countByValue(canonicalPois, (poi) => poi.primaryCategory.id),
canonicalPoiCategoryCount: canonicalPois.filter((poi) => poi.primaryCategory.id === 'poi').length,
renderPoiCount: renderPois.length,
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length,
repositoryDroppedCategoryCounts: countDroppedRenderPoiCategories(adaptedPois, renderPois)
renderPoiCategoryCount: renderPois.filter((poi) => poi.primaryCategory === 'poi').length
})
warnSgsGuideModelDiagnostics('floor render POI diagnostics', {
logSgsGuideModelDiagnostics('floor render POI diagnostics', {
floorId: resolvedFloorId,
floorCode: matchedFloor.floorCode,
poiCount: renderPois.length,
hallPoiCount: renderHallPois.length,
hallPoiWithPositionCount: renderHallPois.filter((poi) => Boolean(poi.positionGltf)).length,
poiY: summarizeYValues(renderPois),
hallPoiY: summarizeYValues(renderHallPois),
fallbackHallY: floorPoiMedianY ?? null
hallPoiY: summarizeYValues(renderHallPois)
})
if (hallDiagnostics.hallPoiWithPositionCount > 0 && !renderHallPois.length) {
warnSgsGuideModelDiagnostics('render POI filter removed all hall POIs', {
floorId: resolvedFloorId,
...hallDiagnostics,
renderPoiCount: renderPois.length
})
}
return renderPois
}
}

View File

@@ -159,6 +159,17 @@ const isVisitorSearchResult = (poi: MuseumPoi) => (
&& hasRenderablePosition(poi)
)
// Explicitly hidden source records must still participate in canonical-place
// merging so a private space cannot leak through an unflagged POI duplicate.
const isCanonicalSearchCandidate = (poi: MuseumPoi) => (
isPoiOnIndoorNavigableFloor(poi)
&& hasRenderablePosition(poi)
&& isVisitorSearchPoi({
...poi,
visitorVisible: true
})
)
const normalizePlaceName = (value: string) => value
.trim()
.normalize('NFKC')
@@ -188,6 +199,11 @@ const canonicalMergeKeys = (poi: MuseumPoi) => {
keys.push(`space:${poi.floorId}:${linkedSpace}:${categoryId || 'other'}`)
}
const sourcePlaceId = poi.sourcePlaceId?.trim()
if (sourcePlaceId) {
keys.push(`place:${poi.floorId}:${categoryId || 'other'}:${sourcePlaceId}`)
}
const normalizedName = normalizePlaceName(poi.name)
const location = positionKey(poi)
// A space, regular POI, and business POI can describe the same visitor
@@ -246,7 +262,10 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
spaceId: primary.spaceId || secondary.spaceId,
sourceSpaceId: primary.sourceSpaceId || secondary.sourceSpaceId,
sourcePlaceId: primary.sourcePlaceId || secondary.sourcePlaceId,
entrances: primary.entrances || secondary.entrances
entrances: primary.entrances || secondary.entrances,
visitorVisible: primary.visitorVisible === false || secondary.visitorVisible === false
? false
: primary.visitorVisible ?? secondary.visitorVisible
}
}
@@ -254,11 +273,11 @@ const mergeCanonicalPoi = (left: MuseumPoi, right: MuseumPoi) => {
* Search queries return a single map-renderable representation of each real
* visitor place. Source collections remain intact for the renderer itself.
*/
const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
export const canonicalizeVisitorSearchPois = (pois: MuseumPoi[]) => {
const canonicalByKey = new Map<string, MuseumPoi>()
pois
.filter(isVisitorSearchResult)
.filter(isCanonicalSearchCandidate)
.forEach((poi) => {
const keys = canonicalMergeKeys(poi)
if (!keys.length) keys.push(`poi:${poi.id}`)
@@ -282,10 +301,11 @@ const mergeCanonicalSearchPois = (pois: MuseumPoi[]) => {
})
return dedupePoisById(Array.from(canonicalByKey.values()))
.filter(isVisitorSearchResult)
}
const mergeSearchablePois = (pois: MuseumPoi[], spacePoints: MuseumPoi[]) => (
mergeCanonicalSearchPois([
canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -426,7 +446,7 @@ export class StaticGuideRepository implements GuideRepository {
this.listPois(),
this.listSpacePoints()
])
this.visitorSearchPoolCache = mergeCanonicalSearchPois([
this.visitorSearchPoolCache = canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -470,8 +490,8 @@ export class StaticGuideRepository implements GuideRepository {
}
async getPoiById(id: string) {
const pois = await this.listPois()
return pois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(pois, id)
const visitorPois = (await this.listPois()).filter(isVisitorSearchPoi)
return visitorPois.find((poi) => poi.id === id) || findPoiByNavIdNameFallback(visitorPois, id)
}
async searchPois(keyword = '', floorId?: string) {
@@ -735,7 +755,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
sourceFailures.push(error)
}
const searchPool = mergeCanonicalSearchPois([
const searchPool = canonicalizeVisitorSearchPois([
...pois,
...spacePoints
])
@@ -796,6 +816,7 @@ export class SgsSdkGuideRepository implements GuideRepository {
])
const direct = directPois
.map((poi) => toMuseumPoiFromSgs(poi, manifest.floors))
.filter(isVisitorSearchPoi)
.find((poi) => poi.id === normalizedId || getPoiSourceLookupIdentity(normalizedId) === poi.spaceId || getPoiSourceLookupIdentity(normalizedId) === poi.sourceSpaceId)
if (direct) return direct
} catch (error) {