/** * 视频归档服务 * 功能:下载临时视频URL,上传到S3,更新数据库记录 */ import { db } from "@/lib/db"; import { message } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { getDefaultStorageConfig } from "@/lib/db/queries"; import { createLogger } from "@/lib/observability/logger"; import { toDbS3UploadConfig, uploadFile, uploadFileWithS3Config, } from "@/lib/storage/service"; import type { VideoMetadata } from "@/types/ai"; const logger = createLogger("video-archiver"); interface VideoArchiveOptions { messageId: string; temporaryUrl: string; taskId: string; metadata?: { duration?: number; resolution?: string; ratio?: string; }; } export class VideoArchiver { /** * 归档视频:下载 → 上传S3 → 更新数据库 */ async archiveVideo(options: VideoArchiveOptions): Promise { const { messageId, temporaryUrl, taskId, metadata } = options; try { logger.info("archive started", { messageId, taskId }); // 1. 更新状态为"归档中" await this.updateVideoStatus(messageId, "archiving"); // 2. 下载视频 logger.info("download started", { messageId, temporaryUrl }); const videoBuffer = await this.downloadVideo(temporaryUrl); if (!videoBuffer) { throw new Error("Failed to download video from temporary URL"); } // 3. 生成文件名 const filename = this.generateVideoFilename(messageId, taskId); // 4. 上传到S3(使用项目统一存储服务) logger.info("upload started", { messageId, filename, bytes: videoBuffer.length, }); const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename); // 5. 更新数据库 await this.updateVideoUrls(messageId, { permanentUrl, temporaryUrl, fileSize: videoBuffer.length, ...metadata, }); logger.info("archive completed", { messageId, permanentUrl, bytes: videoBuffer.length, }); return permanentUrl; } catch (error) { logger.error("archive failed", error, { messageId, taskId }); // 更新状态为"失败" await this.updateVideoStatus(messageId, "failed"); throw error; } } /** * 下载视频(带重试机制) */ private async downloadVideo( url: string, retries = 3 ): Promise { for (let i = 0; i < retries; i++) { try { const response = await fetch(url, { headers: { "User-Agent": "AI-Chatbot-Video-Archiver/1.0", }, }); if (!response.ok) { throw new Error(`HTTP ${response.status}: ${response.statusText}`); } const arrayBuffer = await response.arrayBuffer(); return Buffer.from(arrayBuffer); } catch (error) { logger.warn("download attempt failed", { attempt: i + 1, retries, url, error, }); if (i === retries - 1) { return null; } // 等待后重试(指数退避) await new Promise((resolve) => setTimeout(resolve, Math.pow(2, i) * 1000) ); } } return null; } /** * 上传视频到S3(使用项目统一存储服务) */ private async uploadVideoToS3( buffer: Buffer, filename: string ): Promise { const defaultStorageConfig = await getDefaultStorageConfig().catch( () => undefined ); const defaultS3Config = toDbS3UploadConfig(defaultStorageConfig); if (defaultS3Config) { const uploaded = await uploadFileWithS3Config( filename, buffer as any, defaultS3Config ); logger.info("upload completed with default storage", { filename, url: uploaded.url, storageConfigName: defaultStorageConfig?.name, }); return uploaded.url; } logger.warn("default storage unavailable, using fallback uploader", { filename, }); const fallback = await uploadFile(filename, buffer as any); logger.info("upload completed with fallback storage", { filename, url: fallback.url, }); return fallback.url; } /** * 生成视频文件名 * 项目的 uploadFile 会自动生成 uploads/timestamp-random.ext 格式 */ private generateVideoFilename(messageId: string, taskId: string): string { // 使用唯一标识作为文件名 return `video-${messageId}-${taskId}.mp4`; } /** * 更新视频状态 */ private async updateVideoStatus( messageId: string, status: "temporary" | "archiving" | "archived" | "failed" ) { await db .update(message) .set({ videoStorageStatus: status }) .where(eq(message.id, messageId)); } /** * 更新视频URL和元数据 */ private async updateVideoUrls( messageId: string, data: { permanentUrl: string; temporaryUrl: string; fileSize: number; duration?: number; resolution?: string; ratio?: string; } ) { // 获取现有的 videoMetadata const existing = await db.query.message.findFirst({ where: eq(message.id, messageId), }); const existingMetadata = (existing?.videoMetadata as VideoMetadata) || {}; const existingParts = Array.isArray(existing?.parts) ? existing.parts : []; const nextParts = existingParts.map((part: any) => { if (!part || part.type !== "text" || typeof part.text !== "string") { return part; } return { ...part, text: part.text.split(data.temporaryUrl).join(data.permanentUrl), }; }); await db .update(message) .set({ parts: nextParts as any, permanentVideoUrl: data.permanentUrl, videoStorageStatus: "archived", archivedAt: new Date(), videoMetadata: { ...existingMetadata, temporaryVideoUrl: data.temporaryUrl, permanentVideoUrl: data.permanentUrl, storageStatus: "archived", archivedAt: Date.now(), updatedAt: Date.now(), fileSize: data.fileSize, duration: data.duration, resolution: data.resolution, ratio: data.ratio, } as any, }) .where(eq(message.id, messageId)); } } // 单例模式 export const videoArchiver = new VideoArchiver();