From 21782751ab0eae2d893b701dbfdba32c9660e6c3 Mon Sep 17 00:00:00 2001 From: weichen Date: Sun, 20 Sep 2026 09:14:12 +0000 Subject: [PATCH] fix(server,web): serve project asset raw and recover preview after refresh Add GET /assets/{id}/raw with project access checks; frontend falls back to CAS by assetId or filename when local blob is gone. Align upload accept list with backend whitelist and register binaries into CAS on chat upload. Co-authored-by: Cursor --- internal/server/asset_handlers.go | 41 +++++++ internal/server/server.go | 1 + web/src/api/projectAssets.ts | 16 +++ web/src/components/chat/InputArea.tsx | 2 +- .../doc/preview/FilePreviewPanel.tsx | 115 +++++++++++++++--- web/src/hooks/useSendMessage.ts | 18 +++ web/src/types/workbench.ts | 2 + 7 files changed, 176 insertions(+), 19 deletions(-) diff --git a/internal/server/asset_handlers.go b/internal/server/asset_handlers.go index 7c299cac..a66cc5c6 100644 --- a/internal/server/asset_handlers.go +++ b/internal/server/asset_handlers.go @@ -118,6 +118,47 @@ func (s *Server) handleListProjectAssets(w http.ResponseWriter, r *http.Request) writeJSONResponse(w, http.StatusOK, out) } +func (s *Server) handleGetProjectAssetRaw(w http.ResponseWriter, r *http.Request) { + projectID, err := strconv.ParseInt(r.PathValue("id"), 10, 64) + if err != nil || projectID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid project id") + return + } + assetID, err := strconv.ParseInt(r.PathValue("asset_id"), 10, 64) + if err != nil || assetID <= 0 { + writeSafeError(w, r, http.StatusBadRequest, "invalid asset id") + return + } + if _, ok := s.requireProjectAccess(w, r, projectID); !ok { + return + } + cas := s.cas + if cas == nil { + writeSafeError(w, r, http.StatusServiceUnavailable, "storage unavailable") + return + } + rc, ref, err := cas.OpenFileByRef(r.Context(), assetID) + if err != nil || ref == nil { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + defer rc.Close() + if ref.ProjectID != projectID { + writeSafeError(w, r, http.StatusNotFound, "asset not found") + return + } + filename := ref.FileName + if filename == "" { + filename = "download.bin" + } + mimeType := mimeFromExt(filename) + w.Header().Set("Content-Type", mimeType) + w.Header().Set("Content-Disposition", "inline; filename=\""+filepath.Base(filename)+"\"") + w.Header().Set("Cache-Control", "private, max-age=300") + w.WriteHeader(http.StatusOK) + _, _ = io.Copy(w, rc) +} + func parseUserIDHeader(r *http.Request) int64 { raw := strings.TrimSpace(r.Header.Get("X-User-ID")) if raw == "" { diff --git a/internal/server/server.go b/internal/server/server.go index 78447c65..c75c3ecf 100644 --- a/internal/server/server.go +++ b/internal/server/server.go @@ -237,6 +237,7 @@ func (s *Server) Routes() http.Handler { mux.HandleFunc("POST /api/v1/admin/workspaces/reconcile", s.handleReconcileWorkspace) mux.HandleFunc("POST /api/v1/projects/{id}/assets", s.handleUploadProjectAsset) mux.HandleFunc("GET /api/v1/projects/{id}/assets", s.handleListProjectAssets) + mux.HandleFunc("GET /api/v1/projects/{id}/assets/{asset_id}/raw", s.handleGetProjectAssetRaw) // 6. 多级上下文与偏好记忆管理 mux.HandleFunc("GET /api/v1/context/user", s.handleGetUserContext) diff --git a/web/src/api/projectAssets.ts b/web/src/api/projectAssets.ts index d69694f5..d4362db5 100644 --- a/web/src/api/projectAssets.ts +++ b/web/src/api/projectAssets.ts @@ -36,3 +36,19 @@ export async function listProjectAssets(projectId: number): Promise { + const res = await fetch(projectAssetRawURL(projectId, assetId), { + headers: getBearerAuthHeaders(), + }); + if (!res.ok) { + throw new Error(`fetch asset raw failed: HTTP ${res.status}`); + } + return res.blob(); +} diff --git a/web/src/components/chat/InputArea.tsx b/web/src/components/chat/InputArea.tsx index 7b306cdc..1975d555 100644 --- a/web/src/components/chat/InputArea.tsx +++ b/web/src/components/chat/InputArea.tsx @@ -228,7 +228,7 @@ export const InputArea: React.FC = ({ multiple style={{ display: 'none' }} onChange={handleFileChange} - accept=".pdf,.doc,.docx,.md,.txt,.xlsx" + accept=".pdf,.docx,.md,.txt,.xlsx" /> {/* 超出限制或操作提示条 */} diff --git a/web/src/components/doc/preview/FilePreviewPanel.tsx b/web/src/components/doc/preview/FilePreviewPanel.tsx index 17a50dd5..907e8611 100644 --- a/web/src/components/doc/preview/FilePreviewPanel.tsx +++ b/web/src/components/doc/preview/FilePreviewPanel.tsx @@ -1,4 +1,4 @@ -import React, { useState } from 'react'; +import React, { useEffect, useMemo, useState } from 'react'; import { FileText, FileSpreadsheet, @@ -15,6 +15,7 @@ import { ShieldCheck, } from 'lucide-react'; import { DeliverableItem } from '../../../types/workbench'; +import { fetchProjectAssetBlob, listProjectAssets } from '../../../api/projectAssets'; import { ImageViewer } from './renderers/ImageViewer'; import { PdfViewer } from './renderers/PdfViewer'; import { PptViewer } from './renderers/PptViewer'; @@ -35,15 +36,69 @@ export const FilePreviewPanel: React.FC = ({ }) => { const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview'); const [isFullscreen, setIsFullscreen] = useState(false); + const [recoveredBlob, setRecoveredBlob] = useState(null); + const [recoverState, setRecoverState] = useState<'idle' | 'loading' | 'done' | 'miss'>('idle'); const fileMeta = deliverable.fileMeta; const fileName = fileMeta?.originalName || deliverable.title; const fileSize = fileMeta?.size || '未知大小'; - const fileRaw = fileMeta?.fileRaw; + const fileRaw = fileMeta?.fileRaw || recoveredBlob || undefined; const fileUrl = fileMeta?.fileUrl; - // 文件内容可用性判断:fileRaw(当前 session 内存)或 fileUrl(非 blob 服务端 URL)任一有效即可预览 - const hasFileContent = Boolean(fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:')); + const localHasContent = Boolean(fileMeta?.fileRaw) || Boolean(fileUrl && !fileUrl.startsWith('blob:')); + const hasFileContent = localHasContent || Boolean(recoveredBlob); + + useEffect(() => { + if (localHasContent || recoveredBlob) { + setRecoverState('done'); + return; + } + const projectId = deliverable.projectId; + if (!projectId || projectId <= 0) { + setRecoverState('miss'); + return; + } + let cancelled = false; + const assetIdHint = fileMeta?.assetId; + setRecoverState('loading'); + void (async () => { + try { + let assetId = assetIdHint && assetIdHint > 0 ? assetIdHint : 0; + if (!assetId) { + const assets = await listProjectAssets(projectId); + if (cancelled) return; + const want = (fileMeta?.originalName || '').trim().toLowerCase(); + const match = assets.find((a) => (a.filename || '').trim().toLowerCase() === want) + || assets.find((a) => want && (a.filename || '').toLowerCase().includes(want.replace(/\.[^.]+$/, ''))); + assetId = match?.asset_id ?? 0; + } + if (!assetId) { + if (!cancelled) setRecoverState('miss'); + return; + } + const blob = await fetchProjectAssetBlob(projectId, assetId); + if (cancelled) return; + setRecoveredBlob(blob); + setRecoverState('done'); + } catch { + if (!cancelled) setRecoverState('miss'); + } + })(); + return () => { + cancelled = true; + }; + }, [localHasContent, recoveredBlob, deliverable.projectId, deliverable.docId, fileMeta?.assetId, fileMeta?.originalName]); + + const recoveredObjectUrl = useMemo(() => { + if (!recoveredBlob) return undefined; + return URL.createObjectURL(recoveredBlob); + }, [recoveredBlob]); + + useEffect(() => { + return () => { + if (recoveredObjectUrl) URL.revokeObjectURL(recoveredObjectUrl); + }; + }, [recoveredObjectUrl]); // 多级智能提取真实文件扩展名(兼容 originalName、summary、title 中包含的 .docx, .pdf 等) const detectExtension = (): string => { @@ -80,6 +135,7 @@ export const FilePreviewPanel: React.FC = ({ }; const fileType = getFileType(); + const effectiveFileUrl = fileUrl && !fileUrl.startsWith('blob:') ? fileUrl : recoveredObjectUrl; // 获取类型对应的图标与主题色 const getTypeBadge = () => { @@ -160,22 +216,44 @@ export const FilePreviewPanel: React.FC = ({ document.body.removeChild(a); // 延迟释放,保证浏览器有足够时间启动下载 setTimeout(() => URL.revokeObjectURL(url), 2000); - } else if (fileUrl && !fileUrl.startsWith('blob:')) { - // 服务端持久化 URL,可直接使用 + } else if (effectiveFileUrl) { + // 服务端持久化 URL 或恢复的 blob URL const a = document.createElement('a'); - a.href = fileUrl; + a.href = effectiveFileUrl; a.download = fileName; document.body.appendChild(a); a.click(); document.body.removeChild(a); } else { // 文件内容不可用(刷新后 File 对象/blob URL 均已失效) - alert(`文件「${fileName}」的本地内容在页面刷新后已释放。请在对话框中重新上传该文件以恢复预览与下载功能。`); + alert(`文件「${fileName}」暂无法下载。请确认项目资产仍在服务端,或重新上传该文件。`); } }; - // 文件内容不可用时的占位说明(刷新后 File 对象丢失,需重新上传) - const renderNoContentPlaceholder = () => ( + // 文件内容不可用时的占位说明(刷新后 File 对象丢失;优先尝试服务端回退) + const renderNoContentPlaceholder = () => { + if (recoverState === 'loading') { + return ( +
+

+ 正在从服务端恢复文件… +

+

文件名:{fileName}

+
+ ); + } + return (
= ({ }} > 浏览器本地文件({fileName})仅在上传当次会话内保留于内存。 - 页面刷新后,内存中的文件内容会被浏览器释放,无法再次预览或下载。 + 页面刷新后若服务端未登记同名项目资产,则无法自动恢复预览。

= ({

- ); + ); + }; // 渲染对应的文档预览子组件 const renderViewer = () => { @@ -279,25 +358,25 @@ export const FilePreviewPanel: React.FC = ({ ); case 'pdf': - return ; + return ; case 'excel': return ( ); case 'image': - return ; + return ; case 'ppt': - return ; + return ; case 'text': - return ; + return ; default: return (
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e), ); + // 二进制同步登记 CAS,刷新后可经 raw 端点回退预览 + const extMatch = r.attachment.name.match(/\.([^.]+)$/); + const ext = extMatch ? extMatch[1].toLowerCase() : ''; + const TEXT_EXTS = new Set(['txt', 'md', 'csv', 'json', 'xml', 'yaml', 'yml', 'log']); + if (!TEXT_EXTS.has(ext) && currentProjectId > 0) { + try { + const uploaded = await uploadProjectAsset( + currentProjectId, + r.attachment.fileRaw as Blob, + r.attachment.name, + ); + if (r.deliverable.fileMeta) { + r.deliverable.fileMeta.assetId = uploaded.asset_id; + } + } catch (e) { + console.warn('[assets] 上传附件到 CAS 失败(预览回退可能不可用):', e); + } + } } } diff --git a/web/src/types/workbench.ts b/web/src/types/workbench.ts index cbd30934..b83b0b0a 100644 --- a/web/src/types/workbench.ts +++ b/web/src/types/workbench.ts @@ -296,6 +296,8 @@ export interface UploadedFileMetadata { extension: string; fileRaw?: File | Blob; fileUrl?: string; + /** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */ + assetId?: number; uploadedAt: string; }