rwadurian/backend/services/planting-service/src/infrastructure/storage/minio-storage.service.ts

197 lines
6.7 KiB
TypeScript

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<string>('MINIO_ENDPOINT', 'localhost');
const port = parseInt(this.configService.get<string>('MINIO_PORT', '9000'), 10);
const useSSL = this.configService.get<string>('MINIO_USE_SSL', 'false') === 'true';
const accessKey = this.configService.get<string>('MINIO_ACCESS_KEY', 'admin');
const secretKey = this.configService.get<string>('MINIO_SECRET_KEY', 'minio_secret_password');
this.bucketName = this.configService.get<string>('MINIO_BUCKET_CONTRACTS', 'contracts');
this.publicUrl = this.configService.get<string>('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<void> {
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<string> {
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<string> {
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<Buffer> {
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<void> {
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}`);
// 删除失败不抛出错误
}
}
}