Files
upage-git/app/.server/storage/local-provider.server.ts

177 lines
4.9 KiB
TypeScript
Raw Permalink 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.

import fs from 'fs';
import path from 'path';
import { createScopedLogger } from '~/utils/logger';
import { BaseStorageProvider } from './base-provider.server';
import type { StorageFile, StorageUploadOptions } from './types';
const logger = createScopedLogger('storage.local-provider');
/**
* 本地文件存储提供者
* 将文件存储在本地文件系统中
*/
export class LocalStorageProvider extends BaseStorageProvider {
private baseDir: string;
constructor(baseDir?: string) {
super();
// 默认使用项目根目录下的 public/uploads 目录
this.baseDir = baseDir || path.join(process.cwd(), 'public', 'uploads');
this.ensureDirectoryExists(this.baseDir);
logger.debug('本地存储初始化', JSON.stringify({ baseDir: this.baseDir }));
}
/**
* 上传文件
* @param options 上传选项
* @returns 存储文件信息
*/
async uploadFile(options: StorageUploadOptions): Promise<StorageFile> {
const { userId, contentType, filename, data } = options;
// 生成唯一文件名
const uniqueFilename = this.generateUniqueFilename(filename);
// 确保用户目录存在
const userDir = path.join(this.baseDir, userId);
this.ensureDirectoryExists(userDir);
const filePath = path.join(userDir, uniqueFilename);
try {
if (typeof data === 'string') {
// base64 数据
if (data.startsWith('data:')) {
const base64Data = data.split(',')[1];
await fs.promises.writeFile(filePath, Buffer.from(base64Data, 'base64'));
} else {
await fs.promises.writeFile(filePath, data);
}
} else if (Buffer.isBuffer(data)) {
await fs.promises.writeFile(filePath, data);
} else {
// 处理 Blob 类型
const arrayBuffer = await data.arrayBuffer();
await fs.promises.writeFile(filePath, Buffer.from(arrayBuffer));
}
// 获取文件大小
const stats = await fs.promises.stat(filePath);
logger.debug('文件上传成功', { userId, filename: uniqueFilename, size: stats.size });
return {
filename: uniqueFilename,
contentType,
size: stats.size,
path: filePath,
metadata: options.metadata,
};
} catch (error) {
logger.error('文件上传失败', { userId, filename, error });
throw new Error(`文件上传失败: ${error instanceof Error ? error.message : String(error)}`);
}
}
/**
* 获取文件
* @param userId 用户ID
* @param filename 文件名
* @returns 存储文件信息如果不存在则返回null
*/
async getFile(userId: string, filename: string): Promise<StorageFile | null> {
const filePath = path.join(this.baseDir, userId, filename);
try {
const stats = await fs.promises.stat(filePath);
if (!stats.isFile()) {
return null;
}
const contentType = this.getContentTypeFromFilename(filename);
return {
filename,
contentType,
size: stats.size,
path: filePath,
};
} catch (error) {
logger.error('获取文件失败', { userId, filename, error });
return null;
}
}
/**
* 删除文件
* @param userId 用户ID
* @param filename 文件名
* @returns 是否删除成功
*/
async deleteFile(userId: string, filename: string): Promise<boolean> {
const filePath = path.join(this.baseDir, userId, filename);
try {
await fs.promises.unlink(filePath);
logger.debug('文件删除成功', { userId, filename });
return true;
} catch (error) {
logger.error('删除文件失败', { userId, filename, error });
return false;
}
}
/**
* 检查文件是否存在
* @param userId 用户ID
* @param filename 文件名
* @returns 文件是否存在
*/
async fileExists(userId: string, filename: string): Promise<boolean> {
const filePath = path.join(this.baseDir, userId, filename);
try {
await fs.promises.access(filePath, fs.constants.F_OK);
return true;
} catch {
return false;
}
}
/**
* 确保目录存在
* @param dir 目录路径
*/
private ensureDirectoryExists(dir: string): void {
if (!fs.existsSync(dir)) {
fs.mkdirSync(dir, { recursive: true });
}
}
/**
* 根据文件名获取内容类型
* @param filename 文件名
* @returns 内容类型
*/
private getContentTypeFromFilename(filename: string): string {
const extension = this.getFileExtension(filename).toLowerCase();
// 常见文件类型映射
const mimeTypes: Record<string, string> = {
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.png': 'image/png',
'.gif': 'image/gif',
'.webp': 'image/webp',
'.svg': 'image/svg+xml',
'.mp4': 'video/mp4',
'.webm': 'video/webm',
'.mp3': 'audio/mpeg',
'.wav': 'audio/wav',
'.pdf': 'application/pdf',
};
return mimeTypes[extension] || 'application/octet-stream';
}
}