feat(web): await freeform upload register before send
Make InputArea/onSendMessage async; fail closed with an explicit UI error when createFreeformDoc fails, and persist fileStore under server doc-free IDs. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+31
-60
@@ -11,6 +11,7 @@ import { CommentSidePanel } from './components/doc/CommentSidePanel';
|
||||
import { DocGovActionBar } from './components/doc/DocGovActionBar';
|
||||
import { FreeformDocumentActionBar } from './components/doc/FreeformDocumentActionBar';
|
||||
import {
|
||||
createFreeformDoc,
|
||||
saveMessageToPrivate,
|
||||
listFreeformDocs,
|
||||
fetchFreeformDoc,
|
||||
@@ -18,6 +19,7 @@ import {
|
||||
unshareFreeformDoc,
|
||||
deleteFreeformDoc,
|
||||
} from './api/freeformDoc';
|
||||
import { registerUploadAttachments } from './lib/uploadFreeformRegister';
|
||||
import { fetchChatSessions, upsertChatSession } from './api/chatSessions';
|
||||
import { listProjectWorkflowDocuments } from './api/projectDocuments';
|
||||
import { patchDocumentContent, getRevisionState } from './api/documentRevision';
|
||||
@@ -2708,11 +2710,11 @@ export default function App() {
|
||||
};
|
||||
|
||||
// 发送消息与对话驱动双轨逻辑
|
||||
const handleSendMessage = (
|
||||
const handleSendMessage = async (
|
||||
text: string,
|
||||
attachments?: { name: string; size: string; type: string; docId?: string; fileRaw?: File | Blob; fileUrl?: string }[],
|
||||
quotes?: QuotedMessage[]
|
||||
): SendMessageResult => {
|
||||
): Promise<SendMessageResult> => {
|
||||
const trimmed = text.trim();
|
||||
let clientTurnId: string;
|
||||
try {
|
||||
@@ -2724,53 +2726,32 @@ export default function App() {
|
||||
};
|
||||
}
|
||||
|
||||
// 若包含附件,则自动将用户上传的文件批量注册为个人私密文档 (category: 'private')
|
||||
// 附件必须先注册为服务端 freeform(server docId);失败硬拦,禁止本地 orphan 回退
|
||||
let processedAttachments = attachments;
|
||||
if (attachments && attachments.length > 0) {
|
||||
const newDeliverablesList: DeliverableItem[] = [];
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
const now = Date.now();
|
||||
|
||||
processedAttachments = attachments.map((att, idx) => {
|
||||
const fileDocId = att.docId || `doc-upload-${now}-${idx}`;
|
||||
const baseTitle = att.name.replace(/\.[^/.]+$/, '');
|
||||
const extMatch = att.name.match(/\.([^.]+)$/);
|
||||
const ext = extMatch ? extMatch[1].toLowerCase() : '';
|
||||
|
||||
const newDeliverable: DeliverableItem = {
|
||||
docId: fileDocId,
|
||||
let registered;
|
||||
try {
|
||||
registered = await registerUploadAttachments({
|
||||
attachments,
|
||||
projectId: currentProjectId,
|
||||
taskKey: 'user_requirements',
|
||||
title: baseTitle,
|
||||
stage: 'V0.5',
|
||||
category: 'private',
|
||||
creatorId: currentUser.id,
|
||||
creatorName: currentUser.name,
|
||||
isFixedWorkflow: false,
|
||||
isTemplateOnly: false,
|
||||
lifecycleState: 'drafting',
|
||||
isClaimed: true,
|
||||
author: currentUser.name,
|
||||
reviewer: '李主管',
|
||||
approver: '王总监',
|
||||
updatedAt: new Date().toISOString().substring(0, 10),
|
||||
summary: `用户上传的私密需求文档《${att.name}》,归档至个人私密文档库,可一键共享至项目参考库。`,
|
||||
space: 'my_private',
|
||||
governanceMode: 'freeform',
|
||||
sourceType: 'uploaded_file',
|
||||
fileMeta: {
|
||||
originalName: att.name,
|
||||
size: att.size,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
fileRaw: att.fileRaw,
|
||||
fileUrl: att.fileUrl,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
},
|
||||
userId: currentUser.id,
|
||||
userName: currentUser.name,
|
||||
createFreeformDoc,
|
||||
});
|
||||
} catch (err) {
|
||||
return {
|
||||
accepted: false,
|
||||
error: err instanceof Error ? err.message : '附件服务端注册失败,请重试',
|
||||
};
|
||||
const initialMd = `# ${baseTitle}\n\n> 上传人:${currentUser.name} | 上传时间:${new Date().toLocaleString()} | 文件大小:${att.size}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件格式:\`${att.type || 'document'}\`。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n- 也可以随时点击「撤回为个人私密」恢复独立隔离。`;
|
||||
}
|
||||
|
||||
const newDeliverablesList = registered.map((r) => r.deliverable);
|
||||
const newDocCacheEntries: Record<string, DocumentAST> = {};
|
||||
for (const r of registered) {
|
||||
const baseTitle = r.deliverable.title;
|
||||
const initialMd = r.detail.content_markdown;
|
||||
const newAST = markdownToBlockAST(initialMd, {
|
||||
docId: fileDocId,
|
||||
docId: r.serverDocId,
|
||||
title: baseTitle,
|
||||
taskKey: 'user_requirements',
|
||||
version: 'V1.0',
|
||||
@@ -2785,24 +2766,15 @@ export default function App() {
|
||||
});
|
||||
newAST.governanceMode = 'freeform';
|
||||
newAST.space = 'my_private';
|
||||
|
||||
newDeliverablesList.push(newDeliverable);
|
||||
newDocCacheEntries[fileDocId] = newAST;
|
||||
|
||||
// ✅ 将文件内容持久化到 IndexedDB,支持页面刷新后恢复预览
|
||||
// fire-and-forget:写入失败不影响正常流程
|
||||
if (att.fileRaw) {
|
||||
saveFile(fileDocId, att.fileRaw, att.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', fileDocId, e)
|
||||
newDocCacheEntries[r.serverDocId] = newAST;
|
||||
if (r.attachment.fileRaw) {
|
||||
saveFile(r.serverDocId, r.attachment.fileRaw, r.attachment.name).catch((e) =>
|
||||
console.warn('[fileStore] 保存上传文件失败:', r.serverDocId, e),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
...att,
|
||||
docId: fileDocId,
|
||||
};
|
||||
});
|
||||
|
||||
processedAttachments = registered.map((r) => r.attachment);
|
||||
const newDocIds = new Set(newDeliverablesList.map((d) => d.docId));
|
||||
setDeliverables((prev) => [...newDeliverablesList, ...prev.filter((d) => !newDocIds.has(d.docId))]);
|
||||
setDocCache((prev) => ({
|
||||
@@ -2810,7 +2782,6 @@ export default function App() {
|
||||
...newDocCacheEntries,
|
||||
}));
|
||||
|
||||
// 默认激活打开刚上传的第 1 个文档
|
||||
if (processedAttachments.length > 0 && processedAttachments[0].docId) {
|
||||
setActiveDocId(processedAttachments[0].docId);
|
||||
setActiveCategory('private');
|
||||
|
||||
@@ -43,6 +43,36 @@ export interface CreateFromMessageResponse {
|
||||
data: FreeformDocDetail;
|
||||
}
|
||||
|
||||
/**
|
||||
* 直接创建自由文档(上传附件即时注册服务端事实源)
|
||||
*/
|
||||
export async function createFreeformDoc(payload: {
|
||||
projectId: number;
|
||||
title: string;
|
||||
contentMarkdown: string;
|
||||
space?: 'my_private' | 'project_shared';
|
||||
clientKey?: string;
|
||||
}): Promise<FreeformDocDetail> {
|
||||
const res = await fetch('/api/v1/freeform-documents', {
|
||||
method: 'POST',
|
||||
headers: getBearerAuthHeaders('application/json'),
|
||||
body: JSON.stringify({
|
||||
project_id: payload.projectId,
|
||||
title: payload.title,
|
||||
content_markdown: payload.contentMarkdown,
|
||||
space: payload.space || 'my_private',
|
||||
client_key: payload.clientKey || undefined,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errData = await res.json().catch(() => ({}));
|
||||
const msg = errData.message || errData.error_code || `创建失败 (${res.status})`;
|
||||
throw new Error(typeof msg === 'string' ? msg : `创建失败 (${res.status})`);
|
||||
}
|
||||
const body = await res.json();
|
||||
return body.data;
|
||||
}
|
||||
|
||||
/**
|
||||
* 从单条智能体回复保存为我的私密文档(幂等)
|
||||
*/
|
||||
|
||||
@@ -26,3 +26,13 @@ test('InputArea shows thinking block message', () => {
|
||||
assert.equal(resolveThinkingBlock(true), '正在思考,请稍候');
|
||||
assert.equal(resolveThinkingBlock(false), null);
|
||||
});
|
||||
|
||||
test('InputArea keeps draft when attachment registration fails', () => {
|
||||
const applied = applySendResult(
|
||||
{ accepted: false, error: '附件「a.pdf」服务端注册失败:forbidden。未发送消息,请检查网络后重试。' },
|
||||
draft,
|
||||
);
|
||||
assert.equal(applied.cleared, false);
|
||||
assert.deepEqual(applied.draft, draft);
|
||||
assert.match(applied.error || '', /服务端注册失败/);
|
||||
});
|
||||
|
||||
@@ -19,7 +19,7 @@ interface InputAreaProps {
|
||||
text: string,
|
||||
attachments?: InputAttachmentItem[],
|
||||
quotes?: QuotedMessage[]
|
||||
) => SendMessageResult;
|
||||
) => SendMessageResult | Promise<SendMessageResult>;
|
||||
isThinking: boolean;
|
||||
isStreaming: boolean;
|
||||
onStopStreaming?: () => void;
|
||||
@@ -45,6 +45,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
const [attachments, setAttachments] = useState<InputAttachmentItem[]>([]);
|
||||
const [uploadWarning, setUploadWarning] = useState<string | null>(null);
|
||||
const [sendError, setSendError] = useState<string | null>(null);
|
||||
const [sending, setSending] = useState(false);
|
||||
const textareaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
@@ -56,7 +57,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
}
|
||||
}, [text]);
|
||||
|
||||
const handleSend = () => {
|
||||
const handleSend = async () => {
|
||||
const thinkingError = resolveThinkingBlock(isThinking);
|
||||
if (thinkingError) {
|
||||
setSendError(thinkingError);
|
||||
@@ -70,30 +71,42 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
return;
|
||||
}
|
||||
|
||||
if (sending) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!text.trim() && attachments.length === 0) {
|
||||
return;
|
||||
}
|
||||
|
||||
const result = onSendMessage(text.trim(), attachments, quotes);
|
||||
const applied = applySendResult(result, { text: text.trim(), attachments });
|
||||
if (!applied.cleared) {
|
||||
setSendError(applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSending(true);
|
||||
setSendError(null);
|
||||
setText(applied.draft.text);
|
||||
setAttachments(applied.draft.attachments);
|
||||
setUploadWarning(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '42px';
|
||||
try {
|
||||
const result = await onSendMessage(text.trim(), attachments, quotes);
|
||||
const applied = applySendResult(result, { text: text.trim(), attachments });
|
||||
if (!applied.cleared) {
|
||||
setSendError(applied.error);
|
||||
return;
|
||||
}
|
||||
|
||||
setSendError(null);
|
||||
setText(applied.draft.text);
|
||||
setAttachments(applied.draft.attachments);
|
||||
setUploadWarning(null);
|
||||
if (textareaRef.current) {
|
||||
textareaRef.current.style.height = '42px';
|
||||
}
|
||||
} catch (err) {
|
||||
setSendError(err instanceof Error ? err.message : '消息发送失败,请重试');
|
||||
} finally {
|
||||
setSending(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleKeyDown = (e: React.KeyboardEvent<HTMLTextAreaElement>) => {
|
||||
if (e.key === 'Enter' && !e.shiftKey) {
|
||||
e.preventDefault();
|
||||
handleSend();
|
||||
void handleSend();
|
||||
}
|
||||
};
|
||||
|
||||
@@ -454,16 +467,16 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
|
||||
{/* 智能发送按钮 */}
|
||||
<button
|
||||
onClick={handleSend}
|
||||
disabled={!text.trim() && attachments.length === 0 && !isStreaming}
|
||||
className={isThinking ? 'agent-pulse-active' : ''}
|
||||
onClick={() => void handleSend()}
|
||||
disabled={sending || (!text.trim() && attachments.length === 0 && !isStreaming)}
|
||||
className={isThinking || sending ? 'agent-pulse-active' : ''}
|
||||
style={{
|
||||
width: '36px',
|
||||
height: '36px',
|
||||
borderRadius: 'var(--r-md)',
|
||||
background: isStreaming
|
||||
? 'var(--danger)'
|
||||
: isThinking
|
||||
: isThinking || sending
|
||||
? 'var(--brand-600)'
|
||||
: 'var(--brand-grad)',
|
||||
color: '#fff',
|
||||
@@ -473,14 +486,19 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
flexShrink: 0,
|
||||
marginBottom: '3px',
|
||||
transition: 'all 0.2s ease',
|
||||
opacity: !text.trim() && attachments.length === 0 && !isThinking && !isStreaming ? 0.45 : 1,
|
||||
boxShadow: isThinking
|
||||
opacity:
|
||||
sending || (!text.trim() && attachments.length === 0 && !isThinking && !isStreaming)
|
||||
? 0.45
|
||||
: 1,
|
||||
boxShadow: isThinking || sending
|
||||
? '0 0 12px rgba(107, 132, 255, 0.7)'
|
||||
: '0 2px 8px rgba(79, 107, 255, 0.25)',
|
||||
}}
|
||||
title={
|
||||
isStreaming
|
||||
? '停止当前生成'
|
||||
: sending
|
||||
? '正在注册附件并发送…'
|
||||
: isThinking
|
||||
? '正在思考,请稍候'
|
||||
: '发送 (Enter)'
|
||||
@@ -488,7 +506,7 @@ export const InputArea: React.FC<InputAreaProps> = ({
|
||||
>
|
||||
{isStreaming ? (
|
||||
<Square size={14} fill="#fff" />
|
||||
) : isThinking ? (
|
||||
) : isThinking || sending ? (
|
||||
<Loader2 size={16} className="animate-spin-fast" />
|
||||
) : (
|
||||
<Send size={15} />
|
||||
|
||||
@@ -0,0 +1,128 @@
|
||||
import { describe, it } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
buildUploadClientKey,
|
||||
deliverableFromFreeformUpload,
|
||||
registerUploadAttachments,
|
||||
} from './uploadFreeformRegister';
|
||||
import type { FreeformDocDetail } from '../api/freeformDoc';
|
||||
|
||||
describe('uploadFreeformRegister', () => {
|
||||
it('buildUploadClientKey prefers explicit hint then doc-upload stamp', () => {
|
||||
assert.equal(buildUploadClientKey(100, 0, 'doc-upload-100-0'), 'doc-upload-100-0');
|
||||
assert.equal(buildUploadClientKey(100, 1), 'doc-upload-100-1');
|
||||
});
|
||||
|
||||
it('registerUploadAttachments hard-fails without creating orphan on API error', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
registerUploadAttachments({
|
||||
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
createFreeformDoc: async () => {
|
||||
throw new Error('network down');
|
||||
},
|
||||
}),
|
||||
/服务端注册失败/,
|
||||
);
|
||||
});
|
||||
|
||||
it('registerUploadAttachments rejects local doc-upload id from server', async () => {
|
||||
await assert.rejects(
|
||||
() =>
|
||||
registerUploadAttachments({
|
||||
attachments: [{ name: 'a.pdf', size: '1KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
createFreeformDoc: async () =>
|
||||
({
|
||||
document: {
|
||||
doc_id: 'doc-upload-bad',
|
||||
project_id: 12,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'x',
|
||||
title: 'a',
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'x',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: '# a',
|
||||
}) as FreeformDocDetail,
|
||||
}),
|
||||
/未返回服务端文档 ID/,
|
||||
);
|
||||
});
|
||||
|
||||
it('registerUploadAttachments returns server doc-free ids and freeform facts', async () => {
|
||||
const registered = await registerUploadAttachments({
|
||||
attachments: [{ name: 'spec.pdf', size: '2KB', type: 'application/pdf' }],
|
||||
projectId: 12,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
now: 1789263145197,
|
||||
createFreeformDoc: async (payload) => {
|
||||
assert.equal(payload.clientKey, 'doc-upload-1789263145197-0');
|
||||
assert.ok(payload.contentMarkdown.includes('spec.pdf'));
|
||||
return {
|
||||
document: {
|
||||
doc_id: 'doc-free-1789263145197-2',
|
||||
project_id: 12,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'abc',
|
||||
client_key: payload.clientKey,
|
||||
title: payload.title,
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'abc',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: payload.contentMarkdown,
|
||||
};
|
||||
},
|
||||
});
|
||||
assert.equal(registered.length, 1);
|
||||
assert.equal(registered[0].serverDocId, 'doc-free-1789263145197-2');
|
||||
assert.equal(registered[0].deliverable.governanceMode, 'freeform');
|
||||
assert.equal(registered[0].deliverable.sourceType, 'uploaded_file');
|
||||
assert.equal(registered[0].attachment.docId, 'doc-free-1789263145197-2');
|
||||
});
|
||||
|
||||
it('deliverableFromFreeformUpload maps server id and upload facts', () => {
|
||||
const d = deliverableFromFreeformUpload({
|
||||
detail: {
|
||||
document: {
|
||||
doc_id: 'doc-free-1-2',
|
||||
project_id: 9,
|
||||
owner_user_id: 2,
|
||||
source_content_sha256: 'h',
|
||||
title: 't',
|
||||
governance_mode: 'freeform',
|
||||
space: 'my_private',
|
||||
current_version_id: 1,
|
||||
current_sha256: 'h',
|
||||
is_deleted: false,
|
||||
created_at: '',
|
||||
updated_at: '',
|
||||
},
|
||||
content_markdown: '# t',
|
||||
},
|
||||
projectId: 9,
|
||||
userId: 2,
|
||||
userName: '张工',
|
||||
att: { name: 't.pdf', size: '1KB', type: 'application/pdf' },
|
||||
});
|
||||
assert.equal(d.docId, 'doc-free-1-2');
|
||||
assert.equal(d.sourceType, 'uploaded_file');
|
||||
assert.equal(d.governanceMode, 'freeform');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,153 @@
|
||||
/**
|
||||
* 上传附件 → 服务端 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;
|
||||
docId?: string;
|
||||
fileRaw?: File | Blob;
|
||||
fileUrl?: 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;
|
||||
}): string {
|
||||
const { title, userName, fileName, fileSize, mimeType } = params;
|
||||
return `# ${title}\n\n> 上传人:${userName} | 上传时间:${new Date().toLocaleString()} | 文件大小:${fileSize}\n\n## 1. 文档概述与背景\n\n本文件由用户上传并归档至【个人私密文档库】。原始附件:\`${fileName}\`(\`${mimeType || 'document'}\`)。\n\n## 2. 协作与共享说明\n\n- **当前状态**:🔒 **个人私密 (仅本人可见)**\n- 您可在底部操作栏点击「一键共享给项目组」,将该文档共享至项目协作参考库供其他成员查阅与评审。\n`;
|
||||
}
|
||||
|
||||
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,
|
||||
mimeType: att.type,
|
||||
extension: ext,
|
||||
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;
|
||||
}) => 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,
|
||||
});
|
||||
let detail: FreeformDocDetail;
|
||||
try {
|
||||
detail = await createFreeformDoc({
|
||||
projectId,
|
||||
title: baseTitle || att.name,
|
||||
contentMarkdown: markdown,
|
||||
space: 'my_private',
|
||||
clientKey,
|
||||
});
|
||||
} 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 };
|
||||
Reference in New Issue
Block a user