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,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) : '';
}
}

View File

@@ -0,0 +1,35 @@
import { createScopedLogger } from '~/utils/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';

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';
}
}

View 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;
}