From 7b7c555321571c674fd338e52e32548e29f93469 Mon Sep 17 00:00:00 2001 From: admin <572701190@qq.com> Date: Sun, 3 May 2026 23:26:36 +0800 Subject: [PATCH] =?UTF-8?q?2026-05-03-23-22-10=20=E4=BF=AE=E5=A4=8D?= =?UTF-8?q?=E9=98=85=E8=A7=88=E5=88=87=E6=8D=A2=E5=92=8C=E4=B8=8B=E8=BD=BD?= =?UTF-8?q?=E8=A7=A6=E5=8F=91?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- WebSite/src/App.tsx | 44 +++++++++++-- web_backend.py | 64 +++++++++++++++++-- 工程分析/实现方案-2026-05-03-23-22-10.md | 55 +++++++++++++++++ 工程分析/测试方案-2026-05-03-23-22-10.md | 78 ++++++++++++++++++++++++ 工程分析/经验记录.md | 18 ++++++ 工程分析/需求分析-2026-05-03-23-22-10.md | 55 +++++++++++++++++ 6 files changed, 304 insertions(+), 10 deletions(-) create mode 100644 工程分析/实现方案-2026-05-03-23-22-10.md create mode 100644 工程分析/测试方案-2026-05-03-23-22-10.md create mode 100644 工程分析/需求分析-2026-05-03-23-22-10.md diff --git a/WebSite/src/App.tsx b/WebSite/src/App.tsx index 1348b85..b5edeff 100644 --- a/WebSite/src/App.tsx +++ b/WebSite/src/App.tsx @@ -346,6 +346,7 @@ function OverviewDicomThumbnail({ item }: { item: LibraryItem }) { export default function App() { const restoredDeformationJob = useRef(readStoredDeformationJob()).current; + const downloadedZipJobIds = useRef>(new Set()); // --- Authentication State --- const [isLoggedIn, setIsLoggedIn] = useState(false); @@ -381,6 +382,7 @@ export default function App() { const [viewerPlane, setViewerPlane] = useState('coronal'); const [viewerWindow, setViewerWindow] = useState('default'); const [viewerSliceIndex, setViewerSliceIndex] = useState('middle'); + const [debouncedViewerSliceIndex, setDebouncedViewerSliceIndex] = useState('middle'); const [viewerPreview, setViewerPreview] = useState(null); const [isViewerLoading, setIsViewerLoading] = useState(false); const [viewerError, setViewerError] = useState(''); @@ -462,10 +464,28 @@ export default function App() { const fileUrl = (path?: string) => path ? `${API_BASE}/api/file?path=${encodeURIComponent(path)}` : ''; - const triggerDownload = (path?: string, name?: string) => { + const triggerDownload = async (path?: string, name?: string) => { if (!path) return; + const directUrl = fileUrl(path); + try { + const response = await fetch(directUrl); + if (!response.ok) throw new Error('下载文件读取失败'); + const blob = await response.blob(); + const objectUrl = URL.createObjectURL(blob); + const link = document.createElement('a'); + link.href = objectUrl; + link.download = name || path.split('/').pop() || 'download'; + document.body.appendChild(link); + link.click(); + link.remove(); + window.setTimeout(() => URL.revokeObjectURL(objectUrl), 1000); + return; + } catch { + showToast('浏览器安全策略阻止直接下载时,将尝试备用下载方式'); + } + const link = document.createElement('a'); - link.href = fileUrl(path); + link.href = directUrl; if (name) link.download = name; document.body.appendChild(link); link.click(); @@ -739,8 +759,11 @@ export default function App() { : job; setZipJobs(current => ({ ...current, [target]: displayJob })); if (job.status === 'completed') { - triggerDownload(job.result?.file?.path, job.result?.file?.name); - showToast('ZIP 打包完成,已开始下载'); + if (!downloadedZipJobIds.current.has(job.id)) { + downloadedZipJobIds.current.add(job.id); + await triggerDownload(job.result?.file?.path, job.result?.file?.name); + showToast('ZIP 打包完成,已开始下载'); + } } if (job.status === 'failed') { showToast(job.error || 'ZIP 打包失败'); @@ -903,6 +926,7 @@ export default function App() { setViewerPlane('coronal'); setViewerWindow('default'); setViewerSliceIndex('middle'); + setDebouncedViewerSliceIndex('middle'); setViewerPreview(null); setViewerError(''); }; @@ -914,6 +938,13 @@ export default function App() { setIsViewerLoading(false); }; + useEffect(() => { + const timer = window.setTimeout(() => { + setDebouncedViewerSliceIndex(viewerSliceIndex); + }, typeof viewerSliceIndex === 'number' ? 180 : 0); + return () => window.clearTimeout(timer); + }, [viewerSliceIndex]); + useEffect(() => { if (!libraryViewerItem) return; @@ -921,7 +952,7 @@ export default function App() { setIsViewerLoading(true); setViewerError(''); fetch( - `${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(viewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`, + `${API_BASE}/api/library/reformat-preview?id=${encodeURIComponent(libraryViewerItem.id)}&plane=${encodeURIComponent(viewerPlane)}&index=${encodeURIComponent(String(debouncedViewerSliceIndex))}&window=${encodeURIComponent(viewerWindow)}`, { signal: controller.signal } ) .then(async response => { @@ -937,7 +968,7 @@ export default function App() { }); return () => controller.abort(); - }, [libraryViewerItem?.id, viewerPlane, viewerSliceIndex, viewerWindow]); + }, [libraryViewerItem?.id, viewerPlane, debouncedViewerSliceIndex, viewerWindow]); const changePassword = (userId: string, newPass: string) => { setUsers(users.map(u => u.id === userId ? { ...u, password: newPass } : u)); @@ -1953,6 +1984,7 @@ export default function App() { onClick={() => { setViewerPlane(option.key); setViewerSliceIndex('middle'); + setDebouncedViewerSliceIndex('middle'); }} className={`py-3 rounded-xl text-xs font-black transition-all ${ viewerPlane === option.key diff --git a/web_backend.py b/web_backend.py index ec6f28a..8f9032a 100644 --- a/web_backend.py +++ b/web_backend.py @@ -41,6 +41,9 @@ PORT = 8787 JOBS = {} JOBS_LOCK = threading.Lock() DICOM_FILE_CACHE = {} +DICOM_VOLUME_CACHE = {} +DICOM_VOLUME_CACHE_LOCK = threading.Lock() +DICOM_VOLUME_CACHE_LIMIT = 2 LIBRARY_DIR = APP_DIR / "web_library" LIBRARY_META = LIBRARY_DIR / "library.json" RESULT_DIR = APP_DIR / "web_results" @@ -178,6 +181,56 @@ def sorted_dicom_files(dicom_dir): return sorted_files +def dicom_dir_signature(dicom_dir): + dicom_dir = Path(dicom_dir).resolve() + files = list(dicom_dir.glob("*.dcm")) + return ( + str(dicom_dir), + len(files), + max((file_path.stat().st_mtime for file_path in files), default=0), + ) + + +def load_cached_dicom_volume(dicom_dir): + dicom_dir = Path(dicom_dir).resolve() + signature = dicom_dir_signature(dicom_dir) + cache_key = str(dicom_dir) + with DICOM_VOLUME_CACHE_LOCK: + cached = DICOM_VOLUME_CACHE.get(cache_key) + if cached and cached["signature"] == signature: + cached["last_access"] = time.time() + return cached["volume"] + + volume = load_dicom_volume(dicom_dir) + DICOM_VOLUME_CACHE[cache_key] = { + "signature": signature, + "volume": volume, + "last_access": time.time(), + } + while len(DICOM_VOLUME_CACHE) > DICOM_VOLUME_CACHE_LIMIT: + oldest_key = min( + DICOM_VOLUME_CACHE, + key=lambda key: DICOM_VOLUME_CACHE[key].get("last_access", 0), + ) + if oldest_key == cache_key: + break + DICOM_VOLUME_CACHE.pop(oldest_key, None) + return volume + + +def clear_dicom_caches(dicom_dir=None): + if dicom_dir is None: + DICOM_FILE_CACHE.clear() + with DICOM_VOLUME_CACHE_LOCK: + DICOM_VOLUME_CACHE.clear() + return + + cache_key = str(Path(dicom_dir).resolve()) + DICOM_FILE_CACHE.pop(cache_key, None) + with DICOM_VOLUME_CACHE_LOCK: + DICOM_VOLUME_CACHE.pop(cache_key, None) + + def find_library_item(item_id): return next((item for item in list_library() if item["id"] == item_id), None) @@ -227,7 +280,7 @@ def make_library_reformat_preview(item_id, plane, index, window): plane = plane if plane in {"coronal", "sagittal"} else "coronal" window = window if window in VIEWER_WINDOWS else "default" - volume = load_dicom_volume(item["dicomPath"]) + volume = load_cached_dicom_volume(item["dicomPath"]) def normalize_reformat_index(raw_index, count): if str(raw_index) == "middle": @@ -1156,7 +1209,7 @@ class Handler(BaseHTTPRequestHandler): write_library_meta(remaining) upload_root = Path(target["dicomPath"]).resolve().parent if upload_root.exists() and upload_root.is_relative_to(LIBRARY_DIR.resolve()): - DICOM_FILE_CACHE.pop(str(Path(target["dicomPath"]).resolve()), None) + clear_dicom_caches(target["dicomPath"]) shutil.rmtree(upload_root) preview_cache = PREVIEW_CACHE_DIR / item_id if preview_cache.exists(): @@ -1188,8 +1241,11 @@ class Handler(BaseHTTPRequestHandler): self.send_header("Content-Disposition", f'attachment; filename="{file_path.name}"') self.send_header("Content-Length", str(file_path.stat().st_size)) self.end_headers() - with file_path.open("rb") as file_handle: - shutil.copyfileobj(file_handle, self.wfile) + try: + with file_path.open("rb") as file_handle: + shutil.copyfileobj(file_handle, self.wfile) + except (BrokenPipeError, ConnectionResetError, ConnectionAbortedError): + return def send_cors_headers(self): self.send_header("Access-Control-Allow-Origin", "*") diff --git a/工程分析/实现方案-2026-05-03-23-22-10.md b/工程分析/实现方案-2026-05-03-23-22-10.md new file mode 100644 index 0000000..f8bc029 --- /dev/null +++ b/工程分析/实现方案-2026-05-03-23-22-10.md @@ -0,0 +1,55 @@ +# 实现方案 + +开始时间:2026-05-03-23-22-10 + +## 本次方案路径 + +`工程分析/实现方案-2026-05-03-23-22-10.md` + +## 实现目标 + +修复 DICOM 阅览切片/显示模式切换导致后端连接重置的问题,并降低 ZIP 下载误触发和 HTTP 文件加载警告。 + +## 涉及文件 + +- `web_backend.py` +- `WebSite/src/App.tsx` +- `工程分析/经验记录.md` + +## 执行步骤 + +1. 后端增加 DICOM 体数据缓存: + - 增加 `DICOM_VOLUME_CACHE` 和锁。 + - 基于 DICOM 目录路径、文件数量、最新修改时间生成签名。 + - `/api/library/reformat-preview` 复用缓存体数据,避免每次切片/窗位变化都重新读取完整 DICOM。 + - 控制缓存规模,避免多个大体数据长期驻留。 +2. 后端下载容错: + - `send_file` 捕获 `BrokenPipeError`、`ConnectionResetError`、`ConnectionAbortedError`,避免客户端中断下载时污染日志或影响服务。 +3. 前端 DICOM 阅览请求防抖: + - 增加 `debouncedViewerSliceIndex`。 + - 切片滑杆变化后延迟短时间再请求后端,减少快速拖动时的请求数量。 + - 显示模式或平面切换仍可即时重置到中间切片,但实际请求走防抖后的切片索引。 +4. 前端 ZIP 下载防重复: + - 增加已下载 ZIP job id 记录,确保同一个 ZIP job 自动下载只触发一次。 +5. 前端 ZIP 下载方式调整: + - 对后端文件路径优先使用 `fetch` 获取 blob,再使用本地 `blob:` URL 触发下载。 + - 如 blob 下载失败,回退到原有 `/api/file` URL 方式,并提示用户。 +6. 执行测试方案。 +7. 更新 `工程分析/经验记录.md`。 +8. 提交并推送 Gitea,commit 信息使用 `2026-05-03-23-22-10 修复阅览切换和下载触发`。 +9. 重新部署到 `http://192.168.3.11:3005/`。 + +## 回滚思路 + +若缓存或 blob 下载出现问题,可回滚本次 `web_backend.py` 和 `WebSite/src/App.tsx` 修改,恢复直接读取体数据和直接 URL 下载方式。 + +## 风险控制 + +- 体数据缓存只影响阅览预览接口,不影响真实形变输出。 +- 缓存使用目录签名,影像库数据变化后会重新读取。 +- 删除影像库时同步清除 DICOM 文件缓存和体数据缓存。 +- 前端保留下载回退路径,避免 blob 下载失败后无法下载。 + +## 人工审核状态 + +用户已明确本次不需要人工二次确认,直接执行。 diff --git a/工程分析/测试方案-2026-05-03-23-22-10.md b/工程分析/测试方案-2026-05-03-23-22-10.md new file mode 100644 index 0000000..a5b59f9 --- /dev/null +++ b/工程分析/测试方案-2026-05-03-23-22-10.md @@ -0,0 +1,78 @@ +# 测试方案 + +开始时间:2026-05-03-23-22-10 + +## 本次方案路径 + +`工程分析/测试方案-2026-05-03-23-22-10.md` + +## 测试范围 + +- DICOM 阅览冠状位/矢状位请求是否稳定。 +- 切片滑杆快速变化是否不再导致后端被杀或连接重置。 +- 显示模式切换是否正常返回图片。 +- ZIP 下载 job 完成后是否只触发一次下载。 +- Python 语法、前端类型检查、构建是否通过。 +- 重新部署后服务是否可访问。 + +## 测试命令 + +Python 语法检查: + +```bash +python -m py_compile web_backend.py head_extension_app.py +``` + +前端类型检查: + +```bash +cd WebSite +npm run lint +``` + +前端构建: + +```bash +cd WebSite +npm run build +``` + +后端阅览接口压力抽测: + +```bash +for i in 0 64 128 256 384 511; do + curl -s "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=$i&window=bone" >/dev/null +done +``` + +部署验证: + +```bash +curl -I --max-time 5 http://192.168.3.11:3005/ +curl -s --max-time 10 "http://127.0.0.1:8787/api/library/reformat-preview?id=demo_ori_head_ct&plane=coronal&index=511&window=bone" +``` + +## 手工验证点 + +- 打开数据影像库,进入 DICOM 阅览。 +- 拖动切片滑杆,图像更新且控制台不再出现 `ERR_CONNECTION_RESET`。 +- 切换显示模式,图像更新且后端服务保持在线。 +- 如有 ZIP 打包下载任务,完成后只自动下载一次。 + +## 验收标准 + +- 后端不再因为阅览切片/窗位切换被 `Killed`。 +- `reformat-preview` 连续请求正常返回 JSON。 +- `npm run lint` 通过。 +- `npm run build` 通过。 +- `python -m py_compile web_backend.py head_extension_app.py` 通过。 +- Gitea commit/push 完成。 +- 重新部署后 `http://192.168.3.11:3005/` 返回 `200 OK`。 + +## 残余风险 + +- 纯 HTTP 部署下,浏览器可能仍会对下载文件给出安全提示;本次通过 blob 下载和重复触发保护尽量降低该提示出现概率。彻底消除需要 HTTPS 部署。 + +## 人工审核状态 + +用户已明确本次不需要人工二次确认,直接执行。 diff --git a/工程分析/经验记录.md b/工程分析/经验记录.md index 81e7905..c9dd0e7 100644 --- a/工程分析/经验记录.md +++ b/工程分析/经验记录.md @@ -55,3 +55,21 @@ C. 解决问题方案 D. 后续如何避免问题 已有后端能力应先通过前端入口复用,不重复实现打包逻辑。DICOM 阅览类预览需要使用缓存,避免每次切换切片或窗宽窗位都重复生成相同图像;新增缓存应继续放在现有 `_preview_cache` 下,避免污染仓库。 + +## 2026-05-03-23-22-10 修复阅览切换和下载触发 + +A. 具体问题 + +DICOM 阅览切片滑杆和显示模式切换时,浏览器出现 `ERR_CONNECTION_RESET`;同时 ZIP 文件下载可能在其他 UI 操作期间再次触发,并出现 HTTP 文件加载安全提示。 + +B. 产生问题原因 + +阅览重建接口每次请求都重新读取整套 DICOM 体数据,切片滑杆快速拖动和显示模式切换会并发触发多个重建请求。前端 AbortController 只能取消浏览器等待,不能停止后端已经开始的 DICOM 读取,容易造成内存压力并导致后端被系统终止。ZIP 下载通过直接访问后端 `/api/file` URL 触发,且缺少已完成 ZIP job 的一次性下载保护。 + +C. 解决问题方案 + +后端为 DICOM 体数据增加基于目录签名的内存缓存,并限制缓存数量;阅览接口复用缓存体数据生成不同平面、切片和窗宽窗位的 PNG。前端为阅览切片请求增加防抖,减少滑杆拖动时的请求数量。ZIP 下载增加已下载 job id 记录,并优先使用 fetch blob 转本地 `blob:` URL 触发下载,失败时再回退到直接 URL。 + +D. 后续如何避免问题 + +涉及整套 DICOM 体数据读取的接口必须考虑缓存、并发和请求节流,不能把滑杆这类高频 UI 操作直接绑定到重型后端计算。自动下载类逻辑必须记录已处理 job,避免同一个完成状态在后续渲染或轮询中重复触发。 diff --git a/工程分析/需求分析-2026-05-03-23-22-10.md b/工程分析/需求分析-2026-05-03-23-22-10.md new file mode 100644 index 0000000..b1c12c8 --- /dev/null +++ b/工程分析/需求分析-2026-05-03-23-22-10.md @@ -0,0 +1,55 @@ +# 需求分析 + +开始时间:2026-05-03-23-22-10 + +## 原始需求 + +用户要求严格使用代码编纂工作流,并在最开始确认整体流程。本次需求分析、实现方案、测试方案和执行修改均不需要用户二次人工确认。 + +需要修复两个问题: + +1. 点击 DICOM 阅览中的切片滚动条时出现与 ZIP 文件相关的浏览器警告:`The file at ...head_ct_morph_selected_54ed40feeb63.zip was loaded over an insecure connection. This file should be served over HTTPS.` +2. 切换 DICOM 阅览显示模式时出现请求失败:`GET /api/library/reformat-preview ... net::ERR_CONNECTION_RESET`。 + +## 目标 + +- DICOM 阅览切片滚动和显示模式切换不再导致后端连接重置。 +- 降低切片滑杆快速拖动时的请求风暴和后端内存压力。 +- ZIP 下载完成后只触发一次下载,避免和其他 UI 操作交织造成误触发。 +- 尽量避免通过直接加载 ZIP URL 的方式触发浏览器 insecure file 警告。 + +## 影响范围 + +- `web_backend.py` + - DICOM 阅览重建预览接口。 + - DICOM 体数据读取和缓存策略。 + - 文件下载响应的连接中断容错。 +- `WebSite/src/App.tsx` + - DICOM 阅览请求节流/防抖。 + - ZIP 下载触发逻辑。 + - 已完成 ZIP job 的重复下载保护。 +- `工程分析/经验记录.md` + +## 当前定位 + +- 后端日志显示 `python ../web_backend.py` 被系统 `Killed`,符合快速并发读取整套 DICOM 体数据导致内存压力过大的表现。 +- 当前 `/api/library/reformat-preview` 每次请求都会调用 `load_dicom_volume(item["dicomPath"])`,快速拖动滑杆或切换窗位会并发生成多个请求。 +- 前端阅览 effect 在 `viewerSliceIndex`、`viewerWindow` 等状态变化时立即发请求;AbortController 只中断浏览器端等待,不能中止后端已经开始的体数据读取。 +- ZIP 下载目前通过临时 `` 直接访问后端 `/api/file?path=...zip`,浏览器可能显示 HTTP 文件下载安全警告;同时需要防止同一个 ZIP job 多次自动触发下载。 + +## 约束 + +- 不需要用户二次确认,可以直接执行修改。 +- 不改变真实 DICOM 形变算法。 +- 不引入大型前端 DICOM 阅片库。 +- 不把运行缓存图片、ZIP、DICOM 或构建产物提交到仓库。 + +## 风险点 + +- 体数据内存缓存会占用一定内存,需要按影像库目录签名复用并限制缓存规模。 +- Blob 下载大 ZIP 会占用浏览器内存;本次优先用于减少直接 URL 加载警告,仍需关注超大 ZIP 的浏览器表现。 +- 浏览器对 HTTP 下载的安全提示无法在纯 HTTP 部署下彻底消除;如果必须完全消除,需要 HTTPS 部署。 + +## 待确认事项 + +用户已明确本次不需要二次人工确认,因此本次文档写完后直接执行实现和测试。