177 lines
4.9 KiB
TypeScript
177 lines
4.9 KiB
TypeScript
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';
|
||
}
|
||
}
|