feat(back-end):improvement
This commit is contained in:
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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?
|
||||
|
||||
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);
|
||||
}
|
||||
runSlicerCommand(command, tempDir);
|
||||
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
1
db.sql
@@ -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()
|
||||
);
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
Reference in New Issue
Block a user