import 'server-only' import { Client } from 'minio'; // 初始化 MinIO 客户端 export const minioClient = new Client({ endPoint: process.env.MINIO_ENDPOINT || 'localhost', port: parseInt(process.env.MINIO_API_PORT || '9000'), useSSL: process.env.MINIO_USE_SSL === 'true', accessKey: process.env.MINIO_ROOT_USER || '', secretKey: process.env.MINIO_ROOT_PASSWORD || '', }); export const BUCKET_NAME = process.env.MINIO_BUCKET || 'app-files'; /** * 获取客户端访问的基础 URL * 优先使用 MINIO_SERVER_URL(公网地址),否则使用内部地址 */ function getClientBaseUrl(): string { if (process.env.MINIO_SERVER_URL) { return process.env.MINIO_SERVER_URL; } const protocol = process.env.MINIO_USE_SSL === 'true' ? 'https' : 'http'; const endpoint = process.env.MINIO_ENDPOINT || 'localhost'; const port = process.env.MINIO_API_PORT || '9000'; return `${protocol}://${endpoint}:${port}`; } /** * 替换预签名 URL 中的内部地址为客户端可访问的地址 */ function replaceUrlBase(originalUrl: string): string { const clientBase = getClientBaseUrl(); const url = new URL(originalUrl); const clientUrl = new URL(clientBase); url.protocol = clientUrl.protocol; url.hostname = clientUrl.hostname; url.port = clientUrl.port; // 空字符串表示使用协议默认端口 return url.toString(); } // 桶初始化标志 let bucketInitialized = false; /** * 检查并初始化存储桶 * 如果桶不存在则创建,并设置为公开读取策略(可根据需求调整) */ export async function ensureBucketExists(): Promise { if (bucketInitialized) { return; } try { const exists = await minioClient.bucketExists(BUCKET_NAME); if (!exists) { await minioClient.makeBucket(BUCKET_NAME, 'cn-beijing-1'); console.log(`MinIO bucket '${BUCKET_NAME}' created successfully`); // 设置桶策略:默认私有,但 /public 路径下的文件公开可读 const policy = { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${BUCKET_NAME}/public/*`], }, ], }; await minioClient.setBucketPolicy(BUCKET_NAME, JSON.stringify(policy)); console.log(`MinIO bucket '${BUCKET_NAME}' policy set: private by default, public read for /public path`); } bucketInitialized = true; } catch (error) { console.error('Failed to ensure bucket exists:', error); throw error; } } /** * 预签名 POST 策略配置 */ export interface PresignedPostPolicyOptions { /** 对象名称前缀(不含文件名) public开头是可以公开GetObject */ prefix: string; /** 文件名(可选,如果指定则只允许上传该文件名) */ fileName?: string; /** 过期时间(秒),默认 1 小时 */ expirySeconds?: number; /** 最大文件大小(字节),默认 100MB */ maxSize?: number; /** 允许的文件类型(MIME类型数组),默认允许所有类型 */ allowedContentType?: string; /** 是否允许上传原始文件名元信息,默认 true */ allowOriginalFilename?: boolean; } /** * 预签名 POST 策略结果 */ export interface PresignedPostPolicyResult { /** POST 请求的 URL */ postURL: string; /** POST 请求需要的表单字段 */ formData: Record; /** 对象名称(完整路径) */ objectName: string; } /** * 生成预签名的 POST 策略(用于客户端直传) * 支持单文件上传(指定fileName)或批量上传(不指定fileName,只校验前缀) * 如果客户端无法上传,通过 mc admin trace --verbose --all 排查minio存储桶那边的详细日志 */ export async function generatePresignedPostPolicy( options: PresignedPostPolicyOptions ): Promise { await ensureBucketExists(); const { prefix, fileName, expirySeconds = 3600, maxSize = 100 * 1024 * 1024, // 默认 100MB allowedContentType, allowOriginalFilename = true } = options; try { const policy = minioClient.newPostPolicy(); // 设置过期时间 const expiryDate = new Date(); expiryDate.setSeconds(expiryDate.getSeconds() + expirySeconds); policy.setExpires(expiryDate); // 设置存储桶 policy.setBucket(BUCKET_NAME); // 构建完整的对象名称 const objectName = fileName ? `${prefix}/${fileName}` : prefix; // 如果指定了文件名,则精确匹配;否则只匹配前缀 if (fileName) { policy.setKey(objectName); } else { policy.setKeyStartsWith(prefix); } // 设置文件大小限制 policy.setContentLengthRange(1, maxSize); // 设置允许的内容类型(支持精确匹配和通配符) if (allowedContentType) { // 判断是否为通配符模式(如 'image/*') if (allowedContentType.endsWith('/*')) { // 将通配符模式转换为前缀(如 'image/*' -> 'image/') const prefix = allowedContentType.slice(0, -1); policy.setContentTypeStartsWith(prefix); } else { // 精确匹配 policy.setContentType(allowedContentType); } } if (allowOriginalFilename) { // 允许客户端设置 x-amz-meta-original-filename 元信息 policy.policy.conditions.push(['starts-with', '$x-amz-meta-original-filename', '']) } // 生成预签名 POST 数据 const presignedData = await minioClient.presignedPostPolicy(policy); return { postURL: replaceUrlBase(presignedData.postURL), formData: presignedData.formData, objectName, }; } catch (error) { console.error('Failed to generate presigned POST policy:', error); throw error; } } /** * 预签名 GET 对象配置 */ export interface PresignedGetObjectOptions { /** 对象名称(文件路径) */ objectName: string; /** 过期时间(秒),默认 1 小时 */ expirySeconds?: number; /** 自定义响应头 */ responseHeaders?: { /** 下载时的文件名,默认使用对象的 x-amz-meta-original-filename 元信息 */ 'response-content-disposition'?: string; /** 内容类型 */ 'response-content-type'?: string; }; } /** * 预签名 GET 对象结果 */ export interface PresignedGetObjectResult { /** 预签名的 GET URL */ url: string; /** 过期时间(秒) */ expiresIn: number; } /** * 生成预签名的 GET URL(用于下载对象) * 文件名默认使用对象的 x-amz-meta-original-filename 元信息 */ export async function generatePresignedGetObject( options: PresignedGetObjectOptions ): Promise { await ensureBucketExists(); const { objectName, expirySeconds = 3600, responseHeaders = {}, } = options; try { // 如果没有指定响应头,尝试从对象元数据中获取 if (!responseHeaders['response-content-disposition'] || !responseHeaders['response-content-type']) { try { const metadata = await minioClient.statObject(BUCKET_NAME, objectName); // 设置 Content-Disposition(文件名) if (!responseHeaders['response-content-disposition']) { const originalFilename = metadata.metaData?.['original-filename']; if (originalFilename) { // 使用 attachment 强制下载,并设置文件名 responseHeaders['response-content-disposition'] = `attachment; filename*=UTF-8''${originalFilename}`; } } // 设置 Content-Type if (!responseHeaders['response-content-type'] && metadata.metaData?.['content-type']) { responseHeaders['response-content-type'] = metadata.metaData['content-type']; } } catch (error) { // 如果获取元数据失败,继续使用默认行为 console.warn('Failed to get object metadata:', error); } } // 生成预签名 URL const url = await minioClient.presignedGetObject( BUCKET_NAME, objectName, expirySeconds, responseHeaders ); return { url: replaceUrlBase(url), expiresIn: expirySeconds, }; } catch (error) { console.error('Failed to generate presigned GET URL:', error); throw error; } } /** * 删除对象 * @param objectName 对象名称(文件路径) */ export async function deleteObject(objectName: string): Promise { await ensureBucketExists(); try { await minioClient.removeObject(BUCKET_NAME, objectName); } catch (error) { console.error('Failed to delete object:', error); throw error; } } /** * 批量删除对象 * @param objectNames 对象名称数组 */ export async function deleteBatchObjects(objectNames: string[]): Promise { await ensureBucketExists(); try { await minioClient.removeObjects(BUCKET_NAME, objectNames); } catch (error) { console.error('Failed to delete batch objects:', error); throw error; } } /** * 检查对象是否存在 * @param objectName 对象名称(文件路径) * @returns 是否存在 */ export async function objectExists(objectName: string): Promise { await ensureBucketExists(); try { await minioClient.statObject(BUCKET_NAME, objectName); return true; } catch (error) { return false; } } /** * 批量检查对象是否存在 * @param objectNames 对象名称数组 * @returns 对象存在状态的映射表 { objectName: boolean } */ export async function batchObjectExists( objectNames: string[] ): Promise> { await ensureBucketExists(); const results: Record = {}; // 并发检查所有对象 await Promise.all( objectNames.map(async (objectName) => { try { await minioClient.statObject(BUCKET_NAME, objectName); results[objectName] = true; } catch (error) { results[objectName] = false; } }) ); return results; } /** * 获取对象元数据 * @param objectName 对象名称(文件路径) * @returns 对象元数据 */ export async function getObjectMetadata(objectName: string) { await ensureBucketExists(); try { const stat = await minioClient.statObject(BUCKET_NAME, objectName); return stat; } catch (error) { console.error('Failed to get object metadata:', error); throw error; } } /** * 生成公开访问的 URL(需要桶策略支持公开读取) * @param objectName 对象名称(文件路径) * @returns 公开访问 URL */ export function getPublicUrl(objectName: string): string { return `${getClientBaseUrl()}/${BUCKET_NAME}/${objectName}`; }