first commit

This commit is contained in:
2025-12-09 20:56:23 +08:00
commit 5a3aa22316
13 changed files with 2754 additions and 0 deletions

141
.gitignore vendored Normal file
View File

@@ -0,0 +1,141 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
lerna-debug.log*
# Diagnostic reports (https://nodejs.org/api/report.html)
report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
# Runtime data
pids
*.pid
*.seed
*.pid.lock
# Directory for instrumented libs generated by jscoverage/JSCover
lib-cov
# Coverage directory used by tools like istanbul
coverage
*.lcov
# nyc test coverage
.nyc_output
# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files)
.grunt
# Bower dependency directory (https://bower.io/)
bower_components
# node-waf configuration
.lock-wscript
# Compiled binary addons (https://nodejs.org/api/addons.html)
build/Release
# Dependency directories
node_modules/
jspm_packages/
# Snowpack dependency directory (https://snowpack.dev/)
web_modules/
# TypeScript cache
*.tsbuildinfo
# Optional npm cache directory
.npm
# Optional eslint cache
.eslintcache
# Optional stylelint cache
.stylelintcache
# Optional REPL history
.node_repl_history
# Output of 'npm pack'
*.tgz
# Yarn Integrity file
.yarn-integrity
# dotenv environment variable files
.env
.env.*
!.env.example
# parcel-bundler cache (https://parceljs.org/)
.cache
.parcel-cache
# Next.js build output
.next
out
# Nuxt.js build / generate output
.nuxt
dist
.output
# Gatsby files
.cache/
# Comment in the public line in if your project uses Gatsby and not Next.js
# https://nextjs.org/blog/next-9-1#public-directory-support
# public
# vuepress build output
.vuepress/dist
# vuepress v2.x temp and cache directory
.temp
.cache
# Sveltekit cache directory
.svelte-kit/
# vitepress build output
**/.vitepress/dist
# vitepress cache directory
**/.vitepress/cache
# Docusaurus cache and generated files
.docusaurus
# Serverless directories
.serverless/
# FuseBox cache
.fusebox/
# DynamoDB Local files
.dynamodb/
# Firebase cache directory
.firebase/
# TernJS port file
.tern-port
# Stores VSCode versions used for testing VSCode extensions
.vscode-test
# yarn v3
.pnp.*
.yarn/*
!.yarn/patches
!.yarn/plugins
!.yarn/releases
!.yarn/sdks
!.yarn/versions
# Vite files
vite.config.js.timestamp-*
vite.config.ts.timestamp-*
.vite/

150
README.md Normal file
View File

@@ -0,0 +1,150 @@
# STL to STEP Converter
A modular Node.js web application for converting STL files to STEP format with automatic file cleanup and Redis-based job tracking.
## Features
- ✅ Upload STL files via drag-and-drop or file picker
- ✅ Convert STL to STEP format
- ✅ Track conversion jobs with unique IDs
- ✅ Redis-based job management with TTL
- ✅ Automatic file cleanup scheduler
- ✅ Download converted STEP files
- ✅ Expiration tracking and display
- ✅ Modular architecture
## Project Structure
```
stl-to-step-converter/
├── src/
│ ├── config/
│ │ └── index.js # Configuration
│ ├── services/
│ │ ├── redis.service.js # Redis operations
│ │ ├── converter.service.js # STL to STEP conversion
│ │ ├── file.service.js # File management
│ │ └── cleanup.service.js # Scheduled cleanup
│ ├── routes/
│ │ └── conversion.routes.js # API endpoints
│ └── server.js # Main application
├── public/
│ ├── index.html # Frontend UI
│ └── app.js # Frontend logic
├── uploads/ # Uploaded STL files
├── converted/ # Converted STEP files
└── package.json
```
## Prerequisites
- Node.js 14+
- Redis server
- FreeCAD (with freecadcmd CLI tool)
## Installation
1. Install FreeCAD:
- **Ubuntu/Debian**: `sudo apt-get install freecad`
- **macOS**: `brew install freecad`
- **Windows**: Download from [FreeCAD website](https://www.freecadweb.org/)
2. Install Node.js dependencies:
```bash
npm install
```
3. Start Redis (if not running):
```bash
redis-server
```
4. Start the application:
```bash
npm start
```
For development with auto-reload:
```bash
npm run dev
```
4. Open browser to `http://localhost:3000`
## Configuration
Edit `src/config/index.js` to customize:
- **Port**: Server port (default: 3000)
- **Redis**: Connection settings
- **Upload**: Max file size and directory
- **Jobs**: TTL (time-to-live) and cleanup schedule
- **Cleanup Cron**: Default runs every 15 minutes
## API Endpoints
### POST `/api/convert`
Upload and convert STL file
- Body: `multipart/form-data` with `stlFile` field
- Returns: `{ success, jobId, message }`
### GET `/api/job/:jobId`
Get job status and expiration info
- Returns: Job details including `expiresIn` (seconds)
### GET `/api/download/:jobId`
Download converted STEP file
- Returns: File download
## Environment Variables
```bash
PORT=3000
REDIS_HOST=localhost
REDIS_PORT=6379
```
## How It Works
1. **Upload**: User uploads STL file via web interface
2. **Job Creation**: System creates job in Redis with TTL
3. **Conversion**: STL file is converted to STEP format
4. **Storage**: Both files stored temporarily
5. **Download**: User downloads converted file
6. **Cleanup**: Scheduled job removes expired files and Redis entries
## Cleanup Process
- Runs every 15 minutes (configurable)
- Removes Redis jobs past TTL
- Deletes associated files
- Cleans up orphaned files
## Conversion Process
The application uses FreeCAD's command-line interface (`freecadcmd`) to convert STL files to STEP format:
1. **Mesh Import**: STL file is imported using FreeCAD's Mesh module
2. **Shape Creation**: Mesh topology is converted to a Part shape with 0.01 tolerance
3. **Document Creation**: Temporary FreeCAD document is created for the conversion
4. **Export**: Shape is exported to STEP format using FreeCAD's Import module
5. **Cleanup**: Temporary document is closed to free memory
The conversion command:
```bash
freecadcmd -c "import Part; import Mesh; import FreeCAD; import Import; mesh = Mesh.read('input.stl'); shape = Part.Shape(); shape.makeShapeFromMesh(mesh.Topology, 0.01); doc = FreeCAD.newDocument('STLImport'); obj = doc.addObject('Part::Feature', 'MeshShape'); obj.Shape = shape; Import.export([obj], 'output.step'); FreeCAD.closeDocument('STLImport')"
```
**Note**: FreeCAD must be installed and the `freecadcmd` command must be available in your system PATH.
## Security Considerations
- File type validation (STL only)
- File size limits (50MB default)
- Automatic cleanup prevents disk filling
- Redis TTL prevents memory leaks
- Input sanitization on all endpoints
## License
MIT

1601
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

20
package.json Normal file
View File

@@ -0,0 +1,20 @@
{
"name": "stl-to-step-converter",
"version": "1.0.0",
"description": "Web application for converting STL files to STEP format",
"main": "src/server.js",
"scripts": {
"start": "node src/server.js",
"dev": "nodemon src/server.js"
},
"dependencies": {
"express": "^4.18.2",
"multer": "^1.4.5-lts.1",
"redis": "^4.6.0",
"node-cron": "^3.0.3",
"uuid": "^9.0.0"
},
"devDependencies": {
"nodemon": "^3.0.0"
}
}

144
public/app.js Normal file
View File

@@ -0,0 +1,144 @@
let selectedFile = null;
let currentJobId = null;
const uploadArea = document.getElementById('uploadArea');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const convertBtn = document.getElementById('convertBtn');
const downloadBtn = document.getElementById('downloadBtn');
const loader = document.getElementById('loader');
const status = document.getElementById('status');
// Upload area click
uploadArea.addEventListener('click', () => fileInput.click());
// File selection
fileInput.addEventListener('change', (e) => {
handleFile(e.target.files[0]);
});
// Drag and drop
uploadArea.addEventListener('dragover', (e) => {
e.preventDefault();
uploadArea.classList.add('dragover');
});
uploadArea.addEventListener('dragleave', () => {
uploadArea.classList.remove('dragover');
});
uploadArea.addEventListener('drop', (e) => {
e.preventDefault();
uploadArea.classList.remove('dragover');
handleFile(e.dataTransfer.files[0]);
});
function handleFile(file) {
if (!file) return;
if (!file.name.toLowerCase().endsWith('.stl')) {
showStatus('error', 'Please select a valid STL file');
return;
}
if (file.size > 50 * 1024 * 1024) {
showStatus('error', 'File size exceeds 50MB limit');
return;
}
selectedFile = file;
fileInfo.textContent = `Selected: ${file.name} (${formatFileSize(file.size)})`;
fileInfo.classList.add('show');
convertBtn.disabled = false;
downloadBtn.style.display = 'none';
status.style.display = 'none';
}
convertBtn.addEventListener('click', async () => {
if (!selectedFile) return;
const formData = new FormData();
formData.append('stlFile', selectedFile);
convertBtn.disabled = true;
loader.classList.add('show');
showStatus('processing', 'Converting your file...');
try {
const response = await fetch('/api/convert', {
method: 'POST',
body: formData,
});
const data = await response.json();
if (data.success) {
currentJobId = data.jobId;
showStatus('success', 'Conversion completed successfully!');
// Get job details to show expiration
await updateJobStatus();
downloadBtn.style.display = 'block';
downloadBtn.disabled = false;
} else {
showStatus('error', `Conversion failed: ${data.error || 'Unknown error'}`);
}
} catch (error) {
showStatus('error', `Error: ${error.message}`);
} finally {
loader.classList.remove('show');
convertBtn.disabled = false;
}
});
downloadBtn.addEventListener('click', () => {
if (currentJobId) {
window.location.href = `/api/download/${currentJobId}`;
}
});
async function updateJobStatus() {
if (!currentJobId) return;
try {
const response = await fetch(`/api/job/${currentJobId}`);
const job = await response.json();
if (response.ok) {
const expiresIn = formatDuration(job.expiresIn);
const expiresInfo = document.createElement('div');
expiresInfo.className = 'expires-info';
expiresInfo.textContent = `⏱️ File will expire in ${expiresIn}`;
// Append if not already present
if (!status.querySelector('.expires-info')) {
status.appendChild(expiresInfo);
}
}
} catch (error) {
console.error('Failed to fetch job status:', error);
}
}
function showStatus(type, message) {
status.className = `status ${type}`;
status.textContent = message;
status.style.display = 'block';
}
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
}
function formatDuration(seconds) {
if (seconds < 60) return `${seconds} seconds`;
const minutes = Math.floor(seconds / 60);
if (minutes < 60) return `${minutes} minutes`;
const hours = Math.floor(minutes / 60);
return `${hours} hours`;
}

192
public/index.html Normal file
View File

@@ -0,0 +1,192 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>STL to STEP Converter</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
justify-content: center;
align-items: center;
padding: 20px;
}
.container {
background: white;
border-radius: 16px;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
padding: 40px;
max-width: 600px;
width: 100%;
}
h1 {
color: #333;
margin-bottom: 10px;
font-size: 28px;
}
.subtitle {
color: #666;
margin-bottom: 30px;
font-size: 14px;
}
.upload-area {
border: 3px dashed #667eea;
border-radius: 12px;
padding: 40px;
text-align: center;
background: #f8f9ff;
cursor: pointer;
transition: all 0.3s ease;
margin-bottom: 20px;
}
.upload-area:hover {
border-color: #764ba2;
background: #f0f2ff;
}
.upload-area.dragover {
border-color: #764ba2;
background: #e8ebff;
transform: scale(1.02);
}
.upload-icon {
font-size: 48px;
margin-bottom: 10px;
}
input[type="file"] {
display: none;
}
button {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
padding: 14px 32px;
border-radius: 8px;
font-size: 16px;
font-weight: 600;
cursor: pointer;
width: 100%;
transition: all 0.3s ease;
margin-top: 10px;
}
button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(102, 126, 234, 0.4);
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.status {
margin-top: 20px;
padding: 16px;
border-radius: 8px;
display: none;
}
.status.success {
background: #d4edda;
border: 1px solid #c3e6cb;
color: #155724;
display: block;
}
.status.error {
background: #f8d7da;
border: 1px solid #f5c6cb;
color: #721c24;
display: block;
}
.status.processing {
background: #d1ecf1;
border: 1px solid #bee5eb;
color: #0c5460;
display: block;
}
.file-info {
margin-top: 10px;
padding: 12px;
background: #f8f9fa;
border-radius: 6px;
font-size: 14px;
color: #495057;
display: none;
}
.file-info.show {
display: block;
}
.loader {
border: 4px solid #f3f3f3;
border-top: 4px solid #667eea;
border-radius: 50%;
width: 40px;
height: 40px;
animation: spin 1s linear infinite;
margin: 20px auto;
display: none;
}
.loader.show {
display: block;
}
@keyframes spin {
0% { transform: rotate(0deg); }
100% { transform: rotate(360deg); }
}
.expires-info {
margin-top: 8px;
font-size: 13px;
color: #666;
}
</style>
</head>
<body>
<div class="container">
<h1>🔄 STL to STEP Converter</h1>
<p class="subtitle">Upload your STL file and convert it to STEP format</p>
<div class="upload-area" id="uploadArea">
<div class="upload-icon">📁</div>
<p><strong>Click to upload</strong> or drag and drop</p>
<p style="font-size: 12px; color: #999; margin-top: 5px;">STL files only (Max 50MB)</p>
<input type="file" id="fileInput" accept=".stl">
</div>
<div class="file-info" id="fileInfo"></div>
<button id="convertBtn" disabled>Convert to STEP</button>
<button id="downloadBtn" style="display: none;">Download STEP File</button>
<div class="loader" id="loader"></div>
<div class="status" id="status"></div>
</div>
<script src="app.js"></script>
</body>
</html>

18
src/config/index.js Normal file
View File

@@ -0,0 +1,18 @@
module.exports = {
port: process.env.PORT || 3000,
redis: {
host: process.env.REDIS_HOST || 'localhost',
port: process.env.REDIS_PORT || 6379,
},
upload: {
dir: './uploads',
maxSize: 50 * 1024 * 1024, // 50MB
},
output: {
dir: './converted',
},
jobs: {
ttl: 3600, // 1 hour in seconds
cleanupCron: '*/15 * * * *', // Every 15 minutes
},
};

View File

@@ -0,0 +1,139 @@
const express = require('express');
const multer = require('multer');
const { v4: uuidv4 } = require('uuid');
const path = require('path');
const config = require('../config');
const converterService = require('../services/converter.service');
const redisService = require('../services/redis.service');
const fileService = require('../services/file.service');
const router = express.Router();
const storage = multer.diskStorage({
destination: (req, file, cb) => {
cb(null, config.upload.dir);
},
filename: (req, file, cb) => {
const uniqueName = `${uuidv4()}${path.extname(file.originalname)}`;
cb(null, uniqueName);
},
});
const upload = multer({
storage,
limits: { fileSize: config.upload.maxSize },
fileFilter: (req, file, cb) => {
if (path.extname(file.originalname).toLowerCase() !== '.stl') {
return cb(new Error('Only STL files are allowed'));
}
cb(null, true);
},
});
// Upload and convert STL to STEP
router.post('/convert', upload.single('stlFile'), async (req, res) => {
try {
if (!req.file) {
return res.status(400).json({ error: 'No file uploaded' });
}
const jobId = uuidv4();
const inputPath = req.file.path;
const outputFilename = `${path.parse(req.file.filename).name}.step`;
const outputPath = fileService.getOutputPath(outputFilename);
// Create job in Redis
await redisService.createJob(jobId, {
jobId,
status: 'processing',
originalFilename: req.file.originalname,
inputPath,
outputPath,
outputFilename,
});
// Perform conversion
try {
await converterService.convertSTLtoSTEP(inputPath, outputPath);
await redisService.updateJob(jobId, {
status: 'completed',
completedAt: Date.now(),
});
res.json({
success: true,
jobId,
message: 'Conversion completed successfully',
});
} catch (conversionError) {
await redisService.updateJob(jobId, {
status: 'failed',
error: conversionError.message,
});
res.status(500).json({
success: false,
jobId,
error: 'Conversion failed',
details: conversionError.message,
});
}
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Get job status
router.get('/job/:jobId', async (req, res) => {
try {
const { jobId } = req.params;
const job = await redisService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found or expired' });
}
const expiresIn = Math.max(0, Math.floor(job.remainingTTL));
res.json({
jobId: job.jobId,
status: job.status,
originalFilename: job.originalFilename,
expiresIn,
expiresAt: new Date(job.expiresAt).toISOString(),
createdAt: new Date(job.createdAt).toISOString(),
});
} catch (error) {
res.status(500).json({ error: error.message });
}
});
// Download converted file
router.get('/download/:jobId', async (req, res) => {
try {
const { jobId } = req.params;
const job = await redisService.getJob(jobId);
if (!job) {
return res.status(404).json({ error: 'Job not found or expired' });
}
if (job.status !== 'completed') {
return res.status(400).json({
error: `Cannot download: job status is ${job.status}`
});
}
const exists = await fileService.fileExists(job.outputPath);
if (!exists) {
return res.status(404).json({ error: 'Converted file not found' });
}
res.download(job.outputPath, job.outputFilename);
} catch (error) {
res.status(500).json({ error: error.message });
}
});
module.exports = router;

76
src/server.js Normal file
View File

@@ -0,0 +1,76 @@
const express = require('express');
const path = require('path');
const config = require('./config');
const redisService = require('./services/redis.service');
const fileService = require('./services/file.service');
const cleanupService = require('./services/cleanup.service');
const conversionRoutes = require('./routes/conversion.routes');
const app = express();
// Middleware
app.use(express.json());
app.use(express.static(path.join(__dirname, '../public')));
// Routes
app.use('/api', conversionRoutes);
// Error handling middleware
app.use((err, req, res, next) => {
console.error('Error:', err);
if (err instanceof multer.MulterError) {
if (err.code === 'LIMIT_FILE_SIZE') {
return res.status(400).json({
error: 'File too large',
maxSize: `${config.upload.maxSize / (1024 * 1024)}MB`,
});
}
}
res.status(500).json({
error: err.message || 'Internal server error'
});
});
// Initialize and start server
async function start() {
try {
// Ensure directories exist
await fileService.ensureDirectories();
console.log('Directories initialized');
// Connect to Redis
await redisService.connect();
// Start cleanup scheduler
cleanupService.start();
// Start server
app.listen(config.port, () => {
console.log(`Server running on http://localhost:${config.port}`);
console.log(`Upload max size: ${config.upload.maxSize / (1024 * 1024)}MB`);
console.log(`File TTL: ${config.jobs.ttl}s`);
});
} catch (error) {
console.error('Failed to start server:', error);
process.exit(1);
}
}
// Graceful shutdown
process.on('SIGINT', async () => {
console.log('\nShutting down gracefully...');
cleanupService.stop();
await redisService.disconnect();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log('\nShutting down gracefully...');
cleanupService.stop();
await redisService.disconnect();
process.exit(0);
});
start();

View File

@@ -0,0 +1,81 @@
const cron = require('node-cron');
const config = require('../config');
const redisService = require('./redis.service');
const fileService = require('./file.service');
class CleanupService {
constructor() {
this.job = null;
}
start() {
console.log(`Starting cleanup scheduler: ${config.jobs.cleanupCron}`);
this.job = cron.schedule(config.jobs.cleanupCron, async () => {
console.log('Running cleanup job...');
await this.cleanup();
});
}
stop() {
if (this.job) {
this.job.stop();
console.log('Cleanup scheduler stopped');
}
}
async cleanup() {
try {
const stats = {
filesDeleted: 0,
jobsDeleted: 0,
errors: 0,
};
// Clean up expired jobs from Redis
const jobKeys = await redisService.getAllJobKeys();
for (const key of jobKeys) {
const jobId = key.replace('job:', '');
const job = await redisService.getJob(jobId);
if (!job || job.remainingTTL <= 0) {
// Clean up associated files
if (job) {
const deleted = await fileService.cleanupJobFiles(
job.inputPath,
job.outputPath
);
if (deleted.inputDeleted) stats.filesDeleted++;
if (deleted.outputDeleted) stats.filesDeleted++;
}
await redisService.deleteJob(jobId);
stats.jobsDeleted++;
}
}
// Clean up orphaned files (files without jobs)
const expiredFiles = await fileService.getExpiredFiles();
for (const filePath of expiredFiles) {
const deleted = await fileService.deleteFile(filePath);
if (deleted) stats.filesDeleted++;
else stats.errors++;
}
console.log('Cleanup completed:', stats);
return stats;
} catch (error) {
console.error('Cleanup error:', error);
throw error;
}
}
async manualCleanup() {
return await this.cleanup();
}
}
module.exports = new CleanupService();

View File

@@ -0,0 +1,36 @@
const { exec } = require('child_process');
const path = require('path');
const fs = require('fs').promises;
const util = require('util');
const execPromise = util.promisify(exec);
class ConverterService {
/**
* Convert STL to STEP format using FreeCAD CLI
*/
async convertSTLtoSTEP(inputPath, outputPath) {
try {
const command = `freecadcmd -c "import Part; import Mesh; import FreeCAD; import Import; mesh = Mesh.read('${inputPath}'); shape = Part.Shape(); shape.makeShapeFromMesh(mesh.Topology, 0.01); doc = FreeCAD.newDocument('STLImport'); obj = doc.addObject('Part::Feature', 'MeshShape'); obj.Shape = shape; Import.export([obj], '${outputPath}'); FreeCAD.closeDocument('STLImport')"`;
const { stdout, stderr } = await execPromise(command);
return { success: true, outputPath };
} catch (error) {
throw new Error(`Conversion failed: ${error.message}`);
}
}
async getFileInfo(filePath) {
const stats = await fs.stat(filePath);
return {
size: stats.size,
created: stats.birthtime,
modified: stats.mtime,
};
}
}
module.exports = new ConverterService();

View File

@@ -0,0 +1,76 @@
const fs = require('fs').promises;
const path = require('path');
const config = require('../config');
class FileService {
async ensureDirectories() {
await fs.mkdir(config.upload.dir, { recursive: true });
await fs.mkdir(config.output.dir, { recursive: true });
}
async deleteFile(filePath) {
try {
await fs.unlink(filePath);
return true;
} catch (error) {
console.error(`Failed to delete file ${filePath}:`, error.message);
return false;
}
}
async fileExists(filePath) {
try {
await fs.access(filePath);
return true;
} catch {
return false;
}
}
getUploadPath(filename) {
return path.join(config.upload.dir, filename);
}
getOutputPath(filename) {
return path.join(config.output.dir, filename);
}
async cleanupJobFiles(inputPath, outputPath) {
const results = await Promise.allSettled([
this.deleteFile(inputPath),
this.deleteFile(outputPath),
]);
return {
inputDeleted: results[0].status === 'fulfilled' && results[0].value,
outputDeleted: results[1].status === 'fulfilled' && results[1].value,
};
}
async getExpiredFiles() {
const now = Date.now();
const expiredFiles = [];
for (const dir of [config.upload.dir, config.output.dir]) {
try {
const files = await fs.readdir(dir);
for (const file of files) {
const filePath = path.join(dir, file);
const stats = await fs.stat(filePath);
const age = now - stats.mtimeMs;
if (age > config.jobs.ttl * 1000) {
expiredFiles.push(filePath);
}
}
} catch (error) {
console.error(`Error scanning directory ${dir}:`, error.message);
}
}
return expiredFiles;
}
}
module.exports = new FileService();

View File

@@ -0,0 +1,80 @@
const redis = require('redis');
const config = require('../config');
class RedisService {
constructor() {
this.client = null;
}
async connect() {
this.client = redis.createClient({
socket: {
host: config.redis.host,
port: config.redis.port,
},
});
this.client.on('error', (err) => console.error('Redis Client Error', err));
await this.client.connect();
console.log('Redis connected');
}
async createJob(jobId, data) {
const job = {
...data,
createdAt: Date.now(),
expiresAt: Date.now() + (config.jobs.ttl * 1000),
};
await this.client.setEx(
`job:${jobId}`,
config.jobs.ttl,
JSON.stringify(job)
);
return job;
}
async getJob(jobId) {
const data = await this.client.get(`job:${jobId}`);
if (!data) return null;
const job = JSON.parse(data);
const ttl = await this.client.ttl(`job:${jobId}`);
return {
...job,
remainingTTL: ttl > 0 ? ttl : 0,
};
}
async updateJob(jobId, updates) {
const job = await this.getJob(jobId);
if (!job) return null;
const updatedJob = { ...job, ...updates };
const ttl = await this.client.ttl(`job:${jobId}`);
await this.client.setEx(
`job:${jobId}`,
ttl > 0 ? ttl : config.jobs.ttl,
JSON.stringify(updatedJob)
);
return updatedJob;
}
async deleteJob(jobId) {
await this.client.del(`job:${jobId}`);
}
async getAllJobKeys() {
return await this.client.keys('job:*');
}
async disconnect() {
if (this.client) {
await this.client.quit();
}
}
}
module.exports = new RedisService();