refactor: repartition server-side and client-side code

This commit is contained in:
LIlGG
2025-10-11 18:26:07 +08:00
parent 7acc4949fb
commit e9b573a276
309 changed files with 631 additions and 962 deletions

View File

@@ -0,0 +1,176 @@
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';
}
}