import { Injectable, Logger, OnModuleInit } from '@nestjs/common'; import { ConfigService } from '@nestjs/config'; import * as Minio from 'minio'; import * as crypto from 'crypto'; /** * MinIO 存储服务 * * 负责上传合同签名图片和已签署的 PDF 文件到 MinIO 对象存储 */ @Injectable() export class MinioStorageService implements OnModuleInit { private readonly logger = new Logger(MinioStorageService.name); private minioClient: Minio.Client; private readonly bucketName: string; private readonly publicUrl: string; constructor(private readonly configService: ConfigService) { const endpoint = this.configService.get('MINIO_ENDPOINT', 'localhost'); const port = parseInt(this.configService.get('MINIO_PORT', '9000'), 10); const useSSL = this.configService.get('MINIO_USE_SSL', 'false') === 'true'; const accessKey = this.configService.get('MINIO_ACCESS_KEY', 'admin'); const secretKey = this.configService.get('MINIO_SECRET_KEY', 'minio_secret_password'); this.bucketName = this.configService.get('MINIO_BUCKET_CONTRACTS', 'contracts'); this.publicUrl = this.configService.get('MINIO_PUBLIC_URL', `http://${endpoint}:${port}`); this.minioClient = new Minio.Client({ endPoint: endpoint, port: port, useSSL: useSSL, accessKey: accessKey, secretKey: secretKey, }); this.logger.log(`MinIO client configured: ${endpoint}:${port}, bucket: ${this.bucketName}`); } async onModuleInit() { await this.ensureBucketExists(); } /** * 确保存储桶存在 */ private async ensureBucketExists(): Promise { try { const exists = await this.minioClient.bucketExists(this.bucketName); if (!exists) { await this.minioClient.makeBucket(this.bucketName, 'cn-east-1'); this.logger.log(`Created bucket: ${this.bucketName}`); // 设置桶策略为公开读取 const policy = { Version: '2012-10-17', Statement: [ { Effect: 'Allow', Principal: { AWS: ['*'] }, Action: ['s3:GetObject'], Resource: [`arn:aws:s3:::${this.bucketName}/*`], }, ], }; await this.minioClient.setBucketPolicy(this.bucketName, JSON.stringify(policy)); this.logger.log(`Set public read policy for bucket: ${this.bucketName}`); } else { this.logger.log(`Bucket exists: ${this.bucketName}`); } } catch (error) { this.logger.error(`Failed to ensure bucket exists: ${error.message}`); // 不抛出错误,允许服务继续启动(可能 MinIO 暂时不可用) } } /** * 上传签名图片 * @param orderNo 订单号 * @param signatureBuffer 签名图片 Buffer (PNG) * @returns 签名图片的公开 URL */ async uploadSignature(orderNo: string, signatureBuffer: Buffer): Promise { const timestamp = Date.now(); const hash = crypto.createHash('md5').update(signatureBuffer).digest('hex').slice(0, 8); const objectName = `signatures/${orderNo}/${timestamp}-${hash}.png`; try { await this.minioClient.putObject( this.bucketName, objectName, signatureBuffer, signatureBuffer.length, { 'Content-Type': 'image/png', 'x-amz-acl': 'public-read', }, ); const url = `${this.publicUrl}/${this.bucketName}/${objectName}`; this.logger.log(`Uploaded signature for order ${orderNo}: ${url}`); return url; } catch (error) { this.logger.error(`Failed to upload signature for order ${orderNo}: ${error.message}`); throw new Error(`签名图片上传失败: ${error.message}`); } } /** * 上传已签署的合同 PDF * @param orderNo 订单号 * @param pdfBuffer PDF 文件 Buffer * @returns PDF 文件的公开 URL */ async uploadSignedPdf(orderNo: string, pdfBuffer: Buffer): Promise { const timestamp = Date.now(); const objectName = `contracts/${orderNo}/signed-contract-${timestamp}.pdf`; try { await this.minioClient.putObject( this.bucketName, objectName, pdfBuffer, pdfBuffer.length, { 'Content-Type': 'application/pdf', 'x-amz-acl': 'public-read', 'Content-Disposition': `inline; filename="contract-${orderNo}.pdf"`, }, ); const url = `${this.publicUrl}/${this.bucketName}/${objectName}`; this.logger.log(`Uploaded signed PDF for order ${orderNo}: ${url}`); return url; } catch (error) { this.logger.error(`Failed to upload signed PDF for order ${orderNo}: ${error.message}`); throw new Error(`已签署合同上传失败: ${error.message}`); } } /** * 下载已签署的合同 PDF * @param signedPdfUrl PDF 文件的公开 URL * @returns PDF 文件 Buffer */ async downloadSignedPdf(signedPdfUrl: string): Promise { try { // 从 URL 中提取对象名称 const objectName = this.extractObjectName(signedPdfUrl); const dataStream = await this.minioClient.getObject(this.bucketName, objectName); // 将流转换为 Buffer const chunks: Buffer[] = []; for await (const chunk of dataStream) { chunks.push(Buffer.from(chunk)); } const buffer = Buffer.concat(chunks); this.logger.log(`Downloaded signed PDF: ${objectName}, size: ${buffer.length} bytes`); return buffer; } catch (error) { this.logger.error(`Failed to download signed PDF from ${signedPdfUrl}: ${error.message}`); throw new Error(`已签署合同下载失败: ${error.message}`); } } /** * 从 URL 中提取对象名称 */ private extractObjectName(objectUrl: string): string { // URL 格式: https://minio.xxx.com/contracts/contracts/orderNo/signed-contract-xxx.pdf // 需要提取: contracts/orderNo/signed-contract-xxx.pdf const url = new URL(objectUrl); const pathParts = url.pathname.split('/').filter(Boolean); // 第一个是桶名,后面的是对象路径 return pathParts.slice(1).join('/'); } /** * 删除文件 * @param objectUrl 文件的公开 URL */ async deleteObject(objectUrl: string): Promise { try { // 从 URL 中提取对象名称 const urlParts = objectUrl.replace(this.publicUrl, '').split('/'); const objectName = urlParts.slice(2).join('/'); // 去掉空字符串和桶名 await this.minioClient.removeObject(this.bucketName, objectName); this.logger.log(`Deleted object: ${objectName}`); } catch (error) { this.logger.error(`Failed to delete object: ${error.message}`); // 删除失败不抛出错误 } } }