feat(back-end):improvement
Some checks failed
Build, Test and Deploy / test-backend (push) Successful in 1m19s
Build, Test and Deploy / build-and-push (push) Failing after 37s
Build, Test and Deploy / deploy (push) Has been skipped

This commit is contained in:
2026-02-13 16:03:44 +01:00
parent e1d9823b51
commit f165d191be
16 changed files with 260 additions and 45 deletions

View File

@@ -26,13 +26,14 @@ public class CustomQuoteRequestController {
private final CustomQuoteRequestRepository requestRepo; private final CustomQuoteRequestRepository requestRepo;
private final CustomQuoteRequestAttachmentRepository attachmentRepo; private final CustomQuoteRequestAttachmentRepository attachmentRepo;
// TODO: Inject Storage Service private final com.printcalculator.service.StorageService storageService;
private static final String STORAGE_ROOT = "storage_requests";
public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo, public CustomQuoteRequestController(CustomQuoteRequestRepository requestRepo,
CustomQuoteRequestAttachmentRepository attachmentRepo) { CustomQuoteRequestAttachmentRepository attachmentRepo,
com.printcalculator.service.StorageService storageService) {
this.requestRepo = requestRepo; this.requestRepo = requestRepo;
this.attachmentRepo = attachmentRepo; this.attachmentRepo = attachmentRepo;
this.storageService = storageService;
} }
// 1. Create Custom Quote Request // 1. Create Custom Quote Request
@@ -91,10 +92,8 @@ public class CustomQuoteRequestController {
attachment.setStoredRelativePath(relativePath); attachment.setStoredRelativePath(relativePath);
attachmentRepo.save(attachment); attachmentRepo.save(attachment);
// Save file to disk // Save file to disk via StorageService
Path absolutePath = Paths.get(STORAGE_ROOT, relativePath); storageService.store(file, Paths.get(relativePath));
Files.createDirectories(absolutePath.getParent());
Files.copy(file.getInputStream(), absolutePath);
} }
} }

View File

@@ -30,8 +30,6 @@ public class OrderController {
private final CustomerRepository customerRepo; private final CustomerRepository customerRepo;
private final com.printcalculator.service.StorageService storageService; private final com.printcalculator.service.StorageService storageService;
// TODO: Inject Storage Service or use a base path property
// private static final String STORAGE_ROOT = "storage_orders";
public OrderController(OrderRepository orderRepo, public OrderController(OrderRepository orderRepo,
OrderItemRepository orderItemRepo, OrderItemRepository orderItemRepo,
@@ -207,7 +205,11 @@ public class OrderController {
order.setSetupCostChf(session.getSetupCostChf()); order.setSetupCostChf(session.getSetupCostChf());
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0? order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
order.setDiscountChf(BigDecimal.ZERO); order.setDiscountChf(BigDecimal.ZERO);
// TODO: Calc implementation for shipping // Calculate Shipping (Basic implementation: Flat rate 9.00 if not pickup)
// Future: Check delivery method from request if available
if (order.getShippingCostChf() == null) {
order.setShippingCostChf(BigDecimal.valueOf(9.00));
}
BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO); BigDecimal total = subtotal.add(order.getSetupCostChf()).add(order.getShippingCostChf()).subtract(order.getDiscountChf() != null ? order.getDiscountChf() : BigDecimal.ZERO);
order.setTotalChf(total); order.setTotalChf(total);

View File

@@ -108,7 +108,11 @@ public class QuoteController {
try { try {
file.transferTo(tempInput.toFile()); file.transferTo(tempInput.toFile());
String slicerMachineProfile = "bambu_a1"; // TODO: Add to PrinterMachine entity // Use profile from machine or fallback
String slicerMachineProfile = machine.getSlicerMachineProfile();
if (slicerMachineProfile == null || slicerMachineProfile.isEmpty()) {
slicerMachineProfile = "bambu_a1";
}
PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides); PrintStats stats = slicerService.slice(tempInput.toFile(), slicerMachineProfile, filament, process, machineOverrides, processOverrides);

View File

@@ -41,6 +41,9 @@ public class PrinterMachine {
@Column(name = "created_at", nullable = false) @Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt; private OffsetDateTime createdAt;
@Column(name = "slicer_machine_profile")
private String slicerMachineProfile;
public Long getId() { public Long getId() {
return id; return id;
} }
@@ -57,6 +60,14 @@ public class PrinterMachine {
this.printerDisplayName = printerDisplayName; this.printerDisplayName = printerDisplayName;
} }
public String getSlicerMachineProfile() {
return slicerMachineProfile;
}
public void setSlicerMachineProfile(String slicerMachineProfile) {
this.slicerMachineProfile = slicerMachineProfile;
}
public Integer getBuildVolumeXMm() { public Integer getBuildVolumeXMm() {
return buildVolumeXMm; return buildVolumeXMm;
} }

View File

@@ -0,0 +1,46 @@
package com.printcalculator.exception;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ControllerAdvice;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.multipart.MaxUploadSizeExceededException;
import java.util.HashMap;
import java.util.Map;
@ControllerAdvice
@Slf4j
public class GlobalExceptionHandler {
@ExceptionHandler(StorageException.class)
public ResponseEntity<?> handleStorageException(StorageException exc) {
// Log the full exception for internal debugging
log.error("Storage Exception occurred", exc);
Map<String, String> response = new HashMap<>();
// Check for specific virus case
if (exc.getMessage() != null && exc.getMessage().contains("antivirus scanner")) {
response.put("error", "Security Violation");
// Safe message for client
response.put("message", "File rejected by security policy.");
response.put("code", "VIRUS_DETECTED");
return ResponseEntity.status(HttpStatus.UNPROCESSABLE_ENTITY).body(response);
}
// Generic fallback for other storage errors to avoid leaking internal paths/details
response.put("error", "Storage Operation Failed");
response.put("message", "Unable to process the file upload.");
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(response);
}
@ExceptionHandler(MaxUploadSizeExceededException.class)
public ResponseEntity<?> handleMaxSizeException(MaxUploadSizeExceededException exc) {
Map<String, String> response = new HashMap<>();
response.put("error", "File too large");
response.put("message", "The uploaded file exceeds the maximum allowed size.");
return ResponseEntity.status(HttpStatus.PAYLOAD_TOO_LARGE).body(response);
}
}

View File

@@ -40,7 +40,7 @@ public class ClamAVService {
return true; return true;
} else if (result instanceof ScanResult.VirusFound) { } else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses(); Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("Virus found: {}", viruses); logger.warn("VIRUS DETECTED: {}", viruses);
return false; return false;
} else { } else {
logger.warn("Unknown scan result: {}", result); logger.warn("Unknown scan result: {}", result);

View File

@@ -89,26 +89,15 @@ public class SlicerService {
command.add(inputStl.getAbsolutePath()); command.add(inputStl.getAbsolutePath());
logger.info("Executing Slicer: " + String.join(" ", command)); logger.info("Slicing file: " + inputStl.getAbsolutePath() + " (Size: " + inputStl.length() + " bytes)");
if (!inputStl.exists()) {
throw new IOException("Input file not found: " + inputStl.getAbsolutePath());
}
// 4. Run Process // 4. Run Process
ProcessBuilder pb = new ProcessBuilder(command); runSlicerCommand(command, tempDir);
pb.directory(tempDir.toFile());
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
Process process = pb.start(); // 5. Find Output GCode
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
if (!finished) {
process.destroy();
throw new IOException("Slicer timed out");
}
if (process.exitValue() != 0) {
// Read stderr
String error = new String(process.getErrorStream().readAllBytes());
throw new IOException("Slicer failed with exit code " + process.exitValue() + ": " + error);
}
// 5. Find Output GCode // 5. Find Output GCode
// Usually [basename].gcode or plate_1.gcode // Usually [basename].gcode or plate_1.gcode
@@ -131,9 +120,6 @@ public class SlicerService {
// 6. Parse Results // 6. Parse Results
return gCodeParser.parse(gcodeFile); return gCodeParser.parse(gcodeFile);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e);
} finally { } finally {
// Cleanup temp dir // Cleanup temp dir
// In production we should delete, for debugging we might want to keep? // In production we should delete, for debugging we might want to keep?
@@ -143,4 +129,30 @@ public class SlicerService {
// Implementation detail: Use a utility to clean up. // Implementation detail: Use a utility to clean up.
} }
} }
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException {
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
try {
Process process = pb.start();
boolean finished = process.waitFor(5, TimeUnit.MINUTES);
if (!finished) {
process.destroy();
throw new IOException("Slicer timed out");
}
if (process.exitValue() != 0) {
// Read stderr and stdout
String stderr = new String(process.getErrorStream().readAllBytes());
String stdout = new String(process.getInputStream().readAllBytes()); // STDOUT
throw new IOException("Slicer failed with exit code " + process.exitValue() +
"\nSTDERR: " + stderr +
"\nSTDOUT: " + stdout);
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e);
}
}
} }

View File

@@ -0,0 +1,123 @@
package com.printcalculator.service;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ObjectNode;
import com.printcalculator.model.PrintStats;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.io.TempDir;
import org.mockito.Mock;
import org.mockito.MockitoAnnotations;
import java.io.File;
import java.io.IOException;
import java.nio.file.Files;
import java.nio.file.Path;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import static org.junit.jupiter.api.Assertions.*;
import static org.mockito.ArgumentMatchers.anyString;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.when;
import static org.mockito.ArgumentMatchers.any;
class SlicerServiceTest {
@Mock
private ProfileManager profileManager;
@Mock
private GCodeParser gCodeParser;
private ObjectMapper mapper = new ObjectMapper();
private SlicerService slicerService;
@TempDir
Path tempDir;
// Captured execution details
private List<String> lastCommand;
private Path lastTempDir;
@BeforeEach
void setUp() throws IOException {
MockitoAnnotations.openMocks(this);
// Subclass to override runSlicerCommand
slicerService = new SlicerService("orca-slicer", profileManager, gCodeParser, mapper) {
@Override
protected void runSlicerCommand(List<String> command, Path tempDir) throws IOException {
lastCommand = command;
lastTempDir = tempDir;
// Don't run actual process.
// Simulate GCode output creation for the parser to find?
// Or just let it fail at parser step since we only care about JSON generation here?
// For a full test, we should create a dummy GCode file.
File stl = new File(command.get(command.size() - 1));
String basename = stl.getName().replace(".stl", "");
Files.createFile(tempDir.resolve(basename + ".gcode"));
}
};
// Mock Profile Responses
ObjectNode emptyNode = mapper.createObjectNode();
when(profileManager.getMergedProfile(anyString(), eq("machine"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("filament"))).thenReturn(emptyNode.deepCopy());
when(profileManager.getMergedProfile(anyString(), eq("process"))).thenReturn(emptyNode.deepCopy());
// Mock Parser
when(gCodeParser.parse(any(File.class))).thenReturn(new PrintStats(100, "1m 40s", 10.5, 1000));
}
@Test
void testSlice_WithDefaults_ShouldGenerateConfig() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, null);
assertNotNull(lastTempDir);
assertTrue(Files.exists(lastTempDir.resolve("process.json")));
assertTrue(Files.exists(lastTempDir.resolve("machine.json")));
assertTrue(Files.exists(lastTempDir.resolve("filament.json")));
}
@Test
void testSlice_WithLayerHeightOverride_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("layer_height", "0.12");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertTrue(processJson.has("layer_height"));
assertEquals("0.12", processJson.get("layer_height").asText());
}
@Test
void testSlice_WithInfillAndSupportOverrides_ShouldUpdateProcessJson() throws IOException {
File dummyStl = tempDir.resolve("test.stl").toFile();
Files.createFile(dummyStl.toPath());
Map<String, String> processOverrides = new HashMap<>();
processOverrides.put("sparse_infill_density", "25%");
processOverrides.put("enable_support", "1");
slicerService.slice(dummyStl, "Bambu A1", "PLA", "Standard", null, processOverrides);
File processJsonFile = lastTempDir.resolve("process.json").toFile();
ObjectNode processJson = (ObjectNode) mapper.readTree(processJsonFile);
assertEquals("25%", processJson.get("sparse_infill_density").asText());
assertEquals("1", processJson.get("enable_support").asText());
}
}

1
db.sql
View File

@@ -12,6 +12,7 @@ create table printer_machine
fleet_weight numeric(6, 3) not null default 1.000, fleet_weight numeric(6, 3) not null default 1.000,
is_active boolean not null default true, is_active boolean not null default true,
slicer_machine_profile varchar(255),
created_at timestamptz not null default now() created_at timestamptz not null default now()
); );

View File

@@ -15,13 +15,16 @@ services:
- DB_PASSWORD=${DB_PASSWORD} - DB_PASSWORD=${DB_PASSWORD}
- TEMP_DIR=/app/temp - TEMP_DIR=/app/temp
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=192.168.1.147 - CLAMAV_HOST=host.docker.internal
- CLAMAV_PORT=3310 - CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
restart: always restart: always
volumes: volumes:
- backend_profiles_${ENV}:/app/profiles - backend_profiles_${ENV}:/app/profiles
- /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage_quotes - /mnt/cache/appdata/print-calculator/${ENV}/storage_quotes:/app/storage/quotes
- /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage_orders - /mnt/cache/appdata/print-calculator/${ENV}/storage_orders:/app/storage/orders
extra_hosts:
- "host.docker.internal:host-gateway"
frontend: frontend:

View File

@@ -22,6 +22,7 @@ services:
- PROFILES_DIR=/app/profiles - PROFILES_DIR=/app/profiles
- CLAMAV_HOST=192.168.1.147 - CLAMAV_HOST=192.168.1.147
- CLAMAV_PORT=3310 - CLAMAV_PORT=3310
- STORAGE_LOCATION=/app/storage
depends_on: depends_on:
- db - db
- clamav - clamav

View File

@@ -2,7 +2,9 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1> <h1>{{ 'CALC.TITLE' | translate }}</h1>
<p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p> <p class="subtitle">{{ 'CALC.SUBTITLE' | translate }}</p>
@if (error()) { @if (error() === 'VIRUS_DETECTED') {
<app-alert type="error">{{ 'CALC.ERROR_VIRUS' | translate }}</app-alert>
} @else if (error()) {
<app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert> <app-alert type="error">{{ 'CALC.ERROR_GENERIC' | translate }}</app-alert>
} }
</div> </div>

View File

@@ -26,7 +26,7 @@ export class CalculatorPageComponent implements OnInit {
loading = signal(false); loading = signal(false);
uploadProgress = signal(0); uploadProgress = signal(0);
result = signal<QuoteResult | null>(null); result = signal<QuoteResult | null>(null);
error = signal<boolean>(false); error = signal<string | null>(null);
orderSuccess = signal(false); orderSuccess = signal(false);
@@ -141,7 +141,7 @@ export class CalculatorPageComponent implements OnInit {
this.currentRequest = req; this.currentRequest = req;
this.loading.set(true); this.loading.set(true);
this.uploadProgress.set(0); this.uploadProgress.set(0);
this.error.set(false); this.error.set(null);
this.result.set(null); this.result.set(null);
this.orderSuccess.set(false); this.orderSuccess.set(false);
@@ -175,8 +175,12 @@ export class CalculatorPageComponent implements OnInit {
} }
} }
}, },
error: () => { error: (err) => {
this.error.set(true); if (typeof err === 'string') {
this.error.set(err);
} else {
this.error.set('GENERIC');
}
this.loading.set(false); this.loading.set(false);
} }
}); });

View File

@@ -215,7 +215,8 @@ export class QuoteEstimatorService {
}, },
error: (err) => { error: (err) => {
console.error('Item upload failed', err); console.error('Item upload failed', err);
finalResponses[index] = { success: false, fileName: item.file.name }; const errorMsg = err.error?.code === 'VIRUS_DETECTED' ? 'VIRUS_DETECTED' : 'UPLOAD_FAILED';
finalResponses[index] = { success: false, fileName: item.file.name, error: errorMsg };
completedRequests++; completedRequests++;
checkCompletion(); checkCompletion();
} }
@@ -262,7 +263,13 @@ export class QuoteEstimatorService {
}); });
if (validCount === 0) { if (validCount === 0) {
// Check if any failed due to virus
const virusError = responses.find(r => r.error === 'VIRUS_DETECTED');
if (virusError) {
observer.error('VIRUS_DETECTED');
} else {
observer.error('All calculations failed.'); observer.error('All calculations failed.');
}
return; return;
} }

View File

@@ -40,7 +40,7 @@
"CTA_START": "Start Now", "CTA_START": "Start Now",
"BUSINESS": "Business", "BUSINESS": "Business",
"PRIVATE": "Private", "PRIVATE": "Private",
"MODE_EASY": "Quick", "MODE_EASY": "Easy Print",
"MODE_ADVANCED": "Advanced", "MODE_ADVANCED": "Advanced",
"UPLOAD_LABEL": "Drag your 3D file here", "UPLOAD_LABEL": "Drag your 3D file here",
"UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB", "UPLOAD_SUB": "Supports STL, 3MF, STEP, OBJ up to 50MB",

View File

@@ -17,7 +17,7 @@
"CTA_START": "Inizia Ora", "CTA_START": "Inizia Ora",
"BUSINESS": "Aziende", "BUSINESS": "Aziende",
"PRIVATE": "Privati", "PRIVATE": "Privati",
"MODE_EASY": "Base", "MODE_EASY": "Stampa Facile",
"MODE_ADVANCED": "Avanzata", "MODE_ADVANCED": "Avanzata",
"UPLOAD_LABEL": "Trascina il tuo file 3D qui", "UPLOAD_LABEL": "Trascina il tuo file 3D qui",
"UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB", "UPLOAD_SUB": "Supportiamo STL, 3MF, STEP, OBJ fino a 50MB",