上传文档跨端不可访问修复(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 全绿; 存量无元数据旧文档不可恢复(重新上传即可)。
190 lines
7.0 KiB
TypeScript
190 lines
7.0 KiB
TypeScript
/**
|
||
* 上传附件 → 服务端 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 };
|