Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions deploy/docker-compose.prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 18 additions & 6 deletions deploy/nginx.conf.template
Original file line number Diff line number Diff line change
Expand Up @@ -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) =====
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.*;
Expand Down Expand Up @@ -84,6 +86,36 @@ public ResponseEntity<ApiResponse<MascotResponse>> 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<ApiResponse<CleanMeshGenerateResponse>> 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<ApiResponse<CleanMeshJobStatusResponse>> 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에 업로드하면 자동 리깅 후 애니메이션을 다운로드할 수 있습니다. " +
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
@@ -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;
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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);
Expand Down
Loading