diff --git a/deploy/docker-compose.prod.yml b/deploy/docker-compose.prod.yml index 6c8ce62..01663cc 100644 --- a/deploy/docker-compose.prod.yml +++ b/deploy/docker-compose.prod.yml @@ -157,6 +157,7 @@ services: - ./nginx.conf.template:/etc/nginx/templates/default.conf.template - /var/www/certbot:/var/www/certbot - /etc/letsencrypt:/etc/letsencrypt:ro + - uploads:/srv/files:ro # admin-bff 업로드 볼륨 읽기전용 — static 파일 직접 서빙 depends_on: - admin-bff - kiosk-bff diff --git a/deploy/nginx.conf.template b/deploy/nginx.conf.template index af18a94..d341123 100644 --- a/deploy/nginx.conf.template +++ b/deploy/nginx.conf.template @@ -42,13 +42,25 @@ server { # CORS는 Spring Security에서 처리 (중복 방지) } - # ===== 업로드 파일 서빙 (이미지/PDF) ===== + # ===== 업로드 파일 서빙 (GLB / 이미지 / PDF) ===== + # nginx가 디스크에서 직접 sendfile — admin-bff JVM/Tomcat 경유 없음. + # JVM GC 멈춤·스왑 압력으로 인한 GLB 다운로드 중간 끊김(잘린 200 응답)을 근본 해소. location /internal/files/ { - proxy_pass http://admin-bff:8081; - proxy_set_header Host $host; - proxy_set_header X-Real-IP $remote_addr; - proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; - proxy_set_header X-Forwarded-Proto $scheme; + alias /srv/files/; + + types { + model/gltf-binary glb; + application/pdf pdf; + image/png png; + image/jpeg jpg jpeg; + image/webp webp; + } + default_type application/octet-stream; + + sendfile on; + tcp_nopush on; + add_header Accept-Ranges bytes; # 클라이언트 Range 요청 허용 (재개 가능) + access_log off; } # ===== Kiosk BFF (REST) ===== diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java index f847e60..d48ddbe 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/controller/MascotController.java @@ -3,6 +3,8 @@ import com.guideon.common.exception.CustomException; import com.guideon.common.exception.ErrorCode; import com.guideon.common.response.ApiResponse; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshGenerateResponse; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshJobStatusResponse; import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshResponse; import com.guideon.guideonbackend.domain.mascot.dto.ModelUploadResponse; import com.guideon.guideonbackend.domain.mascot.dto.*; @@ -84,6 +86,36 @@ public ResponseEntity> updateMascot( return ResponseEntity.ok(ApiResponse.success(response, traceId)); } + @Operation(summary = "리깅 없는 FBX 독립 생성 시작", + description = "이미지를 업로드하면 Tripo image_to_model만 실행해 리깅 없는 FBX를 생성합니다. " + + "animate_rig 없이 Mixamo 업로드용 clean mesh만 뽑는 독립 파이프라인. " + + "반환된 taskId로 /clean-mesh/generate/{taskId}/status를 폴링하세요. PLATFORM_ADMIN 권한 필요") + @PostMapping(value = "/clean-mesh/generate", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> startCleanMeshGeneration( + @PathVariable Long siteId, + @RequestPart("file") MultipartFile file, + @AuthenticationPrincipal CustomAdminDetails adminDetails, + HttpServletRequest httpRequest + ) { + CleanMeshGenerateResponse response = generationService.startCleanMeshGeneration(siteId, file, adminDetails); + String traceId = (String) httpRequest.getAttribute(TraceIdUtil.TRACE_ID_ATTR); + return ResponseEntity.ok(ApiResponse.success(response, traceId)); + } + + @Operation(summary = "리깅 없는 FBX 생성 상태 폴링", + description = "status=ready 시 cleanMeshUrl에서 FBX를 다운로드할 수 있습니다.") + @GetMapping("/clean-mesh/generate/{taskId}/status") + public ResponseEntity> pollCleanMeshStatus( + @PathVariable Long siteId, + @PathVariable String taskId, + @AuthenticationPrincipal CustomAdminDetails adminDetails, + HttpServletRequest httpRequest + ) { + CleanMeshJobStatusResponse response = generationService.pollCleanMeshStatus(siteId, taskId, adminDetails); + String traceId = (String) httpRequest.getAttribute(TraceIdUtil.TRACE_ID_ATTR); + return ResponseEntity.ok(ApiResponse.success(response, traceId)); + } + @Operation(summary = "Mixamo 업로드용 Clean Mesh 조회", description = "스켈레톤 제거된 T-포즈 FBX URL을 반환합니다. " + "이 파일을 Mixamo에 업로드하면 자동 리깅 후 애니메이션을 다운로드할 수 있습니다. " + diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshGenerateResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshGenerateResponse.java new file mode 100644 index 0000000..7590755 --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshGenerateResponse.java @@ -0,0 +1,12 @@ +package com.guideon.guideonbackend.domain.mascot.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CleanMeshGenerateResponse { + + /** Tripo image_to_model taskId — 프론트가 폴링에 사용 */ + private String taskId; +} diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshJobStatusResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshJobStatusResponse.java new file mode 100644 index 0000000..fef93b9 --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/CleanMeshJobStatusResponse.java @@ -0,0 +1,17 @@ +package com.guideon.guideonbackend.domain.mascot.dto; + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class CleanMeshJobStatusResponse { + + private String taskId; + + /** "processing" | "ready" | "failed" */ + private String status; + + /** ready 일 때만 non-null */ + private String cleanMeshUrl; +} diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java index 46bcb2d..77eaeaf 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotGenerationService.java @@ -8,6 +8,8 @@ import com.guideon.core.domain.mascot.repository.MascotGenerationRepository; import com.guideon.core.domain.site.entity.Site; import com.guideon.core.domain.site.repository.SiteRepository; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshGenerateResponse; +import com.guideon.guideonbackend.domain.mascot.dto.CleanMeshJobStatusResponse; import com.guideon.guideonbackend.domain.mascot.dto.GenerationStatusResponse; import com.guideon.guideonbackend.domain.mascot.dto.ModelUploadResponse; import com.guideon.guideonbackend.domain.mascot.dto.StartGenerationResponse; @@ -233,6 +235,74 @@ public GenerationStatusResponse getLatestGeneration(Long siteId, CustomAdminDeta return GenerationStatusResponse.from(gen); } + /** + * 독립 clean-mesh 파이프라인 Step 1: 이미지 업로드 → Tripo image_to_model 시작 → taskId 반환. + * rig(animate_rig) 없이 리깅 없는 모델만 생성. Mixamo 수동 리깅 전용. + */ + public CleanMeshGenerateResponse startCleanMeshGeneration(Long siteId, MultipartFile file, + CustomAdminDetails adminDetails) { + validatePlatformAdmin(adminDetails); + + FileValidator.validateImage(file); + String fileHash = FileValidator.computeFileHash(file); + String imageUrl = fileStorageService.store(siteId, fileHash, file); + + byte[] imageBytes = fileStorageService.loadBytes(siteId, imageUrl); + String filename = file.getOriginalFilename() != null ? file.getOriginalFilename() : "mascot.jpg"; + + String imageToken = tripoApiService.uploadImage(imageBytes, filename); + String taskId = tripoApiService.createImageToModelTask(imageToken); + + log.info("독립 clean-mesh 생성 시작: siteId={}, taskId={}", siteId, taskId); + return CleanMeshGenerateResponse.builder().taskId(taskId).build(); + } + + /** + * 독립 clean-mesh 파이프라인 Step 2: Tripo 상태 폴링 → 완료 시 FBX 저장 + cleanMeshUrl 갱신. + */ + public CleanMeshJobStatusResponse pollCleanMeshStatus(Long siteId, String taskId, + CustomAdminDetails adminDetails) { + validatePlatformAdmin(adminDetails); + + TripoApiService.TripoTaskStatus status = tripoApiService.getTaskStatus(taskId); + + if (status.isProcessing()) { + return CleanMeshJobStatusResponse.builder().taskId(taskId).status("processing").build(); + } + if (status.isFailed()) { + log.warn("독립 clean-mesh Tripo 실패: siteId={}, taskId={}", siteId, taskId); + return CleanMeshJobStatusResponse.builder().taskId(taskId).status("failed").build(); + } + + // success — 다운로드 → FBX 확보 → 저장 + byte[] modelBytes = tripoApiService.downloadModel(status.modelUrl()); + String taskShort = taskId.substring(0, Math.min(12, taskId.length())); + String fbxUrl; + + try { + if (FileValidator.isGlb(modelBytes)) { + // GLB → assimp로 FBX 변환 + String glbHash = FileValidator.computeFileHash(modelBytes); + String glbUrl = fileStorageService.store(siteId, glbHash, modelBytes, "cm_raw_" + taskShort + ".glb"); + String glbLocal = fileStorageService.toLocalPath(glbUrl).toString(); + fbxUrl = fileStorageService.resolveUrl(siteId, "cm_" + taskShort + ".fbx"); + String fbxLocal = fileStorageService.toLocalPath(fbxUrl).toString(); + meshProcessorClient.stripRig(glbLocal, fbxLocal); + } else { + // Tripo가 FBX 직접 반환 → 바로 저장 + String fbxHash = FileValidator.computeFileHash(modelBytes); + fbxUrl = fileStorageService.store(siteId, fbxHash, modelBytes, "cm_" + taskShort + ".fbx"); + } + } catch (Exception e) { + log.error("독립 clean-mesh FBX 변환/저장 실패: siteId={}, taskId={}, err={}", siteId, taskId, e.getMessage()); + return CleanMeshJobStatusResponse.builder().taskId(taskId).status("failed").build(); + } + + persistService.saveCleanMeshUrl(siteId, fbxUrl); + log.info("독립 clean-mesh 완료: siteId={}, fbxUrl={}", siteId, fbxUrl); + return CleanMeshJobStatusResponse.builder().taskId(taskId).status("ready").cleanMeshUrl(fbxUrl).build(); + } + private void validatePlatformAdmin(CustomAdminDetails adminDetails) { if (!AdminRole.PLATFORM_ADMIN.name().equals(adminDetails.getRole())) { throw new CustomException(ErrorCode.ACCESS_DENIED);