diff --git a/scripts/validate-attachment-analysis.mjs b/scripts/validate-attachment-analysis.mjs index ee55c48..e831722 100644 --- a/scripts/validate-attachment-analysis.mjs +++ b/scripts/validate-attachment-analysis.mjs @@ -199,6 +199,10 @@ try { "queued task should carry attachment file name", ); assert.ok(textUpload.analysisTask.attachmentDownloadUrl, "queued task should expose attachment download url"); + assert.ok( + textUpload.analysisTask.attachmentDownloadUrl.startsWith(currentServer.baseUrl), + "queued task should use the current runtime origin for attachment download", + ); const promptDownloadUrlMatch = textUpload.analysisTask.executionPrompt.match(/downloadUrl:\s+(http[^\s]+)/); assert.ok(promptDownloadUrlMatch, "execution prompt should include attachment download url"); const unauthDownloadResponse = await fetch(textUpload.analysisTask.attachmentDownloadUrl); diff --git a/src/app/api/v1/attachments/[attachmentId]/download/route.ts b/src/app/api/v1/attachments/[attachmentId]/download/route.ts index aaba6e1..ab1118d 100644 --- a/src/app/api/v1/attachments/[attachmentId]/download/route.ts +++ b/src/app/api/v1/attachments/[attachmentId]/download/route.ts @@ -53,14 +53,30 @@ export async function GET( } if (record.attachment.storageBackend === "aliyun_oss") { - const storageConfig = await getAttachmentStorageConfig(record.attachment.uploadedBy); - if (storageConfig.mode !== "oss" || storageConfig.ossProvider !== "aliyun_oss" || !storageConfig.aliyunOss) { + const aliyunConfig = + record.attachment.storageSnapshot?.provider === "aliyun_oss" + ? { + enabled: true, + accessKeyId: record.attachment.storageSnapshot.accessKeyId, + accessKeySecretEncrypted: record.attachment.storageSnapshot.accessKeySecretEncrypted, + bucket: record.attachment.storageSnapshot.bucket, + endpoint: record.attachment.storageSnapshot.endpoint, + region: record.attachment.storageSnapshot.region, + prefix: record.attachment.storageSnapshot.prefix, + } + : null; + const storageConfig = aliyunConfig ? null : await getAttachmentStorageConfig(record.attachment.uploadedBy); + const resolvedConfig = + aliyunConfig ?? + (storageConfig?.mode === "oss" && + storageConfig.ossProvider === "aliyun_oss" && + storageConfig.aliyunOss + ? storageConfig.aliyunOss + : null); + if (!resolvedConfig) { return NextResponse.json({ ok: false, message: "ATTACHMENT_STORAGE_CONFIG_NOT_FOUND" }, { status: 404 }); } - const signedUrl = await getAliyunOssSignedDownloadUrl( - storageConfig.aliyunOss, - record.attachment.storagePath, - ); + const signedUrl = await getAliyunOssSignedDownloadUrl(resolvedConfig, record.attachment.storagePath); return NextResponse.redirect(signedUrl, { status: 307, headers: buildAttachmentDownloadHeaders(record.attachment), diff --git a/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts b/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts index 94d8451..ba813ab 100644 --- a/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts +++ b/src/app/api/v1/projects/[projectId]/attachments/[attachmentId]/analyze/route.ts @@ -6,6 +6,13 @@ import { queueAttachmentAnalysisTask } from "@/lib/boss-master-agent"; export const runtime = "nodejs"; +function resolveRequestPublicBaseUrl(request: NextRequest) { + const protocol = + request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http"; + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host; + return `${protocol}://${host}`; +} + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string; attachmentId: string }> }, @@ -45,6 +52,7 @@ export async function POST( requestedBy: session.displayName || "你", requestedByAccount: session.account, markProcessing: true, + publicBaseUrl: resolveRequestPublicBaseUrl(request), }); return NextResponse.json({ ok: true, taskId: task.taskId, task }); diff --git a/src/app/api/v1/projects/[projectId]/attachments/route.ts b/src/app/api/v1/projects/[projectId]/attachments/route.ts index f04a98e..8963403 100644 --- a/src/app/api/v1/projects/[projectId]/attachments/route.ts +++ b/src/app/api/v1/projects/[projectId]/attachments/route.ts @@ -18,6 +18,13 @@ function randomToken(prefix: string) { return `${prefix}-${randomBytes(4).toString("hex")}`; } +function resolveRequestPublicBaseUrl(request: NextRequest) { + const protocol = + request.headers.get("x-forwarded-proto") ?? request.nextUrl.protocol.replace(/:$/, "") ?? "http"; + const host = request.headers.get("x-forwarded-host") ?? request.headers.get("host") ?? request.nextUrl.host; + return `${protocol}://${host}`; +} + export async function POST( request: NextRequest, context: { params: Promise<{ projectId: string }> }, @@ -69,6 +76,21 @@ export async function POST( attachmentKind, storageBackend: stored.storageBackend, storagePath: stored.storagePath, + storageSnapshot: + stored.storageBackend === "aliyun_oss" && + storageConfig.mode === "oss" && + storageConfig.ossProvider === "aliyun_oss" && + storageConfig.aliyunOss + ? { + provider: "aliyun_oss", + accessKeyId: storageConfig.aliyunOss.accessKeyId, + accessKeySecretEncrypted: storageConfig.aliyunOss.accessKeySecretEncrypted, + bucket: storageConfig.aliyunOss.bucket, + endpoint: storageConfig.aliyunOss.endpoint, + region: storageConfig.aliyunOss.region, + prefix: storageConfig.aliyunOss.prefix, + } + : undefined, previewAvailable: attachmentKind === "image" || attachmentKind === "video" || attachmentKind === "pdf", uploadedAt: new Date().toISOString(), uploadedBy: session.account, @@ -90,6 +112,7 @@ export async function POST( requestMessageId: message.id, requestedBy: session.displayName || "你", requestedByAccount: session.account, + publicBaseUrl: resolveRequestPublicBaseUrl(request), }); } diff --git a/src/lib/boss-attachments.ts b/src/lib/boss-attachments.ts index 37cc71d..bf89877 100644 --- a/src/lib/boss-attachments.ts +++ b/src/lib/boss-attachments.ts @@ -7,6 +7,7 @@ import type { const LARGE_ATTACHMENT_THRESHOLD_BYTES = 20 * 1024 * 1024; const MAX_ATTACHMENT_TEXT_EXCERPT_CHARS = 12_000; +const MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES = 2 * 1024 * 1024; function extensionOf(fileName: string) { return path.extname(fileName).toLowerCase(); @@ -83,6 +84,12 @@ export function canExtractAttachmentText(attachment: Pick, +) { + return canExtractAttachmentText(attachment) && attachment.fileSizeBytes <= MAX_ATTACHMENT_TEXT_EXCERPT_SOURCE_BYTES; +} + export function extractAttachmentTextExcerpt(buffer: Buffer | Uint8Array) { const normalized = Buffer.from(buffer) .toString("utf8") diff --git a/src/lib/boss-data.ts b/src/lib/boss-data.ts index f67d13a..54294c7 100644 --- a/src/lib/boss-data.ts +++ b/src/lib/boss-data.ts @@ -41,6 +41,15 @@ export interface MessageAttachment { attachmentKind: AttachmentKind; storageBackend: AttachmentStorageBackend; storagePath: string; + storageSnapshot?: { + provider: "aliyun_oss"; + accessKeyId: string; + accessKeySecretEncrypted: string; + bucket: string; + endpoint: string; + region: string; + prefix?: string; + }; previewAvailable: boolean; uploadedAt: string; uploadedBy: string; @@ -1920,6 +1929,18 @@ function normalizeMessageAttachment(raw: Partial): MessageAtt attachmentKind: raw.attachmentKind ?? "binary", storageBackend: raw.storageBackend ?? "server_file", storagePath: raw.storagePath ?? "", + storageSnapshot: + raw.storageSnapshot?.provider === "aliyun_oss" + ? { + provider: "aliyun_oss", + accessKeyId: raw.storageSnapshot.accessKeyId ?? "", + accessKeySecretEncrypted: raw.storageSnapshot.accessKeySecretEncrypted ?? "", + bucket: raw.storageSnapshot.bucket ?? "", + endpoint: raw.storageSnapshot.endpoint ?? "", + region: raw.storageSnapshot.region ?? "", + prefix: raw.storageSnapshot.prefix, + } + : undefined, previewAvailable: raw.previewAvailable ?? false, uploadedAt: raw.uploadedAt ?? nowIso(), uploadedBy: raw.uploadedBy ?? "system", diff --git a/src/lib/boss-master-agent.ts b/src/lib/boss-master-agent.ts index e4cc3ea..d529ad9 100644 --- a/src/lib/boss-master-agent.ts +++ b/src/lib/boss-master-agent.ts @@ -13,7 +13,7 @@ import { updateAttachmentAnalysisResult, updateAiAccountHealth, } from "@/lib/boss-data"; -import { canExtractAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; +import { canInlineAttachmentText, extractAttachmentTextExcerpt } from "@/lib/boss-attachments"; import { readAliyunOssObjectBuffer } from "@/lib/boss-storage-aliyun-oss"; import { readServerFileAttachmentBuffer } from "@/lib/boss-storage-server-file"; @@ -236,14 +236,33 @@ async function buildAttachmentAnalysisContext(params: { const attachment = params.attachment; let excerpt = ""; try { - if (canExtractAttachmentText(attachment)) { + if (canInlineAttachmentText(attachment)) { let buffer: Buffer | Uint8Array = Buffer.alloc(0); if (attachment.storageBackend === "server_file") { buffer = await readServerFileAttachmentBuffer(attachment.storagePath); } else if (attachment.storageBackend === "aliyun_oss") { - const config = await getAttachmentStorageConfig(attachment.uploadedBy); - if (config.mode === "oss" && config.ossProvider === "aliyun_oss" && config.aliyunOss) { - buffer = await readAliyunOssObjectBuffer(config.aliyunOss, attachment.storagePath); + if (attachment.storageSnapshot?.provider === "aliyun_oss") { + buffer = await readAliyunOssObjectBuffer( + { + enabled: true, + accessKeyId: attachment.storageSnapshot.accessKeyId, + accessKeySecretEncrypted: attachment.storageSnapshot.accessKeySecretEncrypted, + bucket: attachment.storageSnapshot.bucket, + endpoint: attachment.storageSnapshot.endpoint, + region: attachment.storageSnapshot.region, + prefix: attachment.storageSnapshot.prefix, + }, + attachment.storagePath, + ); + } else { + const currentConfig = await getAttachmentStorageConfig(attachment.uploadedBy); + if ( + currentConfig.mode === "oss" && + currentConfig.ossProvider === "aliyun_oss" && + currentConfig.aliyunOss + ) { + buffer = await readAliyunOssObjectBuffer(currentConfig.aliyunOss, attachment.storagePath); + } } } excerpt = extractAttachmentTextExcerpt(buffer); @@ -311,6 +330,7 @@ export async function queueAttachmentAnalysisTask(params: { requestedBy: string; requestedByAccount: string; markProcessing?: boolean; + publicBaseUrl?: string; }) { const record = await getProjectAttachment(params.projectId, params.attachmentId); if (!record) { @@ -322,7 +342,7 @@ export async function queueAttachmentAnalysisTask(params: { const attachmentDownloadToken = randomBytes(12).toString("hex"); const attachmentDownloadExpiresAt = new Date(Date.now() + 30 * 60_000).toISOString(); const attachmentDownloadUrl = - `${resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` + + `${params.publicBaseUrl?.trim() || resolveBossPublicBaseUrl()}/api/v1/attachments/${record.attachment.attachmentId}/download` + `?taskId=${taskId}&token=${attachmentDownloadToken}`; const attachmentContext = await buildAttachmentAnalysisContext({ attachment: record.attachment,