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

View File

@@ -30,8 +30,6 @@ public class OrderController {
private final CustomerRepository customerRepo;
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,
OrderItemRepository orderItemRepo,
@@ -207,7 +205,11 @@ public class OrderController {
order.setSetupCostChf(session.getSetupCostChf());
order.setShippingCostChf(BigDecimal.valueOf(9.00)); // Default shipping? or 0?
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);
order.setTotalChf(total);

View File

@@ -108,7 +108,11 @@ public class QuoteController {
try {
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);

View File

@@ -41,6 +41,9 @@ public class PrinterMachine {
@Column(name = "created_at", nullable = false)
private OffsetDateTime createdAt;
@Column(name = "slicer_machine_profile")
private String slicerMachineProfile;
public Long getId() {
return id;
}
@@ -57,6 +60,14 @@ public class PrinterMachine {
this.printerDisplayName = printerDisplayName;
}
public String getSlicerMachineProfile() {
return slicerMachineProfile;
}
public void setSlicerMachineProfile(String slicerMachineProfile) {
this.slicerMachineProfile = slicerMachineProfile;
}
public Integer getBuildVolumeXMm() {
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;
} else if (result instanceof ScanResult.VirusFound) {
Map<String, Collection<String>> viruses = ((ScanResult.VirusFound) result).getFoundViruses();
logger.warn("Virus found: {}", viruses);
logger.warn("VIRUS DETECTED: {}", viruses);
return false;
} else {
logger.warn("Unknown scan result: {}", result);

View File

@@ -89,26 +89,15 @@ public class SlicerService {
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
ProcessBuilder pb = new ProcessBuilder(command);
pb.directory(tempDir.toFile());
// pb.inheritIO(); // Useful for debugging, but maybe capture instead?
runSlicerCommand(command, tempDir);
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
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
@@ -131,9 +120,6 @@ public class SlicerService {
// 6. Parse Results
return gCodeParser.parse(gcodeFile);
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
throw new IOException("Interrupted during slicing", e);
} finally {
// Cleanup temp dir
// 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.
}
}
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,
is_active boolean not null default true,
slicer_machine_profile varchar(255),
created_at timestamptz not null default now()
);

View File

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

View File

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

View File

@@ -2,7 +2,9 @@
<h1>{{ 'CALC.TITLE' | translate }}</h1>
<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>
}
</div>

View File

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

View File

@@ -215,7 +215,8 @@ export class QuoteEstimatorService {
},
error: (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++;
checkCompletion();
}
@@ -262,7 +263,13 @@ export class QuoteEstimatorService {
});
if (validCount === 0) {
observer.error('All calculations failed.');
// 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.');
}
return;
}

View File

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

View File

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