@realsee/vr-cli 0.1.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/NOTICE.md +7 -0
- package/README.md +216 -0
- package/TERMS.txt +54 -0
- package/agent-assets/AGENTS.md +113 -0
- package/agent-assets/adapters/.cursor/rules/realsee-vr.mdc +7 -0
- package/agent-assets/adapters/.github/copilot-instructions.md +1 -0
- package/agent-assets/adapters/.kiro/steering/realsee-vr.md +6 -0
- package/agent-assets/adapters/CLAUDE.md +3 -0
- package/bin/vr-cli.mjs +3 -0
- package/dist/cli.mjs +24 -0
- package/package.json +38 -0
- package/skills/realsee-image-resources/SKILL.md +82 -0
- package/skills/realsee-image-resources/references/cors-and-three-textures.md +50 -0
- package/skills/realsee-image-resources/references/data-wanxiang.md +57 -0
- package/skills/realsee-image-resources/references/five-image-url-transform.md +81 -0
- package/skills/realsee-image-resources/references/source-and-domain-policy.md +63 -0
- package/skills/realsee-vr-app/SKILL.md +157 -0
- package/skills/realsee-vr-app/references/continuous-improvement.md +24 -0
- package/skills/realsee-vr-app/references/dnalogel-api-capabilities.md +241 -0
- package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +818 -0
- package/skills/realsee-vr-app/references/five-api-capabilities.md +119 -0
- package/skills/realsee-vr-app/references/five-runtime.md +83 -0
- package/skills/realsee-vr-app/references/official-docs.md +61 -0
- package/skills/realsee-vr-app/references/plugin-lifecycle.md +65 -0
- package/skills/realsee-vr-app/references/ui-and-validation.md +57 -0
- package/skills/realsee-vr-app/references/vapor-dom-layer.md +186 -0
- package/skills/realsee-vr-app/scripts/verify-project.mjs +1566 -0
- package/skills/threejs-r117-five/SKILL.md +71 -0
- package/skills/threejs-r117-five/references/five-ownership.md +70 -0
- package/skills/threejs-r117-five/references/r117-capabilities.md +82 -0
- package/skills/threejs-r117-five/references/r117-version-guards.md +60 -0
- package/templates/base/.editorconfig +12 -0
- package/templates/base/.env.example +12 -0
- package/templates/base/.gitignore.template +6 -0
- package/templates/base/.node-version +1 -0
- package/templates/base/.npmrc.template +3 -0
- package/templates/base/.nvmrc +1 -0
- package/templates/base/.prettierignore +12 -0
- package/templates/base/README.md +160 -0
- package/templates/base/eslint.config.mjs +75 -0
- package/templates/base/index.html +13 -0
- package/templates/base/package-lock.json +6444 -0
- package/templates/base/package.json +55 -0
- package/templates/base/prettier.config.mjs +5 -0
- package/templates/base/server/app.ts +54 -0
- package/templates/base/server/check-config.ts +4 -0
- package/templates/base/server/config.ts +54 -0
- package/templates/base/server/index.ts +31 -0
- package/templates/base/server/realsee-open-api.ts +316 -0
- package/templates/base/server/types.ts +64 -0
- package/templates/base/server/work-route.ts +80 -0
- package/templates/base/server/work-service.ts +54 -0
- package/templates/base/shared/model-floorplan.ts +242 -0
- package/templates/base/src/main.tsx +11 -0
- package/templates/base/src/shared/useElementSize.ts +28 -0
- package/templates/base/src/styles.css +91 -0
- package/templates/base/src/vr/five/SpatialRuntimeProvider.tsx +234 -0
- package/templates/base/src/vr/five/api.ts +10 -0
- package/templates/base/src/vr/five/fiveConfig.ts +42 -0
- package/templates/base/src/vr/five/initialState.ts +48 -0
- package/templates/base/src/vr/plugins/SpatialPluginRegistry.ts +104 -0
- package/templates/base/src/vr/plugins/dnalogel.ts +2 -0
- package/templates/base/src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts +73 -0
- package/templates/base/src/vr/work/WorkSource.ts +247 -0
- package/templates/base/tsconfig.json +27 -0
- package/templates/base/tsconfig.server.json +25 -0
- package/templates/base/vite.config.ts +21 -0
- package/templates/viewer/src/app/App.tsx +182 -0
- package/templates/viewer/src/features/floorplan/ModelFloorplanFeature.ts +141 -0
- package/templates/viewer/src/features/navigation/ModeSwitcher.tsx +111 -0
- package/templates/viewer/src/features/tags/PanoTagFeature.ts +110 -0
- package/templates/viewer/src/ui/ViewerOverlay.tsx +141 -0
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "__PACKAGE_NAME__",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"packageManager": "npm@11.19.0",
|
|
7
|
+
"scripts": {
|
|
8
|
+
"dev": "npm run check:env && concurrently --kill-others-on-fail --names bff,web \"npm:dev:bff\" \"npm:dev:web\"",
|
|
9
|
+
"dev:bff": "node --env-file-if-exists=.env.development --watch server/index.ts",
|
|
10
|
+
"dev:web": "vite",
|
|
11
|
+
"check:env": "node --env-file-if-exists=.env.development server/check-config.ts",
|
|
12
|
+
"typecheck": "npm run typecheck:web && npm run typecheck:bff",
|
|
13
|
+
"typecheck:web": "tsc -p tsconfig.json",
|
|
14
|
+
"typecheck:bff": "tsc -p tsconfig.server.json",
|
|
15
|
+
"lint": "eslint . --max-warnings=0",
|
|
16
|
+
"format": "prettier --write .",
|
|
17
|
+
"format:check": "prettier --check .",
|
|
18
|
+
"check:bff": "npm run typecheck:bff",
|
|
19
|
+
"check": "npm run typecheck && npm run lint && npm run format:check",
|
|
20
|
+
"deps:relock": "npm install --package-lock-only",
|
|
21
|
+
"deps:install": "npm ci",
|
|
22
|
+
"build": "npm run check && vite build",
|
|
23
|
+
"start": "node --env-file-if-exists=.env.development server/index.ts --serve-frontend",
|
|
24
|
+
"preview": "npm run start"
|
|
25
|
+
},
|
|
26
|
+
"dependencies": {
|
|
27
|
+
"@fastify/static": ">=10.1.3",
|
|
28
|
+
"@realsee/dnalogel": ">=3.81.2",
|
|
29
|
+
"@realsee/five": ">=6.8.9",
|
|
30
|
+
"@realsee/vapor": ">=0.2.12",
|
|
31
|
+
"fastify": ">=5.12.1",
|
|
32
|
+
"react": ">=18.3.1",
|
|
33
|
+
"react-dom": ">=18.3.1",
|
|
34
|
+
"three": "0.117.1"
|
|
35
|
+
},
|
|
36
|
+
"devDependencies": {
|
|
37
|
+
"@eslint/js": ">=10.0.1",
|
|
38
|
+
"@types/node": ">=24.13.3",
|
|
39
|
+
"@types/react": ">=18.3.12",
|
|
40
|
+
"@types/react-dom": ">=18.3.1",
|
|
41
|
+
"@vitejs/plugin-react": ">=4.3.1",
|
|
42
|
+
"concurrently": ">=10.0.5",
|
|
43
|
+
"eslint": ">=10.9.1",
|
|
44
|
+
"eslint-plugin-react-hooks": ">=7.1.1",
|
|
45
|
+
"globals": ">=17.11.0",
|
|
46
|
+
"prettier": ">=3.9.6",
|
|
47
|
+
"typescript": ">=5.9.3",
|
|
48
|
+
"typescript-eslint": ">=8.68.0",
|
|
49
|
+
"vite": ">=5.4.14"
|
|
50
|
+
},
|
|
51
|
+
"engines": {
|
|
52
|
+
"node": ">=22.18.0",
|
|
53
|
+
"npm": ">=11.19.0"
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import path from 'node:path'
|
|
2
|
+
import { fileURLToPath } from 'node:url'
|
|
3
|
+
import Fastify, { type FastifyInstance } from 'fastify'
|
|
4
|
+
import fastifyStatic from '@fastify/static'
|
|
5
|
+
import { workRoutes } from './work-route.ts'
|
|
6
|
+
import type { WorkService } from './types.ts'
|
|
7
|
+
|
|
8
|
+
const PROJECT_ROOT = fileURLToPath(new URL('..', import.meta.url))
|
|
9
|
+
|
|
10
|
+
export interface CreateAppOptions {
|
|
11
|
+
readonly workService: WorkService
|
|
12
|
+
readonly logger?: boolean
|
|
13
|
+
readonly serveFrontend?: boolean
|
|
14
|
+
readonly frontendRoot?: string
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function createApp({
|
|
18
|
+
workService,
|
|
19
|
+
logger = true,
|
|
20
|
+
serveFrontend = false,
|
|
21
|
+
frontendRoot = path.join(PROJECT_ROOT, 'dist'),
|
|
22
|
+
}: CreateAppOptions): Promise<FastifyInstance> {
|
|
23
|
+
const app = Fastify({ logger })
|
|
24
|
+
|
|
25
|
+
app.get('/api/health', () => ({ ok: true }))
|
|
26
|
+
await app.register(workRoutes, { workService })
|
|
27
|
+
|
|
28
|
+
if (serveFrontend) {
|
|
29
|
+
await app.register(fastifyStatic, {
|
|
30
|
+
root: frontendRoot,
|
|
31
|
+
prefix: '/',
|
|
32
|
+
})
|
|
33
|
+
app.setNotFoundHandler((request, reply) => {
|
|
34
|
+
const pathname = new URL(request.raw.url ?? '/', 'http://localhost')
|
|
35
|
+
.pathname
|
|
36
|
+
const acceptsHtml = request.headers.accept
|
|
37
|
+
?.split(',')
|
|
38
|
+
.some((value) => value.trim().startsWith('text/html'))
|
|
39
|
+
const isApiPath = pathname === '/api' || pathname.startsWith('/api/')
|
|
40
|
+
const hasFileExtension = path.posix.extname(pathname).length > 0
|
|
41
|
+
if (
|
|
42
|
+
request.raw.method === 'GET' &&
|
|
43
|
+
acceptsHtml &&
|
|
44
|
+
!isApiPath &&
|
|
45
|
+
!hasFileExtension
|
|
46
|
+
) {
|
|
47
|
+
return reply.sendFile('index.html')
|
|
48
|
+
}
|
|
49
|
+
return reply.code(404).send({ error: 'NOT_FOUND', requestId: request.id })
|
|
50
|
+
})
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
return app
|
|
54
|
+
}
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import type { ServerConfig } from './types.ts'
|
|
2
|
+
|
|
3
|
+
const DEFAULT_GATEWAY_ENDPOINT = 'https://app-gateway.realsee.cn'
|
|
4
|
+
|
|
5
|
+
type ServerEnv = Record<string, string | undefined>
|
|
6
|
+
|
|
7
|
+
export interface LoadServerConfigOptions {
|
|
8
|
+
readonly env?: ServerEnv
|
|
9
|
+
readonly serveFrontend?: boolean
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
const nonEmptyString = (value: string | undefined): value is string =>
|
|
13
|
+
typeof value === 'string' && value.trim().length > 0
|
|
14
|
+
|
|
15
|
+
function requiredCredential(env: ServerEnv, key: string): string {
|
|
16
|
+
const value = env[key]?.trim()
|
|
17
|
+
if (!nonEmptyString(value) || value === '*') {
|
|
18
|
+
throw new Error(
|
|
19
|
+
`${key} 未配置。请从 .env.example 创建 .env.development,并填写服务端凭据。`,
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
return value
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
function parsePort(value: string | undefined, fallback: number): number {
|
|
26
|
+
if (!nonEmptyString(value)) return fallback
|
|
27
|
+
const port = Number(value)
|
|
28
|
+
if (!Number.isInteger(port) || port < 1 || port > 65535) {
|
|
29
|
+
throw new Error(`BFF_PORT 必须是 1-65535 的整数,当前为:${value}`)
|
|
30
|
+
}
|
|
31
|
+
return port
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function loadServerConfig({
|
|
35
|
+
env = process.env,
|
|
36
|
+
serveFrontend = false,
|
|
37
|
+
}: LoadServerConfigOptions = {}): ServerConfig {
|
|
38
|
+
const gatewayEndpoint = new URL(
|
|
39
|
+
env.REALSEE_GATEWAY_ENDPOINT?.trim() || DEFAULT_GATEWAY_ENDPOINT,
|
|
40
|
+
)
|
|
41
|
+
if (gatewayEndpoint.protocol !== 'https:') {
|
|
42
|
+
throw new Error(
|
|
43
|
+
'REALSEE_GATEWAY_ENDPOINT 必须使用 HTTPS,避免泄露服务端凭据。',
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
return Object.freeze({
|
|
48
|
+
host: env.BFF_HOST?.trim() || '127.0.0.1',
|
|
49
|
+
port: parsePort(env.BFF_PORT, serveFrontend ? 8087 : 8088),
|
|
50
|
+
gatewayEndpoint: gatewayEndpoint.href.replace(/\/$/, ''),
|
|
51
|
+
appKey: requiredCredential(env, 'REALSEE_GATEWAY_APP_KEY'),
|
|
52
|
+
appSecret: requiredCredential(env, 'REALSEE_GATEWAY_APP_SECRET'),
|
|
53
|
+
})
|
|
54
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { createApp } from './app.ts'
|
|
2
|
+
import { loadServerConfig } from './config.ts'
|
|
3
|
+
import { RealseeOpenApiClient } from './realsee-open-api.ts'
|
|
4
|
+
import { createWorkService } from './work-service.ts'
|
|
5
|
+
|
|
6
|
+
const serveFrontend = process.argv.includes('--serve-frontend')
|
|
7
|
+
const config = loadServerConfig({ serveFrontend })
|
|
8
|
+
const openApiClient = new RealseeOpenApiClient(config)
|
|
9
|
+
const workService = createWorkService({ openApiClient })
|
|
10
|
+
const app = await createApp({ workService, serveFrontend })
|
|
11
|
+
|
|
12
|
+
let closing = false
|
|
13
|
+
const close = async (signal: string): Promise<void> => {
|
|
14
|
+
if (closing) return
|
|
15
|
+
closing = true
|
|
16
|
+
app.log.info({ signal }, 'Shutting down Fastify BFF')
|
|
17
|
+
await app.close()
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
for (const signal of ['SIGINT', 'SIGTERM']) {
|
|
21
|
+
process.once(signal, () => {
|
|
22
|
+
void close(signal).finally(() => process.exit(0))
|
|
23
|
+
})
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
try {
|
|
27
|
+
await app.listen({ host: config.host, port: config.port })
|
|
28
|
+
} catch (error) {
|
|
29
|
+
app.log.error(error)
|
|
30
|
+
process.exitCode = 1
|
|
31
|
+
}
|
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
import { asModelFloorplanServerData } from '../shared/model-floorplan.ts'
|
|
2
|
+
import type { ModelFloorplanServerData } from '../shared/model-floorplan.ts'
|
|
3
|
+
import type {
|
|
4
|
+
RawRecord,
|
|
5
|
+
RealseeSpatialClient,
|
|
6
|
+
RequestOptions,
|
|
7
|
+
ServerConfig,
|
|
8
|
+
UpstreamEnvelope,
|
|
9
|
+
} from './types.ts'
|
|
10
|
+
|
|
11
|
+
// Official contracts: POST /auth/access_token, GET /open/v3/vr/info,
|
|
12
|
+
// and GET /open/v3/floorplan/plugins. Authorization carries the raw access token.
|
|
13
|
+
const ACCESS_TOKEN_PATH = '/auth/access_token'
|
|
14
|
+
const WORK_INFO_PATH = '/open/v3/vr/info'
|
|
15
|
+
const FLOORPLAN_PLUGINS_PATH = '/open/v3/floorplan/plugins'
|
|
16
|
+
const TOKEN_REFRESH_SKEW_MS = 30_000
|
|
17
|
+
const DEFAULT_REQUEST_TIMEOUT_MS = 10_000
|
|
18
|
+
|
|
19
|
+
const isRecord = (value: unknown): value is RawRecord =>
|
|
20
|
+
Boolean(value && typeof value === 'object' && !Array.isArray(value))
|
|
21
|
+
const asRecord = (value: unknown): RawRecord => (isRecord(value) ? value : {})
|
|
22
|
+
const firstString = (...values: unknown[]): string | undefined =>
|
|
23
|
+
values.find(
|
|
24
|
+
(value): value is string => typeof value === 'string' && value.length > 0,
|
|
25
|
+
)
|
|
26
|
+
const upstreamCode = (payload: UpstreamEnvelope): number => Number(payload.code)
|
|
27
|
+
// The gateway reports auth failures as HTTP 200 with a negative business code
|
|
28
|
+
// (for example `-3 authorization parse fail`), so HTTP 401 alone is not enough.
|
|
29
|
+
const UNAUTHORIZED_UPSTREAM_CODES = new Set([401, -3])
|
|
30
|
+
const isUnauthorized = (
|
|
31
|
+
response: Response,
|
|
32
|
+
payload: UpstreamEnvelope,
|
|
33
|
+
): boolean =>
|
|
34
|
+
response.status === 401 ||
|
|
35
|
+
UNAUTHORIZED_UPSTREAM_CODES.has(upstreamCode(payload))
|
|
36
|
+
|
|
37
|
+
function expirationTime(value: unknown): number {
|
|
38
|
+
const numeric = Number(value)
|
|
39
|
+
if (!Number.isFinite(numeric) || numeric <= 0) return 0
|
|
40
|
+
return numeric < 1_000_000_000_000 ? numeric * 1000 : numeric
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface RealseeOpenApiErrorOptions {
|
|
44
|
+
readonly responseStatus?: number
|
|
45
|
+
readonly responseCode?: unknown
|
|
46
|
+
readonly statusCode?: number
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export class RealseeOpenApiError extends Error {
|
|
50
|
+
readonly responseStatus: number
|
|
51
|
+
readonly responseCode: unknown
|
|
52
|
+
readonly statusCode: number
|
|
53
|
+
|
|
54
|
+
constructor(message: string, options: RealseeOpenApiErrorOptions = {}) {
|
|
55
|
+
super(message)
|
|
56
|
+
const { responseStatus = 0, responseCode, statusCode } = options
|
|
57
|
+
this.name = 'RealseeOpenApiError'
|
|
58
|
+
this.responseStatus = responseStatus
|
|
59
|
+
this.responseCode = responseCode
|
|
60
|
+
this.statusCode =
|
|
61
|
+
statusCode ??
|
|
62
|
+
(responseStatus === 404 || Number(responseCode) === 404 ? 404 : 502)
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
type FetchImpl = typeof globalThis.fetch
|
|
67
|
+
|
|
68
|
+
export interface RealseeOpenApiClientOptions extends Pick<
|
|
69
|
+
ServerConfig,
|
|
70
|
+
'gatewayEndpoint' | 'appKey' | 'appSecret'
|
|
71
|
+
> {
|
|
72
|
+
readonly fetchImpl?: FetchImpl
|
|
73
|
+
readonly now?: () => number
|
|
74
|
+
readonly requestTimeoutMs?: number
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
interface UpstreamResult {
|
|
78
|
+
readonly response: Response
|
|
79
|
+
readonly payload: UpstreamEnvelope
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
export class RealseeOpenApiClient implements RealseeSpatialClient {
|
|
83
|
+
readonly gatewayEndpoint: string
|
|
84
|
+
readonly appKey: string
|
|
85
|
+
readonly appSecret: string
|
|
86
|
+
readonly fetchImpl: FetchImpl
|
|
87
|
+
readonly now: () => number
|
|
88
|
+
readonly requestTimeoutMs: number
|
|
89
|
+
|
|
90
|
+
#accessToken = ''
|
|
91
|
+
#expiresAt = 0
|
|
92
|
+
#tokenPromise: Promise<string> | null = null
|
|
93
|
+
|
|
94
|
+
constructor({
|
|
95
|
+
gatewayEndpoint,
|
|
96
|
+
appKey,
|
|
97
|
+
appSecret,
|
|
98
|
+
fetchImpl = globalThis.fetch,
|
|
99
|
+
now = Date.now,
|
|
100
|
+
requestTimeoutMs = DEFAULT_REQUEST_TIMEOUT_MS,
|
|
101
|
+
}: RealseeOpenApiClientOptions) {
|
|
102
|
+
if (typeof fetchImpl !== 'function')
|
|
103
|
+
throw new Error('RealseeOpenApiClient 需要 fetch 实现。')
|
|
104
|
+
if (!Number.isFinite(requestTimeoutMs) || requestTimeoutMs <= 0)
|
|
105
|
+
throw new Error('requestTimeoutMs 必须是正数。')
|
|
106
|
+
this.gatewayEndpoint = gatewayEndpoint.replace(/\/$/, '')
|
|
107
|
+
this.appKey = appKey
|
|
108
|
+
this.appSecret = appSecret
|
|
109
|
+
this.fetchImpl = fetchImpl
|
|
110
|
+
this.now = now
|
|
111
|
+
this.requestTimeoutMs = requestTimeoutMs
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
#requestSignal(signal: AbortSignal | undefined): AbortSignal {
|
|
115
|
+
const timeoutSignal = AbortSignal.timeout(this.requestTimeoutMs)
|
|
116
|
+
return signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
async #fetchJson(
|
|
120
|
+
requestUrl: string,
|
|
121
|
+
init: RequestInit,
|
|
122
|
+
): Promise<UpstreamResult> {
|
|
123
|
+
let response: Response
|
|
124
|
+
try {
|
|
125
|
+
response = await this.fetchImpl(requestUrl, init)
|
|
126
|
+
} catch (error) {
|
|
127
|
+
const timedOut = error instanceof Error && error.name === 'TimeoutError'
|
|
128
|
+
throw new RealseeOpenApiError(
|
|
129
|
+
timedOut
|
|
130
|
+
? `Realsee OpenAPI 请求超过 ${this.requestTimeoutMs}ms。`
|
|
131
|
+
: `Realsee OpenAPI 网络请求失败:${error instanceof Error ? error.message : String(error)}`,
|
|
132
|
+
{ statusCode: timedOut ? 504 : 502 },
|
|
133
|
+
)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
let payload: UpstreamEnvelope
|
|
137
|
+
try {
|
|
138
|
+
payload = asRecord(await response.json())
|
|
139
|
+
} catch {
|
|
140
|
+
throw new RealseeOpenApiError('Realsee OpenAPI 返回了无效 JSON。', {
|
|
141
|
+
responseStatus: response.status,
|
|
142
|
+
})
|
|
143
|
+
}
|
|
144
|
+
return { response, payload }
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
#assertSuccess(
|
|
148
|
+
response: Response,
|
|
149
|
+
payload: UpstreamEnvelope,
|
|
150
|
+
operation: string,
|
|
151
|
+
): void {
|
|
152
|
+
const code = upstreamCode(payload)
|
|
153
|
+
if (!response.ok || code !== 0) {
|
|
154
|
+
const message =
|
|
155
|
+
firstString(payload.status, payload.msg, payload.message) ||
|
|
156
|
+
`HTTP ${response.status}`
|
|
157
|
+
throw new RealseeOpenApiError(`${operation}失败:${message}`, {
|
|
158
|
+
responseStatus: response.status,
|
|
159
|
+
responseCode: payload.code,
|
|
160
|
+
})
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
async #exchangeAccessToken(): Promise<string> {
|
|
165
|
+
const body = new URLSearchParams({
|
|
166
|
+
app_key: this.appKey,
|
|
167
|
+
app_secret: this.appSecret,
|
|
168
|
+
})
|
|
169
|
+
const { response, payload } = await this.#fetchJson(
|
|
170
|
+
`${this.gatewayEndpoint}${ACCESS_TOKEN_PATH}`,
|
|
171
|
+
{
|
|
172
|
+
method: 'POST',
|
|
173
|
+
headers: {
|
|
174
|
+
Accept: 'application/json',
|
|
175
|
+
'Content-Type': 'application/x-www-form-urlencoded',
|
|
176
|
+
},
|
|
177
|
+
body,
|
|
178
|
+
signal: this.#requestSignal(undefined),
|
|
179
|
+
},
|
|
180
|
+
)
|
|
181
|
+
this.#assertSuccess(response, payload, '获取 access token')
|
|
182
|
+
|
|
183
|
+
const data = asRecord(payload.data)
|
|
184
|
+
const accessToken = firstString(data.access_token, payload.access_token)
|
|
185
|
+
const expiresAt = expirationTime(data.expire_at ?? payload.expire_at)
|
|
186
|
+
if (!accessToken || expiresAt <= this.now()) {
|
|
187
|
+
throw new RealseeOpenApiError(
|
|
188
|
+
'access token 响应缺少有效的 access_token 或 expire_at。',
|
|
189
|
+
)
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
this.#accessToken = accessToken
|
|
193
|
+
this.#expiresAt = expiresAt
|
|
194
|
+
return accessToken
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
async #getAccessToken(): Promise<string> {
|
|
198
|
+
if (
|
|
199
|
+
this.#accessToken &&
|
|
200
|
+
this.now() < this.#expiresAt - TOKEN_REFRESH_SKEW_MS
|
|
201
|
+
) {
|
|
202
|
+
return this.#accessToken
|
|
203
|
+
}
|
|
204
|
+
if (this.#tokenPromise) return this.#tokenPromise
|
|
205
|
+
|
|
206
|
+
const promise = this.#exchangeAccessToken()
|
|
207
|
+
this.#tokenPromise = promise
|
|
208
|
+
try {
|
|
209
|
+
return await promise
|
|
210
|
+
} finally {
|
|
211
|
+
if (this.#tokenPromise === promise) this.#tokenPromise = null
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
#invalidateAccessToken(rejectedToken: string): void {
|
|
216
|
+
if (this.#accessToken !== rejectedToken) return
|
|
217
|
+
this.#accessToken = ''
|
|
218
|
+
this.#expiresAt = 0
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
async #requestAuthenticated(
|
|
222
|
+
pathname: string,
|
|
223
|
+
operation: string,
|
|
224
|
+
signal: AbortSignal | undefined,
|
|
225
|
+
): Promise<UpstreamEnvelope> {
|
|
226
|
+
const request = (accessToken: string) =>
|
|
227
|
+
this.#fetchJson(`${this.gatewayEndpoint}${pathname}`, {
|
|
228
|
+
method: 'GET',
|
|
229
|
+
headers: {
|
|
230
|
+
Accept: 'application/json',
|
|
231
|
+
Authorization: accessToken,
|
|
232
|
+
},
|
|
233
|
+
signal: this.#requestSignal(signal),
|
|
234
|
+
})
|
|
235
|
+
|
|
236
|
+
let accessToken = await this.#getAccessToken()
|
|
237
|
+
let result = await request(accessToken)
|
|
238
|
+
if (isUnauthorized(result.response, result.payload)) {
|
|
239
|
+
this.#invalidateAccessToken(accessToken)
|
|
240
|
+
accessToken = await this.#getAccessToken()
|
|
241
|
+
result = await request(accessToken)
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
this.#assertSuccess(result.response, result.payload, operation)
|
|
245
|
+
return result.payload
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
async getWorkDetail(
|
|
249
|
+
resourceCode: string,
|
|
250
|
+
{ signal }: RequestOptions = {},
|
|
251
|
+
): Promise<RawRecord> {
|
|
252
|
+
const query = new URLSearchParams({ resource_code: resourceCode })
|
|
253
|
+
const payload = await this.#requestAuthenticated(
|
|
254
|
+
`${WORK_INFO_PATH}?${query.toString()}`,
|
|
255
|
+
'获取 Work',
|
|
256
|
+
signal,
|
|
257
|
+
)
|
|
258
|
+
const work = asRecord(payload.data)
|
|
259
|
+
if (!Object.keys(work).length)
|
|
260
|
+
throw new RealseeOpenApiError('Work 响应缺少有效的 data 对象。')
|
|
261
|
+
return work
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
async getModelFloorplan(
|
|
265
|
+
resourceCode: string,
|
|
266
|
+
{ signal }: RequestOptions = {},
|
|
267
|
+
): Promise<ModelFloorplanServerData | undefined> {
|
|
268
|
+
const query = new URLSearchParams({ resource_code: resourceCode })
|
|
269
|
+
const descriptor = await this.#requestAuthenticated(
|
|
270
|
+
`${FLOORPLAN_PLUGINS_PATH}?${query.toString()}`,
|
|
271
|
+
'获取平面图插件描述',
|
|
272
|
+
signal,
|
|
273
|
+
)
|
|
274
|
+
const formatFloorplanUrl = firstString(
|
|
275
|
+
asRecord(descriptor.data).format_floorplan_url,
|
|
276
|
+
)
|
|
277
|
+
if (!formatFloorplanUrl) return undefined
|
|
278
|
+
|
|
279
|
+
let floorplanUrl: URL
|
|
280
|
+
try {
|
|
281
|
+
floorplanUrl = new URL(formatFloorplanUrl)
|
|
282
|
+
} catch {
|
|
283
|
+
throw new RealseeOpenApiError('format_floorplan_url 不是有效 URL。')
|
|
284
|
+
}
|
|
285
|
+
if (
|
|
286
|
+
floorplanUrl.protocol !== 'https:' ||
|
|
287
|
+
floorplanUrl.username ||
|
|
288
|
+
floorplanUrl.password
|
|
289
|
+
) {
|
|
290
|
+
throw new RealseeOpenApiError(
|
|
291
|
+
'format_floorplan_url 必须是无内嵌凭据的 HTTPS URL。',
|
|
292
|
+
)
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
const { response, payload } = await this.#fetchJson(floorplanUrl.href, {
|
|
296
|
+
method: 'GET',
|
|
297
|
+
headers: { Accept: 'application/json' },
|
|
298
|
+
redirect: 'error',
|
|
299
|
+
signal: this.#requestSignal(signal),
|
|
300
|
+
})
|
|
301
|
+
if (!response.ok) {
|
|
302
|
+
throw new RealseeOpenApiError(
|
|
303
|
+
`获取 format_floorplan_url 失败:HTTP ${response.status}`,
|
|
304
|
+
{ responseStatus: response.status },
|
|
305
|
+
)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const floorplan = asModelFloorplanServerData(payload)
|
|
309
|
+
if (!floorplan) {
|
|
310
|
+
throw new RealseeOpenApiError(
|
|
311
|
+
'format_floorplan_url 未返回有效的 { version, data: FloorplanServerData }。',
|
|
312
|
+
)
|
|
313
|
+
}
|
|
314
|
+
return floorplan
|
|
315
|
+
}
|
|
316
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { ModelFloorplanServerData } from '../shared/model-floorplan.ts'
|
|
2
|
+
|
|
3
|
+
// Shared server-side contracts. The browser only depends on the HTTP shape of
|
|
4
|
+
// WorkEnvelope, so a Go or other backend can replace this module entirely.
|
|
5
|
+
|
|
6
|
+
export type RawRecord = Record<string, unknown>
|
|
7
|
+
|
|
8
|
+
export interface ServerConfig {
|
|
9
|
+
readonly host: string
|
|
10
|
+
readonly port: number
|
|
11
|
+
readonly gatewayEndpoint: string
|
|
12
|
+
readonly appKey: string
|
|
13
|
+
readonly appSecret: string
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface RequestOptions {
|
|
17
|
+
readonly signal?: AbortSignal | undefined
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface WorkCapabilities {
|
|
21
|
+
readonly modelFloorplan?: ModelFloorplanServerData
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/** Browser-safe response envelope for `GET /api/work/{resourceCode}`. */
|
|
25
|
+
export interface WorkEnvelope {
|
|
26
|
+
readonly work: RawRecord
|
|
27
|
+
readonly workCode: string
|
|
28
|
+
readonly baseURL?: string
|
|
29
|
+
readonly capabilities?: WorkCapabilities
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Upstream adapter boundary: returns the raw Work payload for a resource code. */
|
|
33
|
+
export interface WorkDetailClient {
|
|
34
|
+
getWorkDetail(
|
|
35
|
+
resourceCode: string,
|
|
36
|
+
options?: RequestOptions,
|
|
37
|
+
): Promise<RawRecord>
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** Optional plugin-data boundary. Absence means the capability is unavailable. */
|
|
41
|
+
export interface ModelFloorplanClient {
|
|
42
|
+
getModelFloorplan(
|
|
43
|
+
resourceCode: string,
|
|
44
|
+
options?: RequestOptions,
|
|
45
|
+
): Promise<ModelFloorplanServerData | undefined>
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export interface RealseeSpatialClient
|
|
49
|
+
extends WorkDetailClient, ModelFloorplanClient {}
|
|
50
|
+
|
|
51
|
+
/** Framework-neutral service boundary consumed by the HTTP layer. */
|
|
52
|
+
export interface WorkService {
|
|
53
|
+
getWorkByResourceCode(
|
|
54
|
+
resourceCode: string,
|
|
55
|
+
options?: RequestOptions,
|
|
56
|
+
): Promise<WorkEnvelope>
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/** Standard Realsee OpenAPI response envelope. */
|
|
60
|
+
export interface UpstreamEnvelope extends RawRecord {
|
|
61
|
+
readonly code?: unknown
|
|
62
|
+
readonly status?: unknown
|
|
63
|
+
readonly data?: unknown
|
|
64
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
import type { FastifyInstance, FastifyReply, FastifyRequest } from 'fastify'
|
|
2
|
+
import type { WorkEnvelope, WorkService } from './types.ts'
|
|
3
|
+
|
|
4
|
+
const RESOURCE_CODE_PATTERN = '^[A-Za-z0-9_-]{1,128}$'
|
|
5
|
+
|
|
6
|
+
export interface WorkRoutesOptions {
|
|
7
|
+
readonly workService: WorkService
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface WorkRouteParams {
|
|
11
|
+
readonly resourceCode: string
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
const upstreamStatusCode = (error: unknown): number => {
|
|
15
|
+
const statusCode =
|
|
16
|
+
error && typeof error === 'object' && 'statusCode' in error
|
|
17
|
+
? Number((error as { statusCode?: unknown }).statusCode)
|
|
18
|
+
: undefined
|
|
19
|
+
if (statusCode === 404) return 404
|
|
20
|
+
if (statusCode === 504) return 504
|
|
21
|
+
return 502
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function workRoutes(
|
|
25
|
+
app: FastifyInstance,
|
|
26
|
+
{ workService }: WorkRoutesOptions,
|
|
27
|
+
done: (error?: Error) => void,
|
|
28
|
+
): void {
|
|
29
|
+
app.get(
|
|
30
|
+
'/api/work/:resourceCode',
|
|
31
|
+
{
|
|
32
|
+
schema: {
|
|
33
|
+
params: {
|
|
34
|
+
type: 'object',
|
|
35
|
+
additionalProperties: false,
|
|
36
|
+
required: ['resourceCode'],
|
|
37
|
+
properties: {
|
|
38
|
+
resourceCode: { type: 'string', pattern: RESOURCE_CODE_PATTERN },
|
|
39
|
+
},
|
|
40
|
+
},
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
async (
|
|
44
|
+
request: FastifyRequest<{ Params: WorkRouteParams }>,
|
|
45
|
+
reply: FastifyReply,
|
|
46
|
+
): Promise<WorkEnvelope | FastifyReply> => {
|
|
47
|
+
const { resourceCode } = request.params
|
|
48
|
+
const requestAbort = new AbortController()
|
|
49
|
+
const abortDisconnectedRequest = (): void => {
|
|
50
|
+
if (request.raw.aborted || !request.raw.complete) requestAbort.abort()
|
|
51
|
+
}
|
|
52
|
+
request.raw.once('close', abortDisconnectedRequest)
|
|
53
|
+
|
|
54
|
+
try {
|
|
55
|
+
return await workService.getWorkByResourceCode(resourceCode, {
|
|
56
|
+
signal: requestAbort.signal,
|
|
57
|
+
})
|
|
58
|
+
} catch (error) {
|
|
59
|
+
const statusCode = upstreamStatusCode(error)
|
|
60
|
+
request.log.error(
|
|
61
|
+
{ err: error, resourceCode },
|
|
62
|
+
'Work BFF request failed',
|
|
63
|
+
)
|
|
64
|
+
return reply.code(statusCode).send({
|
|
65
|
+
error:
|
|
66
|
+
statusCode === 404
|
|
67
|
+
? 'WORK_NOT_FOUND'
|
|
68
|
+
: statusCode === 504
|
|
69
|
+
? 'WORK_UPSTREAM_TIMEOUT'
|
|
70
|
+
: 'WORK_UPSTREAM_FAILED',
|
|
71
|
+
requestId: request.id,
|
|
72
|
+
})
|
|
73
|
+
} finally {
|
|
74
|
+
request.raw.off('close', abortDisconnectedRequest)
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
done()
|
|
80
|
+
}
|