197 lines
6.7 KiB
TypeScript
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}`);
|
|
// 删除失败不抛出错误
|
|
}
|
|
}
|
|
}
|