diff --git a/docs/scene-resource-cache.md b/docs/scene-resource-cache.md new file mode 100644 index 0000000..fc00753 --- /dev/null +++ b/docs/scene-resource-cache.md @@ -0,0 +1,19 @@ +# 三维场景静态资源下载与缓存 + +`public/three-dimensional/zjb/v29/resourceCache.mjs` 统一加载模型和管网静态元数据,不缓存实时仿真或 SCADA 数据。 + +- 每次请求最多下载 1 MiB,通过单区间 `Range` 请求获取资源。服务器返回 `206` 时,校验 `Content-Range` 的起止位置、总长度以及实际收到的字节数;不支持 Range 的服务器可在首次请求返回完整的 `200` 响应。 +- 网络中断、连续 30 秒无数据以及临时 HTTP 错误可重试,每个分段最多尝试 3 次,等待间隔为 300、600 毫秒。永久 HTTP 错误和不合法的分段响应直接显示失败提示。 +- 每个完整分段保存至 IndexedDB。刷新页面或重新载入后,从已保存的分段末尾继续;未完成的当前分段会重新下载。浏览器禁止存储或配额不足时,仍能在本次加载中下载和重试,但无法保证刷新后保留断点。 +- 完整资源和部分下载均按 URL、manifest revision 匹配,版本改变后重新下载。完整资源经过解析验证后才进入正式缓存,后续打开场景直接复用。 +- 进度界面显示下载、解析和断点重试状态。加载模块版本参数需要与 `preview.html`、`preview.mjs` 和 `ThreeDimensionalScene.tsx` 同步,防止浏览器沿用旧加载器。 + +反向代理应透传 `Range` 请求及 `206`、`Content-Range` 响应。若内网完整下载正常,而公网完整下载中断,应同时排查代理的大文件传输;客户端分段恢复不代表代理链路问题已修复。 + +聚焦回归: + +```bash +E2E_BASE_URL=http://127.0.0.1:3000 npx playwright test e2e/three-dimensional-loading.spec.ts --workers=1 +``` + +该命令需要已运行的前端。测试覆盖完整缓存复用、分段重试、跨刷新续传、错误分段拒绝及浏览器存储不可用的降级行为。 diff --git a/e2e/three-dimensional-loading.spec.ts b/e2e/three-dimensional-loading.spec.ts index d0040c6..73dcb1c 100644 --- a/e2e/three-dimensional-loading.spec.ts +++ b/e2e/three-dimensional-loading.spec.ts @@ -1,7 +1,7 @@ import { expect, test } from "@playwright/test"; const root = "/three-dimensional/zjb/v29/"; -const preview = root + "preview.html?host=platform2&v=35-resource-cache"; +const preview = root + "preview.html?host=platform2&v=36-resumable-cache"; const isAsset = (url: string) => /\.glb(?:\?|$)|water-network-v28\.json/.test(url); test.setTimeout(120_000); @@ -28,7 +28,7 @@ test("HTTP 200 的错误元数据不会进入缓存,服务器恢复后重试 expect(cached).toBe(false); await page.getByRole("button", { name: "重新载入场景" }).click(); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); - expect(requests).toBe(2); + expect(requests).toBe(5); }); test("模型和元数据跨页面复用持久缓存,即使服务器资源不可用也能重开场景", async ({ page, context }) => { @@ -46,7 +46,7 @@ test("模型和元数据跨页面复用持久缓存,即使服务器资源不 await page.goto(preview); await expect(page.locator("#boot")).toBeHidden({ timeout: 60_000 }); const coldMs = Date.now() - coldStart; - expect(requests).toHaveLength(10); + expect(new Set(requests).size).toBe(10); requests.length = 0; block = true; await page.close(); @@ -140,7 +140,7 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", await page.route("**/cache-probe.json*", (route) => { requests++; return requests === 1 - ? route.fulfill({ status: 503, body: "unavailable" }) + ? route.fulfill({ status: 404, body: "not found" }) : requests === 2 ? route.fulfill({ body: "invalid json" }) : route.fulfill({ json: { ok: true } }); @@ -160,6 +160,145 @@ test("HTTP 错误和解析失败不会写入缓存,下一次请求可恢复", }, root); expect(requests).toBe(3); expect(result.errors).toHaveLength(2); - expect(result.errors[0]).toContain("HTTP 503"); + expect(result.errors[0]).toContain("HTTP 404"); expect(result.recovered).toEqual({ ok: true }); }); + +test("公网整文件中断时分段下载,重试失败分段并缓存完整模型", async ({ page }) => { + const { readFile } = await import("node:fs/promises"); + const { createHash } = await import("node:crypto"); + const bytes = await readFile("public/three-dimensional/zjb/v29/models/detail_interior.glb"); + const ranges: string[] = []; + let interrupted = false; + await page.route("**/models/detail_interior.glb*", async (route) => { + const range = route.request().headers().range; + ranges.push(range ?? "whole-file"); + // The public proxy drops whole-file responses, and one partial request drops too. + if (!range) return route.abort("failed"); + if (range.startsWith("bytes=1048576-") && !interrupted) { + interrupted = true; + return route.abort("failed"); + } + const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + return route.fulfill({ status: 206, headers: { + "content-type": "model/gltf-binary", + "content-range": `bytes ${start}-${end}/${bytes.length}`, + }, body: bytes.subarray(start, end + 1) }); + }); + await page.goto(root + "manifest.json"); + const result = await page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + const load = () => createSceneResourceCache(location.origin + base).load("models/detail_interior.glb", "range-recovery"); + const data = await load(); + const cached = await load(); + return { length: data.byteLength, cachedLength: cached.byteLength, + sha256: Array.from(new Uint8Array(await crypto.subtle.digest("SHA-256", data)), n => n.toString(16).padStart(2, "0")).join("") }; + }, root); + expect(result).toEqual({ length: bytes.length, cachedLength: bytes.length, sha256: createHash("sha256").update(bytes).digest("hex") }); + expect(ranges).toHaveLength(7); + expect(ranges[0]).toBe("bytes=0-1048575"); + expect(ranges[1]).toBe(ranges[2]); + expect(interrupted).toBe(true); +}); + +for (const fault of ["offset", "total", "truncated", "whole-response"] as const) { + test(`无效分段响应 ${fault} 不会进入缓存,恢复后可重新下载`, async ({ page }) => { + const bytes = Buffer.alloc(1048576 + 16, 7); + let broken = true; + let requests = 0; + await page.route("**/range-probe.bin*", (route) => { + requests++; + const range = route.request().headers().range; + if (!range) return route.abort("failed"); + const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + if (broken && start > 0 && fault === "whole-response") return route.fulfill({ body: bytes }); + return route.fulfill({ status: 206, headers: { + "content-range": `bytes ${broken && start > 0 && fault === "offset" ? 0 : start}-${end}/${bytes.length + (broken && start > 0 && fault === "total" ? 1 : 0)}`, + }, body: bytes.subarray(start, end + (broken && start > 0 && fault === "truncated" ? 0 : 1)) }); + }); + await page.goto(root + "manifest.json"); + const failed = await page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + try { await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first"); return false; } + catch { return true; } + }, root); + expect(failed).toBe(true); + const before = requests; + broken = false; + const length = await page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + return (await createSceneResourceCache(location.origin + base).load("range-probe.bin", "first")).byteLength; + }, root); + expect(length).toBe(bytes.length); + // The valid first segment survives; only the rejected final segment is fetched again. + expect(requests - before).toBe(1); + }); +} + +test("下载中断后刷新页面,从已保存的分段继续下载", async ({ page }) => { + const bytes = Buffer.alloc(2 * 1048576 + 16, 9); + let disconnected = true; + const ranges: string[] = []; + await page.route("**/resume-probe.bin*", (route) => { + const range = route.request().headers().range; + ranges.push(range ?? "whole-file"); + if (!range) return route.abort("failed"); + const match = /^bytes=(\d+)-(\d+)$/.exec(range)!; + const start = Number(match[1]); + const end = Math.min(Number(match[2]), bytes.length - 1); + if (disconnected && start > 0) return route.abort("failed"); + return route.fulfill({ status: 206, headers: { "content-range": `bytes ${start}-${end}/${bytes.length}` }, body: bytes.subarray(start, end + 1) }); + }); + await page.goto(root + "manifest.json"); + const load = (base: string) => page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + try { + const data = await createSceneResourceCache(location.origin + base).load("resume-probe.bin", "first"); + return { length: data.byteLength, failed: false }; + } catch { return { failed: true }; } + }, base); + expect(await load(root)).toEqual({ failed: true }); + expect(ranges.filter(range => range === "bytes=1048576-2097151")).toHaveLength(3); + disconnected = false; + ranges.length = 0; + await page.reload(); + expect(await load(root)).toEqual({ length: bytes.length, failed: false }); + expect(ranges).toEqual(["bytes=1048576-2097151", "bytes=2097152-2097167"]); +}); + +test("临时 HTTP 503 自动重试,成功后才写入缓存", async ({ page }) => { + let requests = 0; + await page.route("**/retry-probe.json*", (route) => { + requests++; + return requests < 3 ? route.fulfill({ status: 503, body: "unavailable" }) : route.fulfill({ json: { ok: true } }); + }); + await page.goto(root + "manifest.json"); + const result = await page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + const stages: string[] = []; + const cache = createSceneResourceCache(location.origin + base, ({ stage }: { stage: string }) => stages.push(stage)); + const data = await cache.load("retry-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); + return { data, retries: stages.filter(stage => stage === "retry").length }; + }, root); + expect(result).toEqual({ data: { ok: true }, retries: 2 }); + expect(requests).toBe(3); +}); + +test("服务器返回空的完整响应时只解析一次,不循环下载", async ({ page }) => { + let requests = 0; + await page.route("**/empty-probe.json*", (route) => { requests++; return route.fulfill({ body: "" }); }); + await page.goto(root + "manifest.json"); + const failed = await page.evaluate(async (base) => { + const { createSceneResourceCache } = await import(base + "resourceCache.mjs"); + try { + await createSceneResourceCache(location.origin + base).load("empty-probe.json", "first", (data: ArrayBuffer) => JSON.parse(new TextDecoder().decode(data))); + return false; + } catch { return true; } + }, root); + expect(failed).toBe(true); + expect(requests).toBe(1); +}); diff --git a/public/three-dimensional/zjb/v29/loadingProgress.mjs b/public/three-dimensional/zjb/v29/loadingProgress.mjs index cda783d..99c27e1 100644 --- a/public/three-dimensional/zjb/v29/loadingProgress.mjs +++ b/public/three-dimensional/zjb/v29/loadingProgress.mjs @@ -32,12 +32,13 @@ export function reportProgress(update) { const done = values.filter(item => item.stage === 'ready').length; const loaded = values.reduce((sum, item) => sum + item.loaded, 0); const cached = values.filter(item => item.stage === 'cache').length; + const retrying = values.some(item => item.stage === 'retry'); const parsing = values.some(item => item.stage === 'parse'); const downloading = values.some(item => item.stage === 'download'); progress.max = Math.max(1, values.length); progress.value = done; title.textContent = `正在载入场景资源 ${done} / ${values.length}`; - detail.textContent = `${parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`; + detail.textContent = `${retrying ? '连接中断,正在从断点重试' : parsing ? '正在解析模型' : downloading ? '正在下载资源' : cached ? '正在读取本地缓存' : '正在准备场景'} · 已读取 ${formatBytes(loaded)}`; } export function finishLoading() { diff --git a/public/three-dimensional/zjb/v29/preview.html b/public/three-dimensional/zjb/v29/preview.html index 25b3050..6feacba 100644 --- a/public/three-dimensional/zjb/v29/preview.html +++ b/public/three-dimensional/zjb/v29/preview.html @@ -36,8 +36,8 @@ diff --git a/public/three-dimensional/zjb/v29/preview.mjs b/public/three-dimensional/zjb/v29/preview.mjs index cf0fbb8..09d3a32 100644 --- a/public/three-dimensional/zjb/v29/preview.mjs +++ b/public/three-dimensional/zjb/v29/preview.mjs @@ -1,7 +1,7 @@ import {createRenderEffects} from './render-effects.mjs'; -import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=35-resource-cache'; -import {createSceneResourceCache} from './resourceCache.mjs?v=35-resource-cache'; -import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=35-resource-cache'; +import {createCameraNavigation,framePose} from './camera-navigation.mjs?v=36-resumable-cache'; +import {createSceneResourceCache} from './resourceCache.mjs?v=36-resumable-cache'; +import {beginLoading,reportProgress,finishLoading,failLoading} from './loadingProgress.mjs?v=36-resumable-cache'; import {createAssetInspector} from './asset-inspector.mjs?v=29-platform2'; import * as THREE from 'three'; import {GLTFLoader} from 'three/addons/loaders/GLTFLoader.js'; diff --git a/public/three-dimensional/zjb/v29/resourceCache.mjs b/public/three-dimensional/zjb/v29/resourceCache.mjs index 291685d..957c9c0 100644 --- a/public/three-dimensional/zjb/v29/resourceCache.mjs +++ b/public/three-dimensional/zjb/v29/resourceCache.mjs @@ -2,6 +2,8 @@ const DATABASE = 'tjwater-scene-assets-v1'; const STORE = 'resources'; const REQUEST_TIMEOUT_MS = 30_000; +const CHUNK_BYTES = 1024 * 1024; +const MAX_ATTEMPTS = 3; function openDatabase() { return new Promise(resolve => { @@ -54,39 +56,111 @@ export function createSceneResourceCache(baseUrl, onProgress = () => {}) { // Revisioned URLs also keep the browser's HTTP cache in sync with the manifest. if (revision) url.searchParams.set('v', revision); - const controller = new AbortController(); - let timer; - const resetTimeout = () => { - clearTimeout(timer); - timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); - }; - report('download'); - resetTimeout(); - let data; - try { - const response = await fetch(url, {signal:controller.signal, cache:'no-cache'}); - if (!response.ok) throw Error('HTTP ' + response.status); - const total = Number(response.headers.get('content-length')) || 0; - if (response.body) { - const reader = response.body.getReader(); - const chunks = []; - let loaded = 0; - while (true) { - const {done, value} = await reader.read(); - if (done) break; - chunks.push(value); loaded += value.byteLength; - report('download', loaded, total); resetTimeout(); + const partialKey = key + '#partial'; + let chunks = [], loaded = 0, total = 0; + if (storage && revision) { + try { + const partial = await transact(storage, 'readonly', store => store.get(partialKey)); + if (partial?.revision === revision && Number.isSafeInteger(partial.total) && partial.total > 0 && + Array.isArray(partial.chunks) && partial.chunks.length && + partial.chunks.every(chunk => chunk instanceof ArrayBuffer && chunk.byteLength > 0 && chunk.byteLength <= CHUNK_BYTES)) { + const size = partial.chunks.reduce((sum, chunk) => sum + chunk.byteLength, 0); + if (size <= partial.total) { chunks = partial.chunks; loaded = size; total = partial.total; } } - const bytes = new Uint8Array(loaded); + } catch { /* A denied partial cache does not prevent downloading. */ } + } + + async function downloadPart() { + const controller = new AbortController(); + let timer; + const resetTimeout = () => { + clearTimeout(timer); + timer = setTimeout(() => controller.abort(), REQUEST_TIMEOUT_MS); + }; + resetTimeout(); + try { + const end = total ? Math.min(loaded + CHUNK_BYTES, total) - 1 : loaded + CHUNK_BYTES - 1; + const response = await fetch(url, {signal:controller.signal, cache:'no-cache', headers:{Range:`bytes=${loaded}-${end}`}}); + if (!response.ok) { + const error = Error('HTTP ' + response.status); + error.retryable = [408, 429, 500, 502, 503, 504].includes(response.status); + throw error; + } + let partTotal = 0, expected = 0; + if (response.status === 206) { + const range = /^bytes (\d+)-(\d+)\/(\d+)$/.exec(response.headers.get('content-range') ?? ''); + const start = Number(range?.[1]), last = Number(range?.[2]); + partTotal = Number(range?.[3]); + if (!range || start !== loaded || last < start || last > end || last >= partTotal || + !Number.isSafeInteger(partTotal) || (total && total !== partTotal)) { + throw Error('资源分段响应无效'); + } + expected = last - start + 1; + } else { + if (loaded) throw Error('服务器未按断点返回资源'); + // Servers without Range support can still return an ordinary complete response. + partTotal = Number(response.headers.get('content-length')) || 0; + } + const buffers = []; + let size = 0; + if (response.body) { + const reader = response.body.getReader(); + try { + while (true) { + const {done, value} = await reader.read(); + if (done) break; + buffers.push(value); size += value.byteLength; + if (expected && size > expected) throw Error('资源分段长度无效'); + report('download', loaded + size, partTotal); resetTimeout(); + } + } finally { await reader.cancel().catch(() => {}); } + } else { + const buffer = new Uint8Array(await response.arrayBuffer()); + buffers.push(buffer); size = buffer.byteLength; + } + if (expected && size !== expected) throw Error('资源分段不完整'); + const bytes = new Uint8Array(size); let offset = 0; - for (const chunk of chunks) { bytes.set(chunk, offset); offset += chunk.byteLength; } - data = bytes.buffer; - } else { - data = await response.arrayBuffer(); + for (const buffer of buffers) { bytes.set(buffer, offset); offset += buffer.byteLength; } + return {data:bytes.buffer, total:response.status === 206 ? partTotal : size, ranged:response.status === 206}; + } catch (error) { + controller.abort(); + throw error; + } finally { clearTimeout(timer); } + } + + report('download', loaded, total); + try { + while (!total || loaded < total) { + let part; + for (let attempt = 0; attempt < MAX_ATTEMPTS; attempt++) { + try { part = await downloadPart(); break; } + catch (error) { + // Permanent HTTP/contract errors stop; temporary transport/server errors can recover. + if (!error.retryable && !(error instanceof TypeError) && error.name !== 'AbortError' && error.message !== '资源分段不完整') throw error; + if (attempt + 1 === MAX_ATTEMPTS) throw error; + report('retry', loaded, total); + await new Promise(resolve => setTimeout(resolve, 300 * (attempt + 1))); + } + } + chunks.push(part.data); loaded += part.data.byteLength; total = part.total; + if (storage && revision && part.ranged) { + try { await transact(storage, 'readwrite', store => store.put({url:partialKey, revision, total, chunks})); } + catch { /* Storage denied/full: retain the in-memory download. */ } + } + if (!part.ranged) break; } } catch (error) { - throw Error(file + ':' + (controller.signal.aborted ? '下载超时,请重试' : error.message)); - } finally { clearTimeout(timer); } + throw Error(file + ':' + (error.name === 'AbortError' ? '下载超时,请重试' : error.message)); + } + const bytes = new Uint8Array(loaded); + let offset = 0; + for (const chunk of chunks) { bytes.set(new Uint8Array(chunk), offset); offset += chunk.byteLength; } + const data = bytes.buffer; + if (storage) { + try { await transact(storage, 'readwrite', store => store.delete(partialKey)); } + catch { /* Completed bytes can still be parsed when storage is unavailable. */ } + } report('parse', data.byteLength, data.byteLength); // Validate/parse before persisting so an HTML error page or bad GLB cannot poison the cache. diff --git a/src/components/threeDimensional/ThreeDimensionalScene.tsx b/src/components/threeDimensional/ThreeDimensionalScene.tsx index 5527fb8..f33b1b7 100644 --- a/src/components/threeDimensional/ThreeDimensionalScene.tsx +++ b/src/components/threeDimensional/ThreeDimensionalScene.tsx @@ -38,7 +38,7 @@ import { type SceneRuntimeState, } from "./sceneProtocol"; -const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=35-resource-cache"; +const SCENE_URL = "/three-dimensional/zjb/v29/preview.html?host=platform2&v=36-resumable-cache"; const SCENE_LOAD_TIMEOUT_MS = 120_000; const initialRuntimeState: SceneRuntimeState = {