@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.
Files changed (72) hide show
  1. package/NOTICE.md +7 -0
  2. package/README.md +216 -0
  3. package/TERMS.txt +54 -0
  4. package/agent-assets/AGENTS.md +113 -0
  5. package/agent-assets/adapters/.cursor/rules/realsee-vr.mdc +7 -0
  6. package/agent-assets/adapters/.github/copilot-instructions.md +1 -0
  7. package/agent-assets/adapters/.kiro/steering/realsee-vr.md +6 -0
  8. package/agent-assets/adapters/CLAUDE.md +3 -0
  9. package/bin/vr-cli.mjs +3 -0
  10. package/dist/cli.mjs +24 -0
  11. package/package.json +38 -0
  12. package/skills/realsee-image-resources/SKILL.md +82 -0
  13. package/skills/realsee-image-resources/references/cors-and-three-textures.md +50 -0
  14. package/skills/realsee-image-resources/references/data-wanxiang.md +57 -0
  15. package/skills/realsee-image-resources/references/five-image-url-transform.md +81 -0
  16. package/skills/realsee-image-resources/references/source-and-domain-policy.md +63 -0
  17. package/skills/realsee-vr-app/SKILL.md +157 -0
  18. package/skills/realsee-vr-app/references/continuous-improvement.md +24 -0
  19. package/skills/realsee-vr-app/references/dnalogel-api-capabilities.md +241 -0
  20. package/skills/realsee-vr-app/references/dnalogel-openapi-recipes.md +818 -0
  21. package/skills/realsee-vr-app/references/five-api-capabilities.md +119 -0
  22. package/skills/realsee-vr-app/references/five-runtime.md +83 -0
  23. package/skills/realsee-vr-app/references/official-docs.md +61 -0
  24. package/skills/realsee-vr-app/references/plugin-lifecycle.md +65 -0
  25. package/skills/realsee-vr-app/references/ui-and-validation.md +57 -0
  26. package/skills/realsee-vr-app/references/vapor-dom-layer.md +186 -0
  27. package/skills/realsee-vr-app/scripts/verify-project.mjs +1566 -0
  28. package/skills/threejs-r117-five/SKILL.md +71 -0
  29. package/skills/threejs-r117-five/references/five-ownership.md +70 -0
  30. package/skills/threejs-r117-five/references/r117-capabilities.md +82 -0
  31. package/skills/threejs-r117-five/references/r117-version-guards.md +60 -0
  32. package/templates/base/.editorconfig +12 -0
  33. package/templates/base/.env.example +12 -0
  34. package/templates/base/.gitignore.template +6 -0
  35. package/templates/base/.node-version +1 -0
  36. package/templates/base/.npmrc.template +3 -0
  37. package/templates/base/.nvmrc +1 -0
  38. package/templates/base/.prettierignore +12 -0
  39. package/templates/base/README.md +160 -0
  40. package/templates/base/eslint.config.mjs +75 -0
  41. package/templates/base/index.html +13 -0
  42. package/templates/base/package-lock.json +6444 -0
  43. package/templates/base/package.json +55 -0
  44. package/templates/base/prettier.config.mjs +5 -0
  45. package/templates/base/server/app.ts +54 -0
  46. package/templates/base/server/check-config.ts +4 -0
  47. package/templates/base/server/config.ts +54 -0
  48. package/templates/base/server/index.ts +31 -0
  49. package/templates/base/server/realsee-open-api.ts +316 -0
  50. package/templates/base/server/types.ts +64 -0
  51. package/templates/base/server/work-route.ts +80 -0
  52. package/templates/base/server/work-service.ts +54 -0
  53. package/templates/base/shared/model-floorplan.ts +242 -0
  54. package/templates/base/src/main.tsx +11 -0
  55. package/templates/base/src/shared/useElementSize.ts +28 -0
  56. package/templates/base/src/styles.css +91 -0
  57. package/templates/base/src/vr/five/SpatialRuntimeProvider.tsx +234 -0
  58. package/templates/base/src/vr/five/api.ts +10 -0
  59. package/templates/base/src/vr/five/fiveConfig.ts +42 -0
  60. package/templates/base/src/vr/five/initialState.ts +48 -0
  61. package/templates/base/src/vr/plugins/SpatialPluginRegistry.ts +104 -0
  62. package/templates/base/src/vr/plugins/dnalogel.ts +2 -0
  63. package/templates/base/src/vr/plugins/examples/FeatureOwnedMeshPlugin.ts +73 -0
  64. package/templates/base/src/vr/work/WorkSource.ts +247 -0
  65. package/templates/base/tsconfig.json +27 -0
  66. package/templates/base/tsconfig.server.json +25 -0
  67. package/templates/base/vite.config.ts +21 -0
  68. package/templates/viewer/src/app/App.tsx +182 -0
  69. package/templates/viewer/src/features/floorplan/ModelFloorplanFeature.ts +141 -0
  70. package/templates/viewer/src/features/navigation/ModeSwitcher.tsx +111 -0
  71. package/templates/viewer/src/features/tags/PanoTagFeature.ts +110 -0
  72. 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,5 @@
1
+ export default {
2
+ semi: false,
3
+ singleQuote: true,
4
+ trailingComma: 'all',
5
+ }
@@ -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,4 @@
1
+ import { loadServerConfig } from './config.ts'
2
+
3
+ loadServerConfig()
4
+ console.log('BFF server configuration is valid.')
@@ -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
+ }