forked from hangshuo652/aurak
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
This commit is contained in:
@@ -0,0 +1,26 @@
|
||||
/**
|
||||
* LibreOffice Service Interface Definition
|
||||
*/
|
||||
|
||||
export interface LibreOfficeConvertResponse {
|
||||
pdf_path?: string;
|
||||
pdf_data?: string; // base64 encoded PDF data
|
||||
converted: boolean;
|
||||
original: string;
|
||||
file_size: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export interface LibreOfficeHealthResponse {
|
||||
status: string;
|
||||
service: string;
|
||||
version: string;
|
||||
uptime: number;
|
||||
}
|
||||
|
||||
export interface LibreOfficeVersionResponse {
|
||||
service: string;
|
||||
version: string;
|
||||
framework: string;
|
||||
libreoffice: string;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { Module } from '@nestjs/common';
|
||||
import { LibreOfficeService } from './libreoffice.service';
|
||||
|
||||
@Module({
|
||||
providers: [LibreOfficeService],
|
||||
exports: [LibreOfficeService],
|
||||
})
|
||||
export class LibreOfficeModule {}
|
||||
@@ -0,0 +1,228 @@
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user