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 <cursoragent@cursor.com>
This commit is contained in:
weichen
2026-09-20 09:14:12 +00:00
co-authored by Cursor
parent aef3d6d5cb
commit 21782751ab
7 changed files with 176 additions and 19 deletions
+41
View File
@@ -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 == "" {
+1
View File
@@ -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)
+16
View File
@@ -36,3 +36,19 @@ export async function listProjectAssets(projectId: number): Promise<ProjectAsset
}
return res.json();
}
/** 构建鉴权后的资产原件 URL(供预览/下载回退)。 */
export function projectAssetRawURL(projectId: number, assetId: number): string {
return `/api/v1/projects/${projectId}/assets/${assetId}/raw`;
}
/** 拉取资产二进制为 Blob(带 Bearer)。 */
export async function fetchProjectAssetBlob(projectId: number, assetId: number): Promise<Blob> {
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();
}
+1 -1
View File
@@ -228,7 +228,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
multiple
style={{ display: 'none' }}
onChange={handleFileChange}
accept=".pdf,.doc,.docx,.md,.txt,.xlsx"
accept=".pdf,.docx,.md,.txt,.xlsx"
/>
{/* 超出限制或操作提示条 */}
@@ -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<FilePreviewPanelProps> = ({
}) => {
const [viewMode, setViewMode] = useState<'preview' | 'info'>('preview');
const [isFullscreen, setIsFullscreen] = useState(false);
const [recoveredBlob, setRecoveredBlob] = useState<Blob | null>(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<FilePreviewPanelProps> = ({
};
const fileType = getFileType();
const effectiveFileUrl = fileUrl && !fileUrl.startsWith('blob:') ? fileUrl : recoveredObjectUrl;
// 获取类型对应的图标与主题色
const getTypeBadge = () => {
@@ -160,22 +216,44 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
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 (
<div
style={{
display: 'flex',
flexDirection: 'column',
alignItems: 'center',
justifyContent: 'center',
height: '100%',
padding: '40px 28px',
background: 'var(--bg-subtle, #f8fafc)',
textAlign: 'center',
}}
>
<h4 style={{ margin: '0 0 8px', fontSize: '15px', fontWeight: 600, color: '#1e293b' }}>
正在从服务端恢复文件…
</h4>
<p style={{ margin: 0, fontSize: '13px', color: '#64748b' }}>文件名:{fileName}</p>
</div>
);
}
return (
<div
style={{
display: 'flex',
@@ -217,7 +295,7 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
}}
>
浏览器本地文件(<code>{fileName}</code>)仅在上传当次会话内保留于内存。
<strong>页面刷新后,内存中的文件内容会被浏览器释放</strong>,无法再次预览或下载。
页面刷新后若服务端未登记同名项目资产,则无法自动恢复预览。
</p>
<p
style={{
@@ -265,7 +343,8 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
</div>
</div>
</div>
);
);
};
// 渲染对应的文档预览子组件
const renderViewer = () => {
@@ -279,25 +358,25 @@ export const FilePreviewPanel: React.FC<FilePreviewPanelProps> = ({
<OnDemand
label="Word 预览"
load={loadDocxViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
/>
);
case 'pdf':
return <PdfViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <PdfViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
case 'excel':
return (
<OnDemand
label="表格预览"
load={loadExcelViewer}
componentProps={{ fileRaw, fileUrl, fileName }}
componentProps={{ fileRaw, fileUrl: effectiveFileUrl, fileName }}
/>
);
case 'image':
return <ImageViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <ImageViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
case 'ppt':
return <PptViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} fileSize={fileSize} />;
return <PptViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} fileSize={fileSize} />;
case 'text':
return <TextViewer fileRaw={fileRaw} fileUrl={fileUrl} fileName={fileName} />;
return <TextViewer fileRaw={fileRaw} fileUrl={effectiveFileUrl} fileName={fileName} />;
default:
return (
<div
+18
View File
@@ -209,6 +209,24 @@ export function useSendMessage(input: SendMessageInput) {
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
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);
}
}
}
}
+2
View File
@@ -296,6 +296,8 @@ export interface UploadedFileMetadata {
extension: string;
fileRaw?: File | Blob;
fileUrl?: string;
/** 服务端 CAS 资产 ID(刷新后可经 raw 端点回退预览) */
assetId?: number;
uploadedAt: string;
}