minio.service.ts 7.6 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236
  1. import { Client } from 'minio';
  2. import { logger } from '@/server/utils/logger';
  3. import * as process from 'node:process';
  4. export class MinioService {
  5. private readonly client: Client;
  6. public readonly bucketName: string;
  7. constructor() {
  8. this.client = new Client({
  9. endPoint: process.env.MINIO_HOST || 'localhost',
  10. port: parseInt(process.env.MINIO_PORT || '443'),
  11. useSSL: process.env.MINIO_USE_SSL !== 'false',
  12. accessKey: process.env.MINIO_ACCESS_KEY || 'minioadmin',
  13. secretKey: process.env.MINIO_SECRET_KEY || 'minioadmin'
  14. });
  15. this.bucketName = process.env.MINIO_BUCKET_NAME || 'd8dai';
  16. }
  17. // 设置桶策略为"公读私写"
  18. async setPublicReadPolicy(bucketName: string = this.bucketName) {
  19. const policy = {
  20. "Version": "2012-10-17",
  21. "Statement": [
  22. {
  23. "Effect": "Allow",
  24. "Principal": {"AWS": "*"},
  25. "Action": ["s3:GetObject"],
  26. "Resource": [`arn:aws:s3:::${bucketName}/*`]
  27. },
  28. {
  29. "Effect": "Allow",
  30. "Principal": {"AWS": "*"},
  31. "Action": ["s3:ListBucket"],
  32. "Resource": [`arn:aws:s3:::${bucketName}`]
  33. }
  34. ]
  35. };
  36. try {
  37. await this.client.setBucketPolicy(bucketName, JSON.stringify(policy));
  38. logger.db(`Bucket policy set to public read for: ${bucketName}`);
  39. } catch (error) {
  40. logger.error(`Failed to set bucket policy for ${bucketName}:`, error);
  41. throw error;
  42. }
  43. }
  44. // 确保存储桶存在
  45. async ensureBucketExists(bucketName: string = this.bucketName) {
  46. try {
  47. const exists = await this.client.bucketExists(bucketName);
  48. if (!exists) {
  49. await this.client.makeBucket(bucketName);
  50. await this.setPublicReadPolicy(bucketName);
  51. logger.db(`Created new bucket: ${bucketName}`);
  52. }
  53. return true;
  54. } catch (error) {
  55. logger.error(`Failed to ensure bucket exists: ${bucketName}`, error);
  56. throw error;
  57. }
  58. }
  59. // 生成上传策略
  60. async generateUploadPolicy(fileKey: string) {
  61. await this.ensureBucketExists();
  62. const expiresAt = new Date(Date.now() + 3600 * 1000);
  63. const policy = this.client.newPostPolicy();
  64. policy.setBucket(this.bucketName);
  65. policy.setKey(fileKey);
  66. policy.setExpires(expiresAt);
  67. const { postURL, formData } = await this.client.presignedPostPolicy(policy);
  68. return {
  69. 'x-amz-algorithm': formData['x-amz-algorithm'],
  70. 'x-amz-credential': formData['x-amz-credential'],
  71. 'x-amz-date': formData['x-amz-date'],
  72. 'x-amz-security-token': formData['x-amz-security-token'] || undefined,
  73. policy: formData['policy'],
  74. 'x-amz-signature': formData['x-amz-signature'],
  75. host: postURL,
  76. key: fileKey,
  77. bucket: this.bucketName,
  78. };
  79. }
  80. // 生成文件访问URL
  81. getFileUrl(bucketName: string, fileKey: string) {
  82. const protocol = process.env.MINIO_USE_SSL !== 'false' ? 'https' : 'http';
  83. const port = process.env.MINIO_PORT ? `:${process.env.MINIO_PORT}` : '';
  84. return `${protocol}://${process.env.MINIO_HOST}${port}/${bucketName}/${fileKey}`;
  85. }
  86. // 生成预签名文件访问URL(用于私有bucket)
  87. async getPresignedFileUrl(bucketName: string, fileKey: string, expiresInSeconds = 3600) {
  88. try {
  89. const url = await this.client.presignedGetObject(bucketName, fileKey, expiresInSeconds);
  90. logger.db(`Generated presigned URL for ${bucketName}/${fileKey}, expires in ${expiresInSeconds}s`);
  91. return url;
  92. } catch (error) {
  93. logger.error(`Failed to generate presigned URL for ${bucketName}/${fileKey}:`, error);
  94. throw error;
  95. }
  96. }
  97. // 生成预签名文件下载URL(带Content-Disposition头)
  98. async getPresignedFileDownloadUrl(bucketName: string, fileKey: string, filename: string, expiresInSeconds = 3600) {
  99. try {
  100. const url = await this.client.presignedGetObject(
  101. bucketName,
  102. fileKey,
  103. expiresInSeconds,
  104. {
  105. 'response-content-disposition': `attachment; filename="${encodeURIComponent(filename)}"`,
  106. 'response-content-type': 'application/octet-stream'
  107. }
  108. );
  109. logger.db(`Generated presigned download URL for ${bucketName}/${fileKey}, filename: ${filename}`);
  110. return url;
  111. } catch (error) {
  112. logger.error(`Failed to generate presigned download URL for ${bucketName}/${fileKey}:`, error);
  113. throw error;
  114. }
  115. }
  116. // 创建分段上传会话
  117. async createMultipartUpload(bucketName: string, objectName: string) {
  118. try {
  119. const uploadId = await this.client.initiateNewMultipartUpload(bucketName, objectName, {});
  120. logger.db(`Created multipart upload for ${objectName} with ID: ${uploadId}`);
  121. return uploadId;
  122. } catch (error) {
  123. logger.error(`Failed to create multipart upload for ${objectName}:`, error);
  124. throw error;
  125. }
  126. }
  127. // 生成分段上传预签名URL
  128. async generateMultipartUploadUrls(
  129. bucketName: string,
  130. objectName: string,
  131. uploadId: string,
  132. partCount: number,
  133. expiresInSeconds = 3600
  134. ) {
  135. try {
  136. const partUrls = [];
  137. for (let partNumber = 1; partNumber <= partCount; partNumber++) {
  138. const url = await this.client.presignedUrl(
  139. 'put',
  140. bucketName,
  141. objectName,
  142. expiresInSeconds,
  143. {
  144. uploadId,
  145. partNumber: partNumber.toString()
  146. }
  147. );
  148. partUrls.push(url);
  149. }
  150. return partUrls;
  151. } catch (error) {
  152. logger.error(`Failed to generate multipart upload URLs for ${objectName}:`, error);
  153. throw error;
  154. }
  155. }
  156. // 完成分段上传
  157. async completeMultipartUpload(
  158. bucketName: string,
  159. objectName: string,
  160. uploadId: string,
  161. parts: { ETag: string; PartNumber: number }[]
  162. ): Promise<{ size: number }> {
  163. try {
  164. await this.client.completeMultipartUpload(
  165. bucketName,
  166. objectName,
  167. uploadId,
  168. parts.map(p => ({ part: p.PartNumber, etag: p.ETag }))
  169. );
  170. logger.db(`Completed multipart upload for ${objectName} with ID: ${uploadId}`);
  171. // 获取对象信息以获取文件大小
  172. const stat = await this.client.statObject(bucketName, objectName);
  173. return { size: stat.size };
  174. } catch (error) {
  175. logger.error(`Failed to complete multipart upload for ${objectName}:`, error);
  176. throw error;
  177. }
  178. }
  179. // 上传文件
  180. async createObject(bucketName: string, objectName: string, fileContent: Buffer, contentType: string = 'application/octet-stream') {
  181. try {
  182. await this.ensureBucketExists(bucketName);
  183. await this.client.putObject(bucketName, objectName, fileContent, fileContent.length, {
  184. 'Content-Type': contentType
  185. });
  186. logger.db(`Created object: ${bucketName}/${objectName}`);
  187. return this.getFileUrl(bucketName, objectName);
  188. } catch (error) {
  189. logger.error(`Failed to create object ${bucketName}/${objectName}:`, error);
  190. throw error;
  191. }
  192. }
  193. // 检查文件是否存在
  194. async objectExists(bucketName: string, objectName: string): Promise<boolean> {
  195. try {
  196. await this.client.statObject(bucketName, objectName);
  197. return true;
  198. } catch (error) {
  199. if ((error as Error).message.includes('not found')) {
  200. return false;
  201. }
  202. logger.error(`Error checking existence of object ${bucketName}/${objectName}:`, error);
  203. throw error;
  204. }
  205. }
  206. // 删除文件
  207. async deleteObject(bucketName: string, objectName: string) {
  208. try {
  209. await this.client.removeObject(bucketName, objectName);
  210. logger.db(`Deleted object: ${bucketName}/${objectName}`);
  211. } catch (error) {
  212. logger.error(`Failed to delete object ${bucketName}/${objectName}:`, error);
  213. throw error;
  214. }
  215. }
  216. }