Files
aurak/server/src/libreoffice/libreoffice.service.ts
T
Developer 0a9588abb7 feat: implement QuestionBank CRUD with pagination and template query
- 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
2026-04-23 17:19:11 +08:00

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;
}
}
}