rwadurian/backend/services/admin-service/src/infrastructure/jobs/contract-batch-download.job.ts

344 lines
10 KiB
TypeScript

/**
* 合同批量下载任务处理器
* [2026-02-05] 新增:定时处理批量下载任务
* 回滚方式:删除此文件并从 app.module.ts 中移除引用
*/
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
import { Cron } from '@nestjs/schedule';
import { ConfigService } from '@nestjs/config';
import * as fs from 'fs/promises';
import * as path from 'path';
import * as archiver from 'archiver';
import { createWriteStream, existsSync, mkdirSync } from 'fs';
import { PrismaService } from '../persistence/prisma/prisma.service';
import { ContractService, ContractDto } from '../../application/services/contract.service';
/**
* 筛选条件类型
*/
interface BatchDownloadFilters {
signedAfter?: string;
signedBefore?: string;
provinceCode?: string;
cityCode?: string;
}
/**
* 合同批量下载任务处理 Job
* 每分钟检查是否有待处理的批量下载任务
*/
@Injectable()
export class ContractBatchDownloadJob implements OnModuleInit {
private readonly logger = new Logger(ContractBatchDownloadJob.name);
private isRunning = false;
private readonly downloadDir: string;
private readonly baseUrl: string;
constructor(
private readonly prisma: PrismaService,
private readonly contractService: ContractService,
private readonly configService: ConfigService,
) {
this.downloadDir = this.configService.get<string>('UPLOAD_DIR') || './uploads';
this.baseUrl = this.configService.get<string>('BASE_URL') || 'http://localhost:3005';
}
onModuleInit() {
this.logger.log('ContractBatchDownloadJob initialized');
// 确保下载目录存在
const contractsDir = path.join(this.downloadDir, 'contracts');
if (!existsSync(contractsDir)) {
mkdirSync(contractsDir, { recursive: true });
this.logger.log(`Created contracts download directory: ${contractsDir}`);
}
}
/**
* 每分钟检查并处理待处理的批量下载任务
*/
@Cron('0 * * * * *') // 每分钟的第0秒
async processPendingTasks(): Promise<void> {
if (this.isRunning) {
this.logger.debug('Batch download job is already running, skipping...');
return;
}
this.isRunning = true;
try {
// 查找待处理的任务
const pendingTask = await this.prisma.contractBatchDownloadTask.findFirst({
where: { status: 'PENDING' },
orderBy: { createdAt: 'asc' },
});
if (!pendingTask) {
return;
}
this.logger.log(`开始处理批量下载任务: ${pendingTask.taskNo}`);
// 更新状态为处理中
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'PROCESSING',
startedAt: new Date(),
},
});
try {
await this.processTask(pendingTask.id, pendingTask.taskNo, pendingTask.filters as BatchDownloadFilters);
} catch (error) {
this.logger.error(`任务处理失败: ${pendingTask.taskNo}`, error);
await this.prisma.contractBatchDownloadTask.update({
where: { id: pendingTask.id },
data: {
status: 'FAILED',
errors: { message: error.message, stack: error.stack },
completedAt: new Date(),
},
});
}
} catch (error) {
this.logger.error('批量下载任务检查失败', error);
} finally {
this.isRunning = false;
}
}
/**
* 处理单个批量下载任务
*/
private async processTask(
taskId: bigint,
taskNo: string,
filters: BatchDownloadFilters | null,
): Promise<void> {
const errors: Array<{ orderNo: string; error: string }> = [];
let downloadedCount = 0;
let failedCount = 0;
// 1. 获取符合条件的合同列表(只获取已签署的)
this.logger.log(`获取合同列表, 筛选条件: ${JSON.stringify(filters)}`);
const contractsResult = await this.contractService.getContracts({
signedAfter: filters?.signedAfter,
signedBefore: filters?.signedBefore,
provinceCode: filters?.provinceCode,
cityCode: filters?.cityCode,
status: 'SIGNED',
pageSize: 10000, // 最大获取1万份
orderBy: 'signedAt',
orderDir: 'asc',
});
const contracts = contractsResult.items;
const totalContracts = contracts.length;
this.logger.log(`共找到 ${totalContracts} 份已签署合同`);
if (totalContracts === 0) {
// 没有合同需要下载
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
totalContracts: 0,
downloadedCount: 0,
failedCount: 0,
progress: 100,
completedAt: new Date(),
},
});
return;
}
// 更新总数
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: { totalContracts },
});
// 2. 创建临时目录
const tempDir = path.join(this.downloadDir, 'temp', taskNo);
if (!existsSync(tempDir)) {
mkdirSync(tempDir, { recursive: true });
}
// 3. 逐个下载合同 PDF
for (let i = 0; i < contracts.length; i++) {
const contract = contracts[i];
try {
// 下载 PDF
const pdfBuffer = await this.contractService.downloadContractPdf(contract.orderNo);
// 生成文件路径(按省市分组)
const safeProvince = this.sanitizeFileName(contract.provinceName || '未知省份');
const safeCity = this.sanitizeFileName(contract.cityName || '未知城市');
const subDir = path.join(tempDir, safeProvince, safeCity);
if (!existsSync(subDir)) {
mkdirSync(subDir, { recursive: true });
}
// 生成文件名
const safeRealName = this.sanitizeFileName(contract.userRealName || '未知');
const fileName = `${contract.contractNo}_${safeRealName}_${contract.treeCount}棵.pdf`;
const filePath = path.join(subDir, fileName);
// 保存文件
await fs.writeFile(filePath, pdfBuffer);
downloadedCount++;
this.logger.debug(`下载成功: ${contract.orderNo} -> ${fileName}`);
} catch (error) {
failedCount++;
errors.push({ orderNo: contract.orderNo, error: error.message });
this.logger.warn(`下载失败: ${contract.orderNo} - ${error.message}`);
}
// 更新进度
const progress = Math.floor(((i + 1) / totalContracts) * 100);
if (progress % 10 === 0 || i === totalContracts - 1) {
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
downloadedCount,
failedCount,
progress,
lastProcessedOrderNo: contract.orderNo,
errors: errors.length > 0 ? errors : undefined,
},
});
this.logger.log(`进度: ${progress}% (${downloadedCount}/${totalContracts})`);
}
}
// 4. 打包成 ZIP
this.logger.log('开始打包 ZIP...');
const zipFileName = this.generateZipFileName(filters, downloadedCount);
const zipDir = path.join(this.downloadDir, 'contracts');
const zipPath = path.join(zipDir, zipFileName);
await this.createZipArchive(tempDir, zipPath);
// 获取 ZIP 文件大小
const zipStats = await fs.stat(zipPath);
const resultFileUrl = `${this.baseUrl}/uploads/contracts/${zipFileName}`;
this.logger.log(`ZIP 打包完成: ${zipFileName}, 大小: ${zipStats.size} bytes`);
// 5. 清理临时文件
await this.cleanupTempDir(tempDir);
// 6. 更新任务状态为完成
await this.prisma.contractBatchDownloadTask.update({
where: { id: taskId },
data: {
status: 'COMPLETED',
downloadedCount,
failedCount,
progress: 100,
resultFileUrl,
resultFileSize: BigInt(zipStats.size),
errors: errors.length > 0 ? errors : undefined,
completedAt: new Date(),
expiresAt: new Date(Date.now() + 7 * 24 * 60 * 60 * 1000), // 7天后过期
},
});
this.logger.log(`任务完成: ${taskNo}, 成功: ${downloadedCount}, 失败: ${failedCount}`);
}
/**
* 生成 ZIP 文件名
*/
private generateZipFileName(filters: BatchDownloadFilters | null, count: number): string {
const dateStr = new Date().toISOString().slice(0, 10).replace(/-/g, '');
let rangeStr = '';
if (filters?.signedAfter || filters?.signedBefore) {
const start = filters.signedAfter
? new Date(filters.signedAfter).toISOString().slice(0, 10).replace(/-/g, '')
: 'all';
const end = filters.signedBefore
? new Date(filters.signedBefore).toISOString().slice(0, 10).replace(/-/g, '')
: 'now';
rangeStr = `_${start}-${end}`;
}
return `contracts_${dateStr}${rangeStr}_${count}份.zip`;
}
/**
* 创建 ZIP 压缩包
*/
private async createZipArchive(sourceDir: string, zipPath: string): Promise<void> {
return new Promise((resolve, reject) => {
const output = createWriteStream(zipPath);
const archive = archiver('zip', {
zlib: { level: 6 }, // 压缩级别
});
output.on('close', () => {
this.logger.log(`ZIP 文件大小: ${archive.pointer()} bytes`);
resolve();
});
archive.on('error', (err: Error) => {
reject(err);
});
archive.pipe(output);
// 添加目录下所有文件
archive.directory(sourceDir, false);
archive.finalize();
});
}
/**
* 清理临时目录
*/
private async cleanupTempDir(tempDir: string): Promise<void> {
try {
await fs.rm(tempDir, { recursive: true, force: true });
this.logger.debug(`清理临时目录: ${tempDir}`);
} catch (error) {
this.logger.warn(`清理临时目录失败: ${tempDir}`, error);
}
}
/**
* 清理文件名中的非法字符
*/
private sanitizeFileName(name: string): string {
return name.replace(/[\/\\:*?"<>|]/g, '_').trim() || '未知';
}
/**
* 手动触发任务处理(供 API 调用)
*/
async triggerProcessing(): Promise<{ processed: boolean; taskNo?: string }> {
if (this.isRunning) {
return { processed: false };
}
await this.processPendingTasks();
return { processed: true };
}
/**
* 获取处理状态
*/
getProcessingStatus(): { isRunning: boolean } {
return { isRunning: this.isRunning };
}
}