ai-chatbot/lib/storage/video-archiver.ts
2026-05-27 10:52:00 +08:00

247 lines
6.3 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 视频归档服务
* 功能:下载临时视频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<string> {
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<Buffer | null> {
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<string> {
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();