forked from hangshuo652/aurak
0a9588abb7
- Add pagination support to findAll (page, limit query params) - Add findByTemplateId method to service - Add GET /by-template/:templateId endpoint to controller - Service already includes CRUD for QuestionBank and QuestionBankItem
229 lines
6.7 KiB
TypeScript
229 lines
6.7 KiB
TypeScript
import { Injectable, Logger, OnModuleInit } from '@nestjs/common';
|
|
import { ConfigService } from '@nestjs/config';
|
|
import * as fs from 'fs/promises';
|
|
import * as path from 'path';
|
|
import axios from 'axios';
|
|
import FormData from 'form-data';
|
|
import {
|
|
LibreOfficeConvertResponse,
|
|
LibreOfficeHealthResponse,
|
|
} from './libreoffice.interface';
|
|
import { I18nService } from '../i18n/i18n.service';
|
|
|
|
@Injectable()
|
|
export class LibreOfficeService implements OnModuleInit {
|
|
private readonly logger = new Logger(LibreOfficeService.name);
|
|
private baseUrl: string;
|
|
|
|
constructor(
|
|
private configService: ConfigService,
|
|
private i18nService: I18nService,
|
|
) {}
|
|
|
|
onModuleInit() {
|
|
const libreofficeUrl = this.configService.get<string>('LIBREOFFICE_URL');
|
|
if (!libreofficeUrl) {
|
|
throw new Error(this.i18nService.getMessage('libreofficeUrlRequired'));
|
|
}
|
|
this.baseUrl = libreofficeUrl;
|
|
this.logger.log(
|
|
`LibreOffice service initialized with base URL: ${this.baseUrl}`,
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Check LibreOffice service health status
|
|
*/
|
|
async healthCheck(): Promise<boolean> {
|
|
try {
|
|
const response = await axios.get<LibreOfficeHealthResponse>(
|
|
`${this.baseUrl}/health`,
|
|
{ timeout: 5000 },
|
|
);
|
|
return response.data.status === 'healthy';
|
|
} catch (error) {
|
|
this.logger.error('LibreOffice health check failed', error.message);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Convert document to PDF
|
|
* @param filePath Path of file to convert
|
|
* @returns PDF file path
|
|
*/
|
|
async convertToPDF(filePath: string): Promise<string> {
|
|
const fileName = path.basename(filePath);
|
|
const ext = path.extname(fileName).toLowerCase();
|
|
|
|
// Return original path directly if PDF
|
|
if (ext === '.pdf') {
|
|
this.logger.log(`File is already PDF: ${filePath}`);
|
|
return filePath;
|
|
}
|
|
|
|
// Check if file exists
|
|
try {
|
|
await fs.access(filePath);
|
|
} catch {
|
|
throw new Error(`File does not exist: ${filePath}`);
|
|
}
|
|
|
|
// Generate output PDF path
|
|
const dir = path.dirname(filePath);
|
|
const baseName = path.basename(filePath, ext);
|
|
const targetPdfPath = path.join(dir, `${baseName}.pdf`);
|
|
|
|
// Return directly if PDF already exists
|
|
try {
|
|
await fs.access(targetPdfPath);
|
|
this.logger.log(`PDF already exists: ${targetPdfPath}`);
|
|
return targetPdfPath;
|
|
} catch {
|
|
// Need to convert as PDF does not exist
|
|
}
|
|
|
|
// Load file
|
|
const fileBuffer = await fs.readFile(filePath);
|
|
|
|
// Build FormData
|
|
const formData = new FormData();
|
|
formData.append('file', fileBuffer, fileName);
|
|
|
|
this.logger.log(`Converting ${fileName} to PDF...`);
|
|
|
|
// Conversion retry count
|
|
const maxRetries = 3;
|
|
let lastError: any;
|
|
|
|
for (let attempt = 1; attempt <= maxRetries; attempt++) {
|
|
try {
|
|
// Call LibreOffice service
|
|
const response = await axios.post(`${this.baseUrl}/convert`, formData, {
|
|
headers: formData.getHeaders(),
|
|
timeout: 300000, // 5 minute timeout
|
|
responseType: 'stream', // Receive file stream
|
|
maxRedirects: 5, // Max redirects
|
|
});
|
|
|
|
// Write stream to output file
|
|
const writer = (await import('fs')).createWriteStream(targetPdfPath);
|
|
response.data.pipe(writer);
|
|
|
|
return new Promise((resolve, reject) => {
|
|
writer.on('finish', () => {
|
|
this.logger.log(
|
|
`Conversion completed: ${fileName} -> ${targetPdfPath}`,
|
|
);
|
|
resolve(targetPdfPath);
|
|
});
|
|
writer.on('error', (err) => {
|
|
this.logger.error(`Error writing PDF file: ${err.message}`);
|
|
reject(err);
|
|
});
|
|
});
|
|
} catch (error) {
|
|
this.logger.error(
|
|
`Attempt ${attempt} failed for ${fileName}: ${error.message}`,
|
|
);
|
|
lastError = error;
|
|
|
|
// Wait and retry on socket hang up or connection error
|
|
if (
|
|
error.code === 'ECONNRESET' ||
|
|
error.code === 'ECONNREFUSED' ||
|
|
error.code === 'ETIMEDOUT' ||
|
|
error.message.includes('socket hang up')
|
|
) {
|
|
if (attempt < maxRetries) {
|
|
const delay = 2000 * attempt; // Increasing delay
|
|
this.logger.log(`Waiting ${delay}ms before retry...`);
|
|
await new Promise((resolve) => setTimeout(resolve, delay));
|
|
}
|
|
} else {
|
|
// Do not retry other errors
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
// Detailed error handling if all retries fail
|
|
if (lastError.response) {
|
|
try {
|
|
const stream = lastError.response.data;
|
|
const chunks: any[] = [];
|
|
for await (const chunk of stream) {
|
|
chunks.push(chunk);
|
|
}
|
|
const errorText = Buffer.concat(chunks).toString('utf8');
|
|
this.logger.error(`LibreOffice service error details: ${errorText}`);
|
|
|
|
let detail = errorText;
|
|
try {
|
|
const errorJson = JSON.parse(errorText);
|
|
detail = errorJson.detail || errorText;
|
|
} catch {
|
|
// ignore JSON parse error
|
|
}
|
|
|
|
if (lastError.response.status === 504) {
|
|
throw new Error('Conversion timed out. The file may be too large.');
|
|
}
|
|
throw new Error(`Conversion failed: ${detail}`);
|
|
} catch (streamError) {
|
|
this.logger.error('Error reading error stream:', streamError);
|
|
throw new Error(`Conversion failed: ${lastError.message}`);
|
|
}
|
|
}
|
|
|
|
this.logger.error(
|
|
`Conversion failed for ${fileName} after ${maxRetries} attempts:`,
|
|
lastError.message,
|
|
);
|
|
if (lastError.code === 'ECONNREFUSED') {
|
|
throw new Error(
|
|
'LibreOffice service is not running. Please check the service status.',
|
|
);
|
|
}
|
|
if (
|
|
lastError.code === 'ECONNRESET' ||
|
|
lastError.message.includes('socket hang up')
|
|
) {
|
|
throw new Error(
|
|
'Connection to LibreOffice service was reset. The service may be unstable.',
|
|
);
|
|
}
|
|
throw new Error(`Conversion failed: ${lastError.message}`);
|
|
}
|
|
|
|
/**
|
|
* Batch convert files
|
|
*/
|
|
async batchConvert(filePaths: string[]): Promise<string[]> {
|
|
const results: string[] = [];
|
|
for (const filePath of filePaths) {
|
|
try {
|
|
const pdfPath = await this.convertToPDF(filePath);
|
|
results.push(pdfPath);
|
|
} catch (error) {
|
|
this.logger.error(`Failed to convert ${filePath}: ${error.message}`);
|
|
throw error;
|
|
}
|
|
}
|
|
return results;
|
|
}
|
|
|
|
/**
|
|
* Get service version information
|
|
*/
|
|
async getVersion(): Promise<any> {
|
|
try {
|
|
const response = await axios.get(`${this.baseUrl}/version`);
|
|
return response.data;
|
|
} catch (error) {
|
|
this.logger.error('Failed to get version', error.message);
|
|
return null;
|
|
}
|
|
}
|
|
}
|