🎉 first commit
This commit is contained in:
66
app/lib/storage/base-provider.server.ts
Normal file
66
app/lib/storage/base-provider.server.ts
Normal file
@@ -0,0 +1,66 @@
|
||||
import type { StorageFile, StorageProvider, StorageUploadOptions } from './types';
|
||||
|
||||
/**
|
||||
* 存储提供者类
|
||||
* 提供通用的文件存储功能实现
|
||||
*/
|
||||
export abstract class BaseStorageProvider implements StorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param options 上传选项
|
||||
*/
|
||||
abstract uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
|
||||
|
||||
/**
|
||||
* 获取文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract getFile(userId: string, filename: string): Promise<StorageFile | null>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract deleteFile(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
*/
|
||||
abstract fileExists(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 生成文件访问URL
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
getFileUrl(userId: string, filename: string): string {
|
||||
return `/assets/${userId}/${filename}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 生成唯一文件名
|
||||
* @param originalFilename 原始文件名
|
||||
* @returns 唯一文件名
|
||||
*/
|
||||
protected generateUniqueFilename(originalFilename: string): string {
|
||||
const timestamp = Date.now();
|
||||
const randomStr = Math.random().toString(36).substring(2, 8);
|
||||
const extension = this.getFileExtension(originalFilename);
|
||||
return `${timestamp}-${randomStr}${extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 获取文件扩展名
|
||||
* @param filename 文件名
|
||||
* @returns 文件扩展名(包含点号)
|
||||
*/
|
||||
protected getFileExtension(filename: string): string {
|
||||
const lastDotIndex = filename.lastIndexOf('.');
|
||||
return lastDotIndex !== -1 ? filename.substring(lastDotIndex) : '';
|
||||
}
|
||||
}
|
||||
35
app/lib/storage/index.server.ts
Normal file
35
app/lib/storage/index.server.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { createScopedLogger } from '~/lib/.server/logger';
|
||||
import { LocalStorageProvider } from './local-provider.server';
|
||||
import type { StorageProvider } from './types';
|
||||
|
||||
const logger = createScopedLogger('storage');
|
||||
|
||||
/**
|
||||
* 获取存储目录配置
|
||||
* @returns 存储目录路径
|
||||
*/
|
||||
const getStorageDir = (): string | undefined => {
|
||||
// 如果在Docker环境中运行,使用环境变量中的配置
|
||||
if (process.env.RUNNING_IN_DOCKER === 'true' && process.env.STORAGE_DIR) {
|
||||
logger.debug('使用Docker环境中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
|
||||
return process.env.STORAGE_DIR;
|
||||
}
|
||||
|
||||
// 使用环境变量中的配置
|
||||
if (process.env.STORAGE_DIR) {
|
||||
logger.debug('使用环境变量中的存储目录', JSON.stringify({ dir: process.env.STORAGE_DIR }));
|
||||
return process.env.STORAGE_DIR;
|
||||
}
|
||||
|
||||
// 默认使用项目根目录下的 public/uploads 目录
|
||||
logger.debug('使用默认存储目录');
|
||||
return undefined;
|
||||
};
|
||||
|
||||
const createStorageProvider = (): StorageProvider => {
|
||||
return new LocalStorageProvider(getStorageDir());
|
||||
};
|
||||
|
||||
export const storageProvider = createStorageProvider();
|
||||
|
||||
export * from './types';
|
||||
176
app/lib/storage/local-provider.server.ts
Normal file
176
app/lib/storage/local-provider.server.ts
Normal file
@@ -0,0 +1,176 @@
|
||||
import fs from 'fs';
|
||||
import path from 'path';
|
||||
import { createScopedLogger } from '~/lib/.server/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';
|
||||
}
|
||||
}
|
||||
75
app/lib/storage/types.ts
Normal file
75
app/lib/storage/types.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
/**
|
||||
* 存储文件信息接口
|
||||
*/
|
||||
export interface StorageFile {
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 内容类型 */
|
||||
contentType: string;
|
||||
/** 文件大小(字节) */
|
||||
size: number;
|
||||
/** 文件路径 */
|
||||
path: string;
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储上传选项接口
|
||||
*/
|
||||
export interface StorageUploadOptions {
|
||||
/** 用户ID */
|
||||
userId: string;
|
||||
/** 内容类型 */
|
||||
contentType: string;
|
||||
/** 文件名 */
|
||||
filename: string;
|
||||
/** 文件数据 */
|
||||
data: Buffer | Blob | string;
|
||||
/** 元数据 */
|
||||
metadata?: Record<string, any>;
|
||||
}
|
||||
|
||||
/**
|
||||
* 存储提供者接口
|
||||
*/
|
||||
export interface StorageProvider {
|
||||
/**
|
||||
* 上传文件
|
||||
* @param options 上传选项
|
||||
* @returns 存储文件信息
|
||||
*/
|
||||
uploadFile(options: StorageUploadOptions): Promise<StorageFile>;
|
||||
|
||||
/**
|
||||
* 获取文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 存储文件信息,如果不存在则返回null
|
||||
*/
|
||||
getFile(userId: string, filename: string): Promise<StorageFile | null>;
|
||||
|
||||
/**
|
||||
* 删除文件
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 是否删除成功
|
||||
*/
|
||||
deleteFile(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 检查文件是否存在
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件是否存在
|
||||
*/
|
||||
fileExists(userId: string, filename: string): Promise<boolean>;
|
||||
|
||||
/**
|
||||
* 生成文件访问URL
|
||||
* @param userId 用户ID
|
||||
* @param filename 文件名
|
||||
* @returns 文件访问URL
|
||||
*/
|
||||
getFileUrl(userId: string, filename: string): string;
|
||||
}
|
||||
Reference in New Issue
Block a user