Files
Agent-System/web/src/lib/uploadFreeformRegister.ts
T
weichen 88e33282db fix(freedoc): persist uploaded file metadata and text body server-side
上传文档跨端不可访问修复(ISSUE-20260927-upload-doc-cross-device):
- 迁移 00076:freeform_documents 增加 source_filename/mime/size_bytes/asset_id
- freedoc:Create/Get/List 读写源文件元数据,负值拒绝
- 前端:注册前全部附件先登记 CAS 并预读文本正文;文本正文嵌入注册
  markdown;跨端恢复 sourceType/fileMeta 路由回文件预览器
- 大小显示 formatFileSize(<512B 不再显示 0 KB)
- E2E 新增 FS-UPLOAD-02 跨浏览器上下文可访问性用例

回归:go vet/arch-check/go test -short(隔离 PG)+ web npm test/build 全绿;
存量无元数据旧文档不可恢复(重新上传即可)。
2026-09-27 07:45:15 +00:00

190 lines
7.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 上传附件 → 服务端 freeform 注册辅助(失败必须硬失败,禁止本地 orphan 回退)。
*/
import type { FreeformDocDetail } from '../api/freeformDoc';
import type { DeliverableItem } from '../types/workbench';
import type { DocumentAST } from '../types/block';
export type UploadAttachmentInput = {
name: string;
size: string;
type: string;
/** 原始字节数;作为服务端事实源持久化,恢复显示时不再解析 size 字符串。 */
sizeBytes?: number;
docId?: string;
fileRaw?: File | Blob;
fileUrl?: string;
/** CAS 资产 ID(所有扩展名上传登记后回填;文本类此前跳过 CAS 导致跨端无原件) */
assetId?: number;
/** 上传命中内容去重:物理仅存一份 */
deduplicated?: boolean;
/** MinerU 解析状态初始值 */
parseState?: string;
/** 文本类文件正文(UTF-8)。注册时写入 content_markdown,跨端可读真实内容。 */
textContent?: string;
};
export function buildUploadClientKey(now: number, idx: number, hint?: string): string {
const base = hint?.trim() || `doc-upload-${now}-${idx}`;
return base;
}
export function buildUploadRegistrationMarkdown(params: {
title: string;
userName: string;
fileName: string;
fileSize: string;
mimeType: string;
/** 文本类文件正文:提供时作为文档正文嵌入,注册后即为可读内容而非占位模板。 */
textContent?: string;
}): string {
const { title, userName, fileName, fileSize, mimeType, textContent } = params;
const header = `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n> 原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)\n\n`;
const body = normalizeTextContent(textContent);
if (body) {
return `${header}## 1. 文档正文(上传原文)\n\n${body}\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
}
return `${header}## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。解析完成后可在线预览原件。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
}
/** 文本正文规整:去 BOM、截断超长内容,保证 markdown 段落可读。 */
function normalizeTextContent(text?: string, maxChars = 200_000): string {
if (!text) return '';
const stripped = text.replace(/^\uFEFF/, '').trimEnd();
if (!stripped.trim()) return '';
return stripped.length > maxChars ? `${stripped.slice(0, maxChars)}\n\n> (原文过长,已截断至 ${maxChars} 字符;完整内容请下载原件查看。)` : stripped;
}
export type RegisteredUpload = {
clientKey: string;
serverDocId: string;
detail: FreeformDocDetail;
deliverable: DeliverableItem;
attachment: UploadAttachmentInput;
};
export function deliverableFromFreeformUpload(params: {
detail: FreeformDocDetail;
projectId: number;
userId: number;
userName: string;
att: UploadAttachmentInput;
}): DeliverableItem {
const { detail, projectId, userId, userName, att } = params;
const doc = detail.document;
const extMatch = att.name.match(/\.([^.]+)$/);
const ext = extMatch ? extMatch[1].toLowerCase() : '';
return {
docId: doc.doc_id,
projectId: doc.project_id || projectId,
taskKey: 'user_requirements',
title: doc.title || att.name.replace(/\.[^/.]+$/, ''),
stage: 'V0.5',
category: 'private',
creatorId: userId,
creatorName: userName,
isFixedWorkflow: false,
isTemplateOnly: false,
lifecycleState: 'drafting',
isClaimed: true,
author: userName,
reviewer: '',
approver: '',
updatedAt: new Date().toISOString().substring(0, 10),
summary: `用户上传的私密需求文档《${att.name}》,已注册服务端版本账本。`,
space: doc.space || 'my_private',
governanceMode: 'freeform',
sourceType: 'uploaded_file',
fileMeta: {
originalName: att.name,
size: att.size,
sizeBytes: att.sizeBytes,
mimeType: att.type,
extension: ext,
assetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
fileRaw: att.fileRaw,
fileUrl: att.fileUrl,
uploadedAt: new Date().toISOString(),
},
};
}
/** 将一批附件全部注册为服务端 freeform;任一失败则抛错(调用方不得静默回退 orphan)。 */
export async function registerUploadAttachments(params: {
attachments: UploadAttachmentInput[];
projectId: number;
userId: number;
userName: string;
createFreeformDoc: (payload: {
projectId: number;
title: string;
contentMarkdown: string;
space?: 'my_private' | 'project_shared';
clientKey?: string;
sourceFilename?: string;
sourceMimeType?: string;
sourceSizeBytes?: number;
sourceAssetId?: number;
}) => Promise<FreeformDocDetail>;
now?: number;
}): Promise<RegisteredUpload[]> {
const { attachments, projectId, userId, userName, createFreeformDoc } = params;
if (!projectId || projectId <= 0) {
throw new Error('请先进入有效项目后再上传附件');
}
const now = params.now ?? Date.now();
const out: RegisteredUpload[] = [];
for (let idx = 0; idx < attachments.length; idx++) {
const att = attachments[idx];
const clientKey = buildUploadClientKey(now, idx, att.docId);
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
const markdown = buildUploadRegistrationMarkdown({
title: baseTitle,
userName,
fileName: att.name,
fileSize: att.size,
mimeType: att.type,
textContent: att.textContent,
});
let detail: FreeformDocDetail;
try {
detail = await createFreeformDoc({
projectId,
title: baseTitle || att.name,
contentMarkdown: markdown,
space: 'my_private',
clientKey,
sourceFilename: att.name,
sourceMimeType: att.type,
sourceSizeBytes: att.sizeBytes ?? 0,
sourceAssetId: att.assetId && att.assetId > 0 ? att.assetId : undefined,
});
} catch (e) {
const msg = e instanceof Error ? e.message : String(e);
throw new Error(`附件「${att.name}」服务端注册失败:${msg}。未发送消息,请检查网络后重试。`);
}
const serverDocId = detail?.document?.doc_id;
if (!serverDocId || serverDocId.startsWith('doc-upload-')) {
throw new Error(`附件「${att.name}」未返回服务端文档 ID,已阻止生成本地 orphan。`);
}
const deliverable = deliverableFromFreeformUpload({
detail,
projectId,
userId,
userName,
att,
});
out.push({
clientKey,
serverDocId,
detail,
deliverable,
attachment: { ...att, docId: serverDocId },
});
}
return out;
}
export type UploadAstPair = { docId: string; ast: DocumentAST };