This commit is contained in:
2025-10-20 13:06:06 +08:00
commit 9b45c4e3e8
48 changed files with 5843 additions and 0 deletions
+317
View File
@@ -0,0 +1,317 @@
/**
* MinIO 工具函数使用示例
* 这个文件展示了如何使用 minio.ts 中的各种函数
*/
import {
uploadFile,
getFileUrl,
deleteFile,
listFiles,
generateUniqueFileName,
validateFileType,
validateFileSize,
downloadFile,
fileExists,
getFileInfo,
} from './minio';
// ========================================
// 1. 上传文件示例
// ========================================
/**
* 上传用户头像
*/
async function uploadAvatar(file: File, userId: string) {
// 验证文件类型
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
// 验证文件大小(5MB)
const maxSize = 5 * 1024 * 1024;
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大5MB)');
}
// 生成唯一文件名,存储在 avatars 目录下
const path = generateUniqueFileName(file.name, `avatars/${userId}`);
// 上传文件
const url = await uploadFile(file, path, {
'Content-Type': file.type,
'User-Id': userId,
});
return { url, path };
}
/**
* 上传文章封面图
*/
async function uploadPostCover(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
const maxSize = 10 * 1024 * 1024; // 10MB
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大10MB)');
}
// 按日期组织文件
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const path = generateUniqueFileName(
file.name,
`posts/${year}/${month}/covers`
);
const url = await uploadFile(file, path);
return { url, path };
}
/**
* 上传文章内容中的图片
*/
async function uploadPostImage(file: File, postId: string) {
const allowedTypes = ['jpg', 'jpeg', 'png', 'gif', 'webp'];
if (!validateFileType(file.name, allowedTypes)) {
throw new Error('不支持的图片格式');
}
const maxSize = 5 * 1024 * 1024; // 5MB
if (!validateFileSize(file.size, maxSize)) {
throw new Error('文件大小超过限制(最大5MB)');
}
const date = new Date();
const year = date.getFullYear();
const month = String(date.getMonth() + 1).padStart(2, '0');
const path = generateUniqueFileName(
file.name,
`posts/${year}/${month}/images`
);
const url = await uploadFile(file, path);
return { url, path };
}
// ========================================
// 2. 获取文件URL示例
// ========================================
/**
* 获取文件的公共访问URL
*/
function getPublicFileUrl(path: string) {
return getFileUrl(path);
}
// ========================================
// 3. 删除文件示例
// ========================================
/**
* 删除用户头像(更换头像时)
*/
async function deleteAvatar(avatarPath: string) {
try {
await deleteFile(avatarPath);
console.log('头像删除成功');
} catch (error) {
console.error('删除头像失败:', error);
throw error;
}
}
/**
* 删除文章时,删除相关的所有图片
*/
async function deletePostImages(postId: string) {
try {
// 列出文章相关的所有图片
const files = await listFiles(`posts/`, true);
// 过滤出该文章的图片(根据实际情况调整逻辑)
const postFiles = files.filter(file =>
file.name?.includes(postId)
);
// 批量删除
const paths = postFiles.map(f => f.name).filter((name): name is string => !!name);
if (paths.length > 0) {
const { deleteFiles } = await import('./minio');
await deleteFiles(paths);
console.log(`删除了 ${paths.length} 个文件`);
}
} catch (error) {
console.error('删除文章图片失败:', error);
throw error;
}
}
// ========================================
// 4. 列出文件示例
// ========================================
/**
* 获取用户的所有头像历史
*/
async function getUserAvatars(userId: string) {
try {
const files = await listFiles(`avatars/${userId}/`, false);
return files.map(file => ({
name: file.name,
size: file.size,
lastModified: file.lastModified,
url: file.name ? getFileUrl(file.name) : null,
}));
} catch (error) {
console.error('获取用户头像列表失败:', error);
throw error;
}
}
/**
* 获取某月的所有文章封面
*/
async function getPostCoversByMonth(year: number, month: number) {
try {
const monthStr = String(month).padStart(2, '0');
const files = await listFiles(`posts/${year}/${monthStr}/covers/`, false);
return files.map(file => ({
name: file.name,
size: file.size,
url: file.name ? getFileUrl(file.name) : null,
}));
} catch (error) {
console.error('获取封面列表失败:', error);
throw error;
}
}
// ========================================
// 5. 检查文件是否存在
// ========================================
/**
* 检查头像是否存在
*/
async function checkAvatarExists(avatarPath: string): Promise<boolean> {
return await fileExists(avatarPath);
}
// ========================================
// 6. 获取文件信息
// ========================================
/**
* 获取文件详细信息
*/
async function getFileDetails(path: string) {
try {
const info = await getFileInfo(path);
return {
size: info.size,
lastModified: info.lastModified,
etag: info.etag,
contentType: info.metaData?.['content-type'],
};
} catch (error) {
console.error('获取文件信息失败:', error);
throw error;
}
}
// ========================================
// 7. 下载文件示例
// ========================================
/**
* 下载文件到本地
*/
async function downloadFileToBuffer(path: string): Promise<Buffer> {
try {
return await downloadFile(path);
} catch (error) {
console.error('下载文件失败:', error);
throw error;
}
}
// ========================================
// 8. 在 API Route 中使用示例
// ========================================
/**
* Next.js API Route 示例:上传文件
*
* 使用方法:
*
* // app/api/upload/route.ts
* import { uploadFile, generateUniqueFileName } from '@/lib/minio';
*
* export async function POST(request: Request) {
* const formData = await request.formData();
* const file = formData.get('file') as File;
*
* if (!file) {
* return Response.json({ error: '没有文件' }, { status: 400 });
* }
*
* const path = generateUniqueFileName(file.name, 'uploads');
* const url = await uploadFile(file, path);
*
* return Response.json({ url, path });
* }
*/
/**
* Next.js API Route 示例:删除文件
*
* // app/api/delete/route.ts
* import { deleteFile } from '@/lib/minio';
*
* export async function DELETE(request: Request) {
* const { path } = await request.json();
*
* if (!path) {
* return Response.json({ error: '缺少文件路径' }, { status: 400 });
* }
*
* await deleteFile(path);
*
* return Response.json({ success: true });
* }
*/
// ========================================
// 导出示例函数
// ========================================
export {
uploadAvatar,
uploadPostCover,
uploadPostImage,
getPublicFileUrl,
deleteAvatar,
deletePostImages,
getUserAvatars,
getPostCoversByMonth,
checkAvatarExists,
getFileDetails,
downloadFileToBuffer,
};
+340
View File
@@ -0,0 +1,340 @@
import * as Minio from 'minio';
// MinIO 客户端配置
const useSSL = process.env.MINIO_USE_SSL === 'true';
const port = Number(process.env.MINIO_PORT) || 9000;
// 当使用标准HTTPS端口(443)或HTTP端口(80)时,MinIO客户端不需要指定端口
const shouldOmitPort = (useSSL && port === 443) || (!useSSL && port === 80);
const minioClient = new Minio.Client({
endPoint: process.env.MINIO_ENDPOINT || 'localhost',
...(shouldOmitPort ? {} : { port }),
useSSL,
accessKey: process.env.MINIO_ACCESS_KEY || '',
secretKey: process.env.MINIO_SECRET_KEY || '',
pathStyle: true, // 使用路径风格,对反向代理更友好
});
const BUCKET_NAME = process.env.MINIO_BUCKET_NAME || 'home-page';
/**
* 初始化 MinIO Bucket(确保 bucket 存在)
*/
export async function initBucket(): Promise<void> {
try {
const exists = await minioClient.bucketExists(BUCKET_NAME);
if (!exists) {
await minioClient.makeBucket(BUCKET_NAME, 'us-east-1');
console.log(`Bucket ${BUCKET_NAME} created successfully`);
}
// 设置公共读取策略(可选)
const policy = {
Version: '2012-10-17',
Statement: [
{
Effect: 'Allow',
Principal: { AWS: ['*'] },
Action: ['s3:GetObject'],
Resource: [`arn:aws:s3:::${BUCKET_NAME}/*`],
},
],
};
await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy));
} catch (error) {
console.error('Error initializing bucket:', error);
throw error;
}
}
/**
* 上传文件到 MinIO
* @param file - File 对象或 Buffer
* @param path - 文件存储路径(如: 'avatars/user123.jpg' 或 'posts/2024/image.png')
* @param metadata - 可选的元数据
* @returns 文件的访问URL
*/
export async function uploadFile(
file: File | Buffer,
path: string,
metadata?: Record<string, string>
): Promise<string> {
try {
await initBucket();
let buffer: Buffer;
let contentType: string;
if (file instanceof File) {
buffer = Buffer.from(await file.arrayBuffer());
contentType = file.type || 'application/octet-stream';
} else {
buffer = file;
contentType = metadata?.['Content-Type'] || 'application/octet-stream';
}
const metaData = {
'Content-Type': contentType,
...metadata,
};
await minioClient.putObject(BUCKET_NAME, path, buffer, buffer.length, metaData);
return getFileUrl(path);
} catch (error) {
console.error('Error uploading file:', error);
throw error;
}
}
/**
* 获取文件的公共访问URL
* @param path - 文件路径
* @returns 文件的访问URL
*/
export function getFileUrl(path: string): string {
const endpoint = process.env.MINIO_REAL_DOMAIN;
return `${endpoint}/${BUCKET_NAME}/${path}`;
}
/**
* 获取文件的临时访问URL(带签名,用于私有文件)
* @param path - 文件路径
* @param expirySeconds - 过期时间(秒),默认7天
* @returns 临时访问URL
*/
export async function getPresignedUrl(
path: string,
expirySeconds: number = 7 * 24 * 60 * 60
): Promise<string> {
try {
return await minioClient.presignedGetObject(BUCKET_NAME, path, expirySeconds);
} catch (error) {
console.error('Error generating presigned URL:', error);
throw error;
}
}
/**
* 下载文件
* @param path - 文件路径
* @returns 文件的 Buffer
*/
export async function downloadFile(path: string): Promise<Buffer> {
try {
const chunks: Buffer[] = [];
const stream = await minioClient.getObject(BUCKET_NAME, path);
return new Promise((resolve, reject) => {
stream.on('data', (chunk) => chunks.push(chunk));
stream.on('end', () => resolve(Buffer.concat(chunks)));
stream.on('error', reject);
});
} catch (error) {
console.error('Error downloading file:', error);
throw error;
}
}
/**
* 获取文件流
* @param path - 文件路径
* @returns 文件流
*/
export async function getFileStream(path: string): Promise<NodeJS.ReadableStream> {
try {
return await minioClient.getObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error getting file stream:', error);
throw error;
}
}
/**
* 删除文件
* @param path - 文件路径
*/
export async function deleteFile(path: string): Promise<void> {
try {
await minioClient.removeObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error deleting file:', error);
throw error;
}
}
/**
* 批量删除文件
* @param paths - 文件路径数组
*/
export async function deleteFiles(paths: string[]): Promise<void> {
try {
await minioClient.removeObjects(BUCKET_NAME, paths);
} catch (error) {
console.error('Error deleting files:', error);
throw error;
}
}
/**
* 检查文件是否存在
* @param path - 文件路径
* @returns 文件是否存在
*/
export async function fileExists(path: string): Promise<boolean> {
try {
await minioClient.statObject(BUCKET_NAME, path);
return true;
} catch (error) {
return false;
}
}
/**
* 获取文件信息
* @param path - 文件路径
* @returns 文件元数据
*/
export async function getFileInfo(path: string): Promise<Minio.BucketItemStat> {
try {
return await minioClient.statObject(BUCKET_NAME, path);
} catch (error) {
console.error('Error getting file info:', error);
throw error;
}
}
/**
* 列出指定路径下的所有文件
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
* @param recursive - 是否递归列出子目录
* @returns 文件列表
*/
export async function listFiles(
prefix: string = '',
recursive: boolean = false
): Promise<(Minio.BucketItem & { endpoint: string })[]> {
try {
const files: (Minio.BucketItem & { endpoint: string })[] = [];
const stream = minioClient.listObjects(BUCKET_NAME, prefix, recursive);
return new Promise((resolve, reject) => {
stream.on('data', (obj) => {
if (obj.name) {
files.push({ endpoint: `${process.env.MINIO_REAL_DOMAIN}/${BUCKET_NAME}`, ...obj, } as Minio.BucketItem & { endpoint: string });
}
});
stream.on('end', () => resolve(files));
stream.on('error', reject);
});
} catch (error) {
console.error('Error listing files:', error);
throw error;
}
}
/**
* 复制文件
* @param sourcePath - 源文件路径
* @param destPath - 目标文件路径
*/
export async function copyFile(sourcePath: string, destPath: string): Promise<void> {
try {
const conds = new Minio.CopyConditions();
await minioClient.copyObject(
BUCKET_NAME,
destPath,
`/${BUCKET_NAME}/${sourcePath}`,
conds
);
} catch (error) {
console.error('Error copying file:', error);
throw error;
}
}
/**
* 生成唯一文件名
* @param originalName - 原始文件名
* @param prefix - 路径前缀(如: 'avatars/' 或 'posts/2024/')
* @returns 带路径的唯一文件名
*/
export function generateUniqueFileName(originalName: string, prefix: string = ''): string {
const timestamp = Date.now();
const random = Math.random().toString(36).substring(2, 8);
const ext = originalName.split('.').pop();
const nameWithoutExt = originalName.replace(`.${ext}`, '').replace(/[^a-zA-Z0-9]/g, '_');
const fileName = `${nameWithoutExt}_${timestamp}_${random}.${ext}`;
return prefix ? `${prefix.replace(/\/$/, '')}/${fileName}` : fileName;
}
/**
* 获取文件扩展名
* @param filename - 文件名
* @returns 扩展名(不含点)
*/
export function getFileExtension(filename: string): string {
return filename.split('.').pop() || '';
}
/**
* 验证文件类型
* @param filename - 文件名
* @param allowedTypes - 允许的扩展名数组(如: ['jpg', 'png', 'gif'])
* @returns 是否为允许的类型
*/
export function validateFileType(filename: string, allowedTypes: string[]): boolean {
const ext = getFileExtension(filename).toLowerCase();
return allowedTypes.map(t => t.toLowerCase()).includes(ext);
}
/**
* 验证文件大小
* @param size - 文件大小(字节)
* @param maxSize - 最大大小(字节)
* @returns 是否在允许的大小范围内
*/
export function validateFileSize(size: number, maxSize: number): boolean {
return size <= maxSize;
}
/**
* 格式化文件大小
* @param bytes - 字节数
* @returns 格式化后的文件大小
*/
export function formatFileSize(bytes: number): string {
if (bytes === 0) return '0 B';
const k = 1024;
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(2))} ${sizes[i]}`;
}
export default {
initBucket,
uploadFile,
getFileUrl,
getPresignedUrl,
downloadFile,
getFileStream,
deleteFile,
deleteFiles,
fileExists,
getFileInfo,
listFiles,
copyFile,
generateUniqueFileName,
getFileExtension,
validateFileType,
validateFileSize,
formatFileSize,
};
+9
View File
@@ -0,0 +1,9 @@
import { PrismaClient } from '@prisma/client'
const globalForPrisma = globalThis as unknown as {
prisma: PrismaClient | undefined
}
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma