From 1f89e8b3e0ee2ab9dedba8945b03241dace4c614 Mon Sep 17 00:00:00 2001 From: cxk <119064883@qq.com> Date: Mon, 20 Jul 2026 23:54:19 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BF=AE=E5=A4=8D=E5=AF=BC=E8=A7=88=E7=82=B9?= =?UTF-8?q?=E4=BD=8D=E5=8F=AF=E8=A7=81=E6=80=A7=E4=B8=8E=E6=9E=84=E5=BB=BA?= =?UTF-8?q?=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 2 +- scripts/finalize-h5-build-policy.cjs | 61 ++++++ scripts/finalize-h5-build.cjs | 46 +++-- src/data/adapters/navAssetsAdapter.ts | 12 ++ src/data/adapters/sgsSdkGuideAdapter.ts | 31 ++- src/data/providers/sgsSdkApiProvider.ts | 2 + src/data/providers/staticNavAssetsProvider.ts | 1 + src/domain/museum.ts | 2 + src/domain/poiCategories.ts | 28 ++- src/repositories/GuideModelRepository.ts | 144 ++------------ src/repositories/GuideRepository.ts | 37 +++- tests/unit/GuideRepositorySearch.spec.ts | 185 +++++++++++++++++- tests/unit/finalizeH5BuildPolicy.spec.ts | 86 ++++++++ tests/unit/navAssetsAdapter.spec.ts | 30 ++- 14 files changed, 499 insertions(+), 168 deletions(-) create mode 100644 scripts/finalize-h5-build-policy.cjs create mode 100644 tests/unit/finalizeH5BuildPolicy.spec.ts diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7e9ae80..3aaf752 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -32,7 +32,7 @@ jobs: run: pnpm lint - name: Build H5 - run: pnpm build:h5 + run: pnpm build:test:h5 - name: Build WeChat Mini Program run: pnpm build:mp-weixin diff --git a/scripts/finalize-h5-build-policy.cjs b/scripts/finalize-h5-build-policy.cjs new file mode 100644 index 0000000..cda80d2 --- /dev/null +++ b/scripts/finalize-h5-build-policy.cjs @@ -0,0 +1,61 @@ +const { loadEnv } = require('vite') + +const tencentMapKeyPlaceholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__' +const tencentMapKeyPattern = /^[A-Z0-9]{5}(?:-[A-Z0-9]{5}){4,6}$/i + +const isUsableTencentMapKey = (value) => ( + tencentMapKeyPattern.test(value) + && value !== tencentMapKeyPlaceholder +) + +const resolveH5BuildPolicy = ({ + args = [], + projectRoot, + environment = process.env, + loadProductionEnv = (root) => loadEnv('production', root, '') +}) => { + const flags = new Set(args) + const requireTencentMapKey = flags.has('--require-tencent-map-key') + const allowPlaceholder = flags.has('--allow-placeholder') + + if (requireTencentMapKey && allowPlaceholder) { + throw new Error('H5 build cannot require a Tencent map key and allow its placeholder at the same time.') + } + + if (!requireTencentMapKey) { + return { + allowPlaceholder, + requireTencentMapKey, + tencentMapKey: '' + } + } + + const productionEnv = loadProductionEnv(projectRoot) + const tencentMapKey = ( + environment.VITE_TENCENT_MAP_KEY + || productionEnv.VITE_TENCENT_MAP_KEY + || '' + ).trim() + + if (!isUsableTencentMapKey(tencentMapKey)) { + throw new Error([ + 'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.', + 'Inject it through CI or an ignored .env.production.local file.' + ].join(' ')) + } + + return { + allowPlaceholder, + requireTencentMapKey, + tencentMapKey + } +} + +const isTextBuildFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml|map)$/i.test(filePath) + +module.exports = { + isTextBuildFile, + isUsableTencentMapKey, + resolveH5BuildPolicy, + tencentMapKeyPlaceholder +} diff --git a/scripts/finalize-h5-build.cjs b/scripts/finalize-h5-build.cjs index d6e5d18..9e46f13 100644 --- a/scripts/finalize-h5-build.cjs +++ b/scripts/finalize-h5-build.cjs @@ -1,29 +1,36 @@ const fs = require('node:fs') const path = require('node:path') const { spawnSync } = require('node:child_process') - -require('./copy-h5-nav-assets.cjs') +const { + isTextBuildFile, + resolveH5BuildPolicy, + tencentMapKeyPlaceholder +} = require('./finalize-h5-build-policy.cjs') const projectRoot = path.resolve(__dirname, '..') const h5Root = path.join(projectRoot, 'dist', 'build', 'h5') -const placeholder = '__REPLACE_WITH_TENCENT_MAP_WEB_KEY__' +const placeholder = tencentMapKeyPlaceholder const badPatterns = [ 'http://1.92.206.90:9000', '1.92.206.90', 'guide.whaoyue.com', placeholder ] -const args = new Set(process.argv.slice(2)) -const requireTencentMapKey = args.has('--require-tencent-map-key') -const allowPlaceholder = args.has('--allow-placeholder') -const tencentMapKey = (process.env.VITE_TENCENT_MAP_KEY || '').trim() +const { + allowPlaceholder, + requireTencentMapKey, + tencentMapKey +} = resolveH5BuildPolicy({ + args: process.argv.slice(2), + projectRoot +}) + +require('./copy-h5-nav-assets.cjs') if (!fs.existsSync(h5Root)) { throw new Error(`H5 build output not found: ${h5Root}`) } -const isTextFile = (filePath) => /\.(?:js|mjs|html|json|css|txt|svg|xml)$/i.test(filePath) - const walkFiles = (dir, files = []) => { for (const entry of fs.readdirSync(dir, { withFileTypes: true })) { const filePath = path.join(dir, entry.name) @@ -42,16 +49,21 @@ const walkFiles = (dir, files = []) => { } const allFiles = walkFiles(h5Root) -const textFiles = allFiles.filter(isTextFile) +const textFiles = allFiles.filter(isTextBuildFile) const placeholderFiles = textFiles.filter((file) => fs.readFileSync(file, 'utf8').includes(placeholder)) -const hasUsableTencentMapKey = tencentMapKey.length > 0 && tencentMapKey !== placeholder +const hasUsableTencentMapKey = tencentMapKey.length > 0 +const missingTencentMapKeyMessage = [ + 'Production H5 build requires a well-formed non-placeholder VITE_TENCENT_MAP_KEY.', + 'Inject it through CI or an ignored .env.production.local file.' +].join(' ') + +if (requireTencentMapKey && !hasUsableTencentMapKey) { + throw new Error(missingTencentMapKeyMessage) +} if (placeholderFiles.length > 0) { if (!hasUsableTencentMapKey) { - const message = [ - 'H5 build output still contains Tencent map key placeholder.', - 'Please set VITE_TENCENT_MAP_KEY before production build.' - ].join(' ') + const message = `H5 build output still contains Tencent map key placeholder. ${missingTencentMapKeyMessage}` if (requireTencentMapKey && !allowPlaceholder) { throw new Error(message) @@ -108,8 +120,8 @@ for (const file of textFiles) { } if (badMatches.length > 0) { - if (allowPlaceholder && badMatches.every((item) => item.pattern === placeholder)) { - console.warn('[finalize-h5-build] Tencent map key placeholder is allowed for this build.') + if (allowPlaceholder) { + console.warn('[finalize-h5-build] Test H5 build allows non-production source hosts and the Tencent map key placeholder.') } else { console.error(JSON.stringify(badMatches.slice(0, 20), null, 2)) throw new Error('H5 build output contains forbidden test host, old domain, or placeholder.') diff --git a/src/data/adapters/navAssetsAdapter.ts b/src/data/adapters/navAssetsAdapter.ts index 9003221..dc35393 100644 --- a/src/data/adapters/navAssetsAdapter.ts +++ b/src/data/adapters/navAssetsAdapter.ts @@ -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 diff --git a/src/data/adapters/sgsSdkGuideAdapter.ts b/src/data/adapters/sgsSdkGuideAdapter.ts index 277ef35..61b73cc 100644 --- a/src/data/adapters/sgsSdkGuideAdapter.ts +++ b/src/data/adapters/sgsSdkGuideAdapter.ts @@ -19,6 +19,7 @@ import { isIndoorNavigableFloor } from '@/domain/guideFloor' import { + isVisitorRestrictedPlaceName, isPoiSearchCategorySupported, normalizePoiSemanticValue } from '@/domain/poiCategories' @@ -146,6 +147,7 @@ const spaceCategoryBySgsType: Record = { const categoryBySgsType: Record = { 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, diff --git a/src/data/providers/sgsSdkApiProvider.ts b/src/data/providers/sgsSdkApiProvider.ts index 58fa2ca..9353785 100644 --- a/src/data/providers/sgsSdkApiProvider.ts +++ b/src/data/providers/sgsSdkApiProvider.ts @@ -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 } diff --git a/src/data/providers/staticNavAssetsProvider.ts b/src/data/providers/staticNavAssetsProvider.ts index 1dbee25..a44e220 100644 --- a/src/data/providers/staticNavAssetsProvider.ts +++ b/src/data/providers/staticNavAssetsProvider.ts @@ -27,6 +27,7 @@ export interface StaticNavPoiPayload { sourceObjectName?: string navigationReadiness?: string sourceConfidence?: string + visitorVisible?: boolean | null } export interface StaticNavManifestFloorModelPayload { diff --git a/src/domain/museum.ts b/src/domain/museum.ts index 2dc74c4..827452b 100644 --- a/src/domain/museum.ts +++ b/src/domain/museum.ts @@ -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 diff --git a/src/domain/poiCategories.ts b/src/domain/poiCategories.ts index ae3a927..88a1ff6 100644 --- a/src/domain/poiCategories.ts +++ b/src/domain/poiCategories.ts @@ -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> = { 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) } diff --git a/src/repositories/GuideModelRepository.ts b/src/repositories/GuideModelRepository.ts index be58a5f..42f90b2 100644 --- a/src/repositories/GuideModelRepository.ts +++ b/src/repositories/GuideModelRepository.ts @@ -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) => 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() - 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 = ( items: T[], selector: (item: T) => string | number | null | undefined @@ -124,17 +93,6 @@ const countByValue = ( 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 ( - endpoint: 'pois' | 'spaces' | 'navigablePlaces' | 'guidePois' | 'guideSpacePoints', + endpoint: 'guideSearch', loader: () => Promise ) => { 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 } } diff --git a/src/repositories/GuideRepository.ts b/src/repositories/GuideRepository.ts index 9061b28..1d0bcfb 100644 --- a/src/repositories/GuideRepository.ts +++ b/src/repositories/GuideRepository.ts @@ -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() 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) { diff --git a/tests/unit/GuideRepositorySearch.spec.ts b/tests/unit/GuideRepositorySearch.spec.ts index 85851c6..8b5534f 100644 --- a/tests/unit/GuideRepositorySearch.spec.ts +++ b/tests/unit/GuideRepositorySearch.spec.ts @@ -5,7 +5,11 @@ import { } from '@/domain/poiCategories' import type { SgsSdkApiProvider, SgsSdkManifestPayload } from '@/data/providers/sgsSdkApiProvider' import type { StaticNavAssetsProvider } from '@/data/providers/staticNavAssetsProvider' -import { SgsSdkGuideRepository, StaticGuideRepository } from '@/repositories/GuideRepository' +import { + canonicalizeVisitorSearchPois, + SgsSdkGuideRepository, + StaticGuideRepository +} from '@/repositories/GuideRepository' import { SgsSdkGuideModelRepository, StaticGuideModelRepository @@ -40,7 +44,17 @@ const floorSpaces = [ { id: 'space-hall', name: '地球展厅', type: 'exhibition_hall', floorId: 'L1', center: { x: 1, y: 12, z: 1 } }, { id: 'space-cinema', name: '穹幕影院', type: 'theater', floorId: 'L1', center: { x: 2, y: 12, z: 2 } }, { id: 'space-dining', name: '餐饮区', type: 'restaurant', floorId: 'L1', center: { x: 3, y: 12, z: 3 } }, - { id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } } + { id: 'space-shopping', name: '文创商店', type: 'commercial', floorId: 'L1', center: { x: 4, y: 12, z: 4 } }, + { id: 'space-private-tea', name: '茶水间', type: 'service_space', floorId: 'L1', center: { x: 5, y: 12, z: 5 } }, + { id: 'space-private-vip', name: '贵宾接待区', type: 'service_space', floorId: 'L1', center: { x: 6, y: 12, z: 6 } }, + { + id: 'space-public-rest', + name: '游客休息区', + type: 'service_space', + floorId: 'L1', + center: { x: 7, y: 12, z: 7 }, + visitorVisible: true + } ] const floorPois = [ @@ -120,17 +134,25 @@ describe('GuideRepository visitor search contracts', () => { const provider = createSgsProvider() const repository = new SgsSdkGuideRepository(provider) - const [defaultResults, availability] = await Promise.all([ + const [defaultResults, availability, privateKeyword, publicKeyword] = await Promise.all([ repository.listDestinationPois('L1'), - repository.getQuickFindCategoryAvailability('L1') + repository.getQuickFindCategoryAvailability('L1'), + repository.searchPois('茶水间', 'L1'), + repository.searchPois('游客休息区', 'L1') ]) expect(defaultResults.map((poi) => poi.id)).toEqual(expect.arrayContaining([ 'hall-space-hall', 'hall-space-cinema', 'space-space-dining', - 'space-space-shopping' + 'space-space-shopping', + 'space-space-public-rest' ])) + for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) { + expect(defaultResults.map((poi) => poi.id)).not.toContain(privatePoiId) + } + expect(privateKeyword).toEqual([]) + expect(publicKeyword.map((poi) => poi.id)).toEqual(['space-space-public-rest']) expect(defaultResults.map((poi) => poi.id)).not.toEqual(expect.arrayContaining([ 'restroom', 'elevator', @@ -160,9 +182,13 @@ describe('GuideRepository visitor search contracts', () => { // survive model marker deduplication. expect(renderPoiIds.has('restroom')).toBe(true) expect(renderPoiIds.has('elevator')).toBe(true) + expect(renderPoiIds.has('space-space-public-rest')).toBe(true) for (const hiddenPoiId of ['door', 'anchor', 'route-node', 'guide-stop']) { expect(renderPoiIds.has(hiddenPoiId)).toBe(false) } + for (const privatePoiId of ['space-space-private-tea', 'space-space-private-vip']) { + expect(renderPoiIds.has(privatePoiId)).toBe(false) + } const quickResultIds = (await Promise.all(allQuickCategoryIds.map((categoryId) => ( repository.listQuickFindPois(categoryId, 'L1') )))).flat().map((poi) => poi.id) @@ -188,6 +214,115 @@ describe('GuideRepository visitor search contracts', () => { expect(hiddenKeyword).toEqual([]) }) + it('does not resolve private direct IDs outside the visitor search pool', async () => { + const repository = new SgsSdkGuideRepository(createSgsProvider({ + queryPois: vi.fn().mockResolvedValue([{ + id: 'private-direct-space', + name: '茶水间', + type: 'service_space', + floorId: 'L1', + visitorVisible: false, + position: { x: 30, y: 12, z: 30 } + }]) + })) + + await expect(repository.getPoiById('private-direct-space')).resolves.toBeNull() + }) + + it('keeps private and structural space duplicates out of search lists and map markers', async () => { + const provider = createSgsProvider({ + getFloorPois: vi.fn().mockResolvedValue([ + { + id: 'poi-private-dining', + name: '西侧餐饮区', + type: 'restaurant', + floorId: 'L1', + spatialAreaId: 'space-private-dining', + position: { x: 30, y: 12, z: 30 } + }, + { + id: 'poi-service-space', + name: '服务空间', + type: 'service_space', + floorId: 'L1', + position: { x: 31, y: 12, z: 31 } + } + ]), + getFloorBusinessPois: vi.fn().mockResolvedValue([]), + getFloorSpaces: vi.fn().mockResolvedValue([ + { + id: 'space-private-dining', + name: '西侧餐饮区', + type: 'restaurant', + floorId: 'L1', + visitorVisible: false, + center: { x: 30, y: 12, z: 30 } + }, + { + id: 'space-private-cinema', + name: '私享影院', + type: 'theater', + floorId: 'L1', + visitorVisible: false, + center: { x: 32, y: 12, z: 32 } + }, + { + id: 'space-route-node', + name: '导航区域', + type: 'navigable_place', + floorId: 'L1', + center: { x: 33, y: 12, z: 33 } + } + ]), + getNavigablePlaces: vi.fn().mockResolvedValue([]) + }) + const repository = new SgsSdkGuideRepository(provider) + const modelRepository = new SgsSdkGuideModelRepository(provider, repository) + + const [defaultResults, dining, cinema, privateKeyword, serviceKeyword, structuralKeyword, renderPois] = await Promise.all([ + repository.listDestinationPois('L1'), + repository.listQuickFindPois('dining', 'L1'), + repository.listQuickFindPois('cinema', 'L1'), + repository.searchPois('西侧餐饮区', 'L1'), + repository.searchPois('服务空间', 'L1'), + repository.searchPois('导航区域', 'L1'), + modelRepository.loadFloorPois('L1') + ]) + + expect(defaultResults).toEqual([]) + expect(dining).toEqual([]) + expect(cinema).toEqual([]) + expect(privateKeyword).toEqual([]) + expect(serviceKeyword).toEqual([]) + expect(structuralKeyword).toEqual([]) + expect(renderPois).toEqual([]) + }) + + it('merges source-place duplicates before returning canonical visitor IDs', () => { + const basePoi = { + floorId: 'L1', + floorLabel: '1F', + primaryCategory: { id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }, + categories: [{ id: 'space_restaurant', label: '餐饮', iconType: 'restaurant' }], + positionGltf: [40, 12, 40] as [number, number, number], + accessible: false, + kind: 'facility' as const, + sourcePlaceId: 'place-cafe' + } + + const results = canonicalizeVisitorSearchPois([ + { ...basePoi, id: 'poi-cafe-a', name: '中庭咖啡' }, + { + ...basePoi, + id: 'poi-cafe-b', + name: '中庭咖啡取餐台', + positionGltf: [41, 12, 41] + } + ]) + + expect(results.map((poi) => poi.id)).toEqual(['poi-cafe-a']) + }) + it('deduplicates co-located sources when they do not provide a shared spatial area ID', async () => { const repository = new SgsSdkGuideRepository(createSgsProvider({ getFloorPois: vi.fn().mockResolvedValue([{ @@ -348,6 +483,24 @@ describe('GuideRepository visitor search contracts', () => { primaryCategoryZh: '基础服务设施', iconType: 'restroom', positionGltf: position(4) + }, + { + id: 'static-vip-reception', + name: '贵宾接待区', + floorId: 'L1', + primaryCategory: 'basic_service_facility', + primaryCategoryZh: '基础服务设施', + iconType: 'restroom', + positionGltf: position(5) + }, + { + id: 'static-vip-restroom', + name: '贵宾卫生间', + floorId: 'L1', + primaryCategory: 'basic_service_facility', + primaryCategoryZh: '基础服务设施', + iconType: 'restroom', + positionGltf: position(6) } ]) } as unknown as StaticNavAssetsProvider @@ -362,6 +515,10 @@ describe('GuideRepository visitor search contracts', () => { await expect(repository.searchPois('洗手间', 'L1')).resolves.toMatchObject([ { id: 'static-restroom' } ]) + await expect(repository.searchPois('茶水间', 'L1')).resolves.toEqual([]) + await expect(repository.searchPois('贵宾', 'L1')).resolves.toEqual([]) + await expect(repository.getPoiById('static-tea-room')).resolves.toBeNull() + await expect(repository.getPoiById('static-vip-restroom')).resolves.toBeNull() await expect(repository.searchPois('服务', 'L1')).resolves.toEqual([ expect.objectContaining({ id: 'static-service-desk' }) ]) @@ -397,6 +554,24 @@ describe('GuideRepository visitor search contracts', () => { primaryCategoryZh: '讲解点', iconType: 'guide', positionGltf: position(2) + }, + { + id: 'static-private-tea', + name: '茶水间', + floorId: 'L1', + primaryCategory: 'basic_service_facility', + primaryCategoryZh: '基础服务设施', + iconType: 'restroom', + positionGltf: position(3) + }, + { + id: 'static-vip-restroom', + name: '贵宾卫生间', + floorId: 'L1', + primaryCategory: 'basic_service_facility', + primaryCategoryZh: '基础服务设施', + iconType: 'restroom', + positionGltf: position(4) } ]) } as unknown as StaticNavAssetsProvider diff --git a/tests/unit/finalizeH5BuildPolicy.spec.ts b/tests/unit/finalizeH5BuildPolicy.spec.ts new file mode 100644 index 0000000..33c7a05 --- /dev/null +++ b/tests/unit/finalizeH5BuildPolicy.spec.ts @@ -0,0 +1,86 @@ +import { createRequire } from 'node:module' +import { describe, expect, it, vi } from 'vitest' + +const require = createRequire(import.meta.url) +const { + isTextBuildFile, + resolveH5BuildPolicy, + tencentMapKeyPlaceholder +} = require('../../scripts/finalize-h5-build-policy.cjs') as { + isTextBuildFile: (filePath: string) => boolean + resolveH5BuildPolicy: (options: { + args?: string[] + projectRoot: string + environment?: Record + loadProductionEnv?: (root: string) => Record + }) => { + allowPlaceholder: boolean + requireTencentMapKey: boolean + tencentMapKey: string + } + tencentMapKeyPlaceholder: string +} + +const projectRoot = 'C:/museum-guide' +const validTencentMapKey = 'ABCDE-FGHIJ-KLMNO-PQRST-UVWXY-12345' + +describe('finalize H5 build policy', () => { + it('keeps test builds isolated from production map credentials', () => { + const loadProductionEnv = vi.fn(() => ({ + VITE_TENCENT_MAP_KEY: validTencentMapKey + })) + + expect(resolveH5BuildPolicy({ + args: ['--allow-placeholder'], + projectRoot, + environment: { VITE_TENCENT_MAP_KEY: validTencentMapKey }, + loadProductionEnv + })).toEqual({ + allowPlaceholder: true, + requireTencentMapKey: false, + tencentMapKey: '' + }) + expect(loadProductionEnv).not.toHaveBeenCalled() + }) + + it('loads a well-formed production key only for the production gate', () => { + const loadProductionEnv = vi.fn(() => ({ + VITE_TENCENT_MAP_KEY: validTencentMapKey + })) + + expect(resolveH5BuildPolicy({ + args: ['--require-tencent-map-key'], + projectRoot, + environment: {}, + loadProductionEnv + })).toEqual({ + allowPlaceholder: false, + requireTencentMapKey: true, + tencentMapKey: validTencentMapKey + }) + expect(loadProductionEnv).toHaveBeenCalledWith(projectRoot) + }) + + it('rejects conflicting flags, placeholders, and malformed production keys', () => { + expect(() => resolveH5BuildPolicy({ + args: ['--allow-placeholder', '--require-tencent-map-key'], + projectRoot, + environment: {} + })).toThrow('cannot require a Tencent map key') + + for (const key of ['', tencentMapKeyPlaceholder, 'fake-key']) { + expect(() => resolveH5BuildPolicy({ + args: ['--require-tencent-map-key'], + projectRoot, + environment: { VITE_TENCENT_MAP_KEY: key }, + loadProductionEnv: () => ({}) + })).toThrow('well-formed non-placeholder') + } + }) + + it('scans source maps as text build artifacts', () => { + expect(isTextBuildFile('assets/index.js.map')).toBe(true) + expect(isTextBuildFile('assets/index.js')).toBe(true) + expect(isTextBuildFile('assets/model.glb')).toBe(false) + }) +}) diff --git a/tests/unit/navAssetsAdapter.spec.ts b/tests/unit/navAssetsAdapter.spec.ts index f40237c..42999b8 100644 --- a/tests/unit/navAssetsAdapter.spec.ts +++ b/tests/unit/navAssetsAdapter.spec.ts @@ -1,6 +1,6 @@ import { describe, expect, it } from 'vitest' import { toMuseumPoi } from '@/data/adapters/navAssetsAdapter' -import { resolvePoiCategory } from '@/domain/poiCategories' +import { isVisitorSearchPoi, resolvePoiCategory } from '@/domain/poiCategories' import type { StaticNavPoiPayload } from '@/data/providers/staticNavAssetsProvider' const createStaticPoi = (name: string, iconType = 'elevator'): StaticNavPoiPayload => ({ @@ -52,10 +52,23 @@ describe('static nav POI adapter', () => { const serviceDesk = toMuseumPoi(createStaticPoi('服务台', 'restroom')) const locker = toMuseumPoi(createStaticPoi('存包处', 'restroom')) const teaRoom = toMuseumPoi(createStaticPoi('茶水间', 'restroom')) + const vipReception = toMuseumPoi(createStaticPoi('贵宾接待区', 'restroom')) + const vipRestroom = toMuseumPoi(createStaticPoi('贵宾卫生间', 'restroom')) expect(resolvePoiCategory(serviceDesk)?.id).toBe('service-center') expect(resolvePoiCategory(locker)?.id).toBe('service-center') expect(resolvePoiCategory(teaRoom)).toBeNull() + expect(teaRoom.visitorVisible).toBe(false) + expect(vipReception.visitorVisible).toBe(false) + expect(vipRestroom.visitorVisible).toBe(false) + expect(toMuseumPoi({ + ...createStaticPoi('茶水间', 'restroom'), + visitorVisible: true + }).visitorVisible).toBe(true) + expect(toMuseumPoi({ + ...createStaticPoi('贵宾卫生间', 'restroom'), + visitorVisible: true + }).visitorVisible).toBe(true) }) it('splits generic touring fallback records into their visitor-facing semantic kinds', () => { @@ -73,4 +86,19 @@ describe('static nav POI adapter', () => { }) expect(resolvePoiCategory(ticketOffice)?.id).toBe('ticket-office') }) + + it('keeps a hall visible when its secondary category describes an entrance', () => { + const hall = toMuseumPoi(createTouringPoi('地球展厅')) + hall.kind = 'hall' + hall.categories = [ + hall.primaryCategory, + { + id: 'exhibition_hall_entrance', + label: '展厅出入口', + iconType: 'hall_entrance' + } + ] + + expect(isVisitorSearchPoi(hall)).toBe(true) + }) })