/** * 视频归档服务 * 功能:下载临时视频URL,上传到S3,更新数据库记录 */ import { db } from "@/lib/db"; import { message } from "@/lib/db/schema"; import { eq } from "drizzle-orm"; import { uploadFile } from "@/lib/storage/service"; import type { VideoMetadata } from "@/types/ai"; 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 { console.log(`[VideoArchiver] 📹 开始归档视频:${messageId}`); // 1. 更新状态为"归档中" await this.updateVideoStatus(messageId, "archiving"); // 2. 下载视频 console.log(`[VideoArchiver] ⬇️ 下载视频:${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(使用项目统一存储服务) console.log(`[VideoArchiver] ⬆️ 上传视频到S3:${filename}`); const permanentUrl = await this.uploadVideoToS3(videoBuffer, filename); // 5. 更新数据库 await this.updateVideoUrls(messageId, { permanentUrl, fileSize: videoBuffer.length, ...metadata, }); console.log(`[VideoArchiver] ✅ 视频归档完成:${permanentUrl}`); return permanentUrl; } catch (error) { console.error(`[VideoArchiver] ❌ 归档失败:`, error); // 更新状态为"失败" 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) { console.error( `[VideoArchiver] 下载失败 (尝试 ${i + 1}/${retries}):`, 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 { // 直接使用项目已有的 uploadFile 方法 // 支持:数据库配置的S3 / 环境变量S3 / Vercel Blob const result = await uploadFile(filename, buffer as any); console.log(`[VideoArchiver] 视频已上传到S3:${result.url}`); return result.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; 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) || {}; await db .update(message) .set({ permanentVideoUrl: data.permanentUrl, videoStorageStatus: "archived", archivedAt: new Date(), videoMetadata: { ...existingMetadata, fileSize: data.fileSize, duration: data.duration, resolution: data.resolution, ratio: data.ratio, } as any, }) .where(eq(message.id, messageId)); } } // 单例模式 export const videoArchiver = new VideoArchiver();