From 5190d4ddba77b68737d4c98d8b5425559c9c2772 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:05:58 +0900 Subject: [PATCH 1/3] =?UTF-8?q?refactor:=20=EB=A7=88=EC=8A=A4=EC=BD=94?= =?UTF-8?q?=ED=8A=B8=20anim=20=EB=B3=91=ED=95=A9=20=EB=A1=9C=EC=A7=81=20Ma?= =?UTF-8?q?scotAnimationMergeService=EB=A1=9C=20=EC=B6=94=EC=B6=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MascotAnimationMergeService.java | 73 +++++++++++++++++++ .../service/MascotGenerationService.java | 48 +----------- 2 files changed, 75 insertions(+), 46 deletions(-) create mode 100644 guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java new file mode 100644 index 0000000..a5986ed --- /dev/null +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java @@ -0,0 +1,73 @@ +package com.guideon.guideonbackend.domain.mascot.service; + +import com.guideon.core.domain.mascot.entity.MascotAnimConfig; +import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; +import com.guideon.guideonbackend.global.storage.FileStorageService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +import java.util.List; +import java.util.Map; +import java.util.stream.Collectors; + +/** + * rig 완료된 마스코트 GLB + 상태별 Mixamo 애니메이션 GLB → 통합 anim GLB 병합. + * + * rig 완료 시점(MascotGenerationService)과 + * 애니메이션 GLB 업로드 시점(MascotAnimConfigService) 양쪽에서 공통으로 호출된다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MascotAnimationMergeService { + + private final MascotAnimConfigRepository animConfigRepository; + private final FileStorageService fileStorageService; + private final MeshProcessorClient meshProcessorClient; + private final MascotGenerationPersistService persistService; + + /** + * anim_config가 설정돼 있고 rig GLB가 존재하면 mesh-processor에 병합을 요청한다. + * + * 실패해도 예외를 전파하지 않는다 — rig base GLB는 이미 확보된 상태이므로 + * animModelUrl만 null로 남고 파이프라인 완료는 유지된다. + * + * @param siteId 사이트 ID + * @param generationId MascotGeneration PK (출력 파일명 + DB 업데이트에 사용) + * @param riggedModelUrl Tripo rig 결과 GLB 서빙 URL + */ + public void mergeIfRiggedMascotExists(Long siteId, Long generationId, String riggedModelUrl) { + List animConfigs = animConfigRepository.findBySiteId(siteId); + if (animConfigs.isEmpty()) { + log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId); + return; + } + + try { + // { "Idle": "/app/uploads/1/abc.glb", "Talking": "/app/uploads/1/def.glb", ... } + Map animGlbs = animConfigs.stream().collect(Collectors.toMap( + MascotAnimConfig::getClipName, + c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString() + )); + + String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); + String animFilename = "anim_" + generationId + ".glb"; + String animModelUrl = fileStorageService.resolveUrl(siteId, animFilename); + String animLocalPath = fileStorageService.toLocalPath(animModelUrl).toString(); + + meshProcessorClient.combine(riggedLocalPath, animGlbs, animLocalPath); + + Map animClips = animConfigs.stream().collect(Collectors.toMap( + MascotAnimConfig::getStateKey, + MascotAnimConfig::getClipName + )); + persistService.applyAnimationComplete(siteId, generationId, animModelUrl, animClips); + log.info("anim GLB 병합 완료: generationId={}, animModelUrl={}", generationId, animModelUrl); + + } catch (Exception e) { + log.warn("mesh-processor 실패 — animModelUrl 없이 완료: generationId={}, err={}", + generationId, e.getMessage()); + } + } +} 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 4b07cc7..4ac6d45 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 @@ -4,9 +4,7 @@ import com.guideon.common.exception.ErrorCode; import com.guideon.core.domain.admin.entity.AdminRole; import com.guideon.core.domain.mascot.entity.GenerationStatus; -import com.guideon.core.domain.mascot.entity.MascotAnimConfig; import com.guideon.core.domain.mascot.entity.MascotGeneration; -import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; import com.guideon.core.domain.mascot.repository.MascotGenerationRepository; import com.guideon.core.domain.site.entity.Site; import com.guideon.core.domain.site.repository.SiteRepository; @@ -20,9 +18,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.List; -import java.util.Map; -import java.util.stream.Collectors; /** * 마스코트 3D 모델 생성 파이프라인 오케스트레이션. @@ -43,8 +38,7 @@ public class MascotGenerationService { private final MascotGenerationRepository generationRepository; private final SiteRepository siteRepository; private final FileStorageService fileStorageService; - private final MascotAnimConfigRepository animConfigRepository; - private final MeshProcessorClient meshProcessorClient; + private final MascotAnimationMergeService animationMergeService; /** * Step 1: 선업로드된 이미지 URL을 받아 Tripo 3D 생성 task 시작 @@ -136,7 +130,7 @@ public GenerationStatusResponse pollStatus(Long siteId, Long generationId, log.info("리깅 GLB 저장 완료: generationId={}", generationId); // anim_config가 설정되어 있으면 mesh-processor로 자동 병합 - triggerAnimationMerge(siteId, generationId, modelUrl); + animationMergeService.mergeIfRiggedMascotExists(siteId, generationId, modelUrl); } return GenerationStatusResponse.from(gen); @@ -158,44 +152,6 @@ public GenerationStatusResponse getLatestGeneration(Long siteId, CustomAdminDeta return GenerationStatusResponse.from(gen); } - /** - * rig 완료 후 anim_config가 있으면 mesh-processor에 병합을 요청한다. - * 실패해도 파이프라인 완료 처리는 유지 (base GLB 확보됨, animModelUrl만 null로 남음). - */ - private void triggerAnimationMerge(Long siteId, Long generationId, String riggedModelUrl) { - List animConfigs = animConfigRepository.findBySiteId(siteId); - if (animConfigs.isEmpty()) { - log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId); - return; - } - - try { - // { "Idle": "/app/uploads/1/abc.glb", "Talking": "/app/uploads/1/def.glb", ... } - Map animGlbs = animConfigs.stream().collect(Collectors.toMap( - MascotAnimConfig::getClipName, - c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString() - )); - - String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); - String animFilename = "anim_" + generationId + ".glb"; - String animModelUrl = fileStorageService.resolveUrl(siteId, animFilename); - String animLocalPath = fileStorageService.toLocalPath(animModelUrl).toString(); - - meshProcessorClient.combine(riggedLocalPath, animGlbs, animLocalPath); - - Map animClips = animConfigs.stream().collect(Collectors.toMap( - MascotAnimConfig::getStateKey, - MascotAnimConfig::getClipName - )); - persistService.applyAnimationComplete(siteId, generationId, animModelUrl, animClips); - log.info("anim GLB 자동 병합 완료: generationId={}, animModelUrl={}", generationId, animModelUrl); - - } catch (Exception e) { - log.warn("mesh-processor 실패 — animModelUrl 없이 완료: generationId={}, err={}", - generationId, e.getMessage()); - } - } - private void validatePlatformAdmin(CustomAdminDetails adminDetails) { if (!AdminRole.PLATFORM_ADMIN.name().equals(adminDetails.getRole())) { throw new CustomException(ErrorCode.ACCESS_DENIED); From 3d723624b4292fc6cc6325d0984d7f8bfcbd376c Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:08:10 +0900 Subject: [PATCH 2/3] =?UTF-8?q?feat:=20=EC=95=A0=EB=8B=88=EB=A9=94?= =?UTF-8?q?=EC=9D=B4=EC=85=98=20GLB=20=EC=97=85=EB=A1=9C=EB=93=9C=20?= =?UTF-8?q?=EC=8B=9C=EC=A0=90=20mesh-processor=20=EC=9E=90=EB=8F=99=20?= =?UTF-8?q?=EB=B3=91=ED=95=A9=20=ED=8A=B8=EB=A6=AC=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AnimationGlbsUploadResponse.java | 3 ++ .../service/MascotAnimConfigService.java | 28 ++++++++++++++++++- 2 files changed, 30 insertions(+), 1 deletion(-) diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java index 4c7242f..2a07c0c 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/dto/AnimationGlbsUploadResponse.java @@ -12,6 +12,9 @@ public class AnimationGlbsUploadResponse { private List uploaded; + /** 병합 완료 시 animModelUrl, 마스코트 미생성 또는 병합 실패 시 null */ + private String animModelUrl; + @Getter @Builder public static class UploadedClip { diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java index 5a1a0b4..323afd8 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java @@ -4,7 +4,9 @@ import com.guideon.common.exception.ErrorCode; import com.guideon.core.domain.admin.entity.AdminRole; import com.guideon.core.domain.mascot.entity.MascotAnimConfig; +import com.guideon.core.domain.mascot.entity.MascotGeneration; import com.guideon.core.domain.mascot.repository.MascotAnimConfigRepository; +import com.guideon.core.domain.mascot.repository.MascotGenerationRepository; import com.guideon.guideonbackend.domain.mascot.dto.AnimConfigRequest; import com.guideon.guideonbackend.domain.mascot.dto.AnimConfigResponse; import com.guideon.guideonbackend.domain.mascot.dto.AnimationGlbsUploadResponse; @@ -41,7 +43,9 @@ public class MascotAnimConfigService { ); private final MascotAnimConfigRepository animConfigRepository; + private final MascotGenerationRepository generationRepository; private final FileStorageService fileStorageService; + private final MascotAnimationMergeService animationMergeService; /** * state별 GLB 파일 업로드. @@ -106,7 +110,13 @@ public AnimationGlbsUploadResponse uploadAnimationGlbs( .build()); } - return AnimationGlbsUploadResponse.builder().uploaded(uploaded).build(); + // rig가 완료된 마스코트가 있으면 즉시 병합 — animModelUrl을 응답에 반영 + String animModelUrl = triggerMergeIfAvailable(siteId); + + return AnimationGlbsUploadResponse.builder() + .uploaded(uploaded) + .animModelUrl(animModelUrl) + .build(); } /** @@ -158,6 +168,22 @@ public AnimConfigResponse updateAnimConfig(Long siteId, AnimConfigRequest reques return getAnimConfig(siteId, adminDetails); } + /** + * 해당 site의 최신 rig 완료 이력이 있으면 병합을 동기 실행하고 animModelUrl 반환. + * 없으면 null 반환 (이후 rig 완료 시점에 자동 병합됨). + */ + private String triggerMergeIfAvailable(Long siteId) { + return generationRepository.findTopBySite_SiteIdOrderByCreatedAtDesc(siteId) + .filter(MascotGeneration::isFullyCompleted) + .filter(gen -> gen.getResultModelUrl() != null) + .map(gen -> { + animationMergeService.mergeIfRiggedMascotExists( + siteId, gen.getGenerationId(), gen.getResultModelUrl()); + return fileStorageService.resolveUrl(siteId, "anim_" + gen.getGenerationId() + ".glb"); + }) + .orElse(null); + } + private void validatePlatformAdmin(CustomAdminDetails adminDetails) { if (!AdminRole.PLATFORM_ADMIN.name().equals(adminDetails.getRole())) { throw new CustomException(ErrorCode.ACCESS_DENIED); From 2022d9f06dcd869f9a5d5c96d532430813fb32c8 Mon Sep 17 00:00:00 2001 From: JaeHyunAn <98042706+yyuneu@users.noreply.github.com> Date: Thu, 4 Jun 2026 23:26:32 +0900 Subject: [PATCH 3/3] =?UTF-8?q?fix:=20anim=20=EB=B3=91=ED=95=A9=20?= =?UTF-8?q?=EC=84=B1=EA=B3=B5=EC=97=AC=EB=B6=80=20=EB=B0=98=EC=98=81=C2=B7?= =?UTF-8?q?=EC=A4=91=EB=B3=B5=20clipName=C2=B7=EC=B5=9C=EC=8B=A0=20?= =?UTF-8?q?=EC=99=84=EB=A3=8C=20=EC=9D=B4=EB=A0=A5=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EB=B3=B4=EA=B0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mascot/service/MascotAnimConfigService.java | 13 +++++++------ .../service/MascotAnimationMergeService.java | 15 ++++++++------- 2 files changed, 15 insertions(+), 13 deletions(-) diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java index 323afd8..2938fef 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimConfigService.java @@ -171,16 +171,17 @@ public AnimConfigResponse updateAnimConfig(Long siteId, AnimConfigRequest reques /** * 해당 site의 최신 rig 완료 이력이 있으면 병합을 동기 실행하고 animModelUrl 반환. * 없으면 null 반환 (이후 rig 완료 시점에 자동 병합됨). + * + * findTop 대신 전체 목록 스트림으로 첫 번째 완료건을 선택 — + * 최신 생성이 FAILED/진행중이어도 이전 완료 이력을 정확히 찾는다. */ private String triggerMergeIfAvailable(Long siteId) { - return generationRepository.findTopBySite_SiteIdOrderByCreatedAtDesc(siteId) + return generationRepository.findBySite_SiteIdOrderByCreatedAtDesc(siteId).stream() .filter(MascotGeneration::isFullyCompleted) .filter(gen -> gen.getResultModelUrl() != null) - .map(gen -> { - animationMergeService.mergeIfRiggedMascotExists( - siteId, gen.getGenerationId(), gen.getResultModelUrl()); - return fileStorageService.resolveUrl(siteId, "anim_" + gen.getGenerationId() + ".glb"); - }) + .findFirst() + .map(gen -> animationMergeService.mergeIfRiggedMascotExists( + siteId, gen.getGenerationId(), gen.getResultModelUrl())) .orElse(null); } diff --git a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java index a5986ed..8e31ae4 100644 --- a/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java +++ b/guideon-admin-bff/src/main/java/com/guideon/guideonbackend/domain/mascot/service/MascotAnimationMergeService.java @@ -30,25 +30,24 @@ public class MascotAnimationMergeService { /** * anim_config가 설정돼 있고 rig GLB가 존재하면 mesh-processor에 병합을 요청한다. * - * 실패해도 예외를 전파하지 않는다 — rig base GLB는 이미 확보된 상태이므로 - * animModelUrl만 null로 남고 파이프라인 완료는 유지된다. - * * @param siteId 사이트 ID * @param generationId MascotGeneration PK (출력 파일명 + DB 업데이트에 사용) * @param riggedModelUrl Tripo rig 결과 GLB 서빙 URL + * @return 병합 성공 시 animModelUrl, anim_config 미설정 또는 병합 실패 시 null */ - public void mergeIfRiggedMascotExists(Long siteId, Long generationId, String riggedModelUrl) { + public String mergeIfRiggedMascotExists(Long siteId, Long generationId, String riggedModelUrl) { List animConfigs = animConfigRepository.findBySiteId(siteId); if (animConfigs.isEmpty()) { log.info("anim_config 미설정 — anim 병합 skip: siteId={}", siteId); - return; + return null; } try { - // { "Idle": "/app/uploads/1/abc.glb", "Talking": "/app/uploads/1/def.glb", ... } + // clipName이 중복일 때 IllegalStateException 방지 — 첫 번째 값 유지 (a, b) -> a Map animGlbs = animConfigs.stream().collect(Collectors.toMap( MascotAnimConfig::getClipName, - c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString() + c -> fileStorageService.toLocalPath(c.getGlbUrl()).toString(), + (a, b) -> a )); String riggedLocalPath = fileStorageService.toLocalPath(riggedModelUrl).toString(); @@ -64,10 +63,12 @@ public void mergeIfRiggedMascotExists(Long siteId, Long generationId, String rig )); persistService.applyAnimationComplete(siteId, generationId, animModelUrl, animClips); log.info("anim GLB 병합 완료: generationId={}, animModelUrl={}", generationId, animModelUrl); + return animModelUrl; } catch (Exception e) { log.warn("mesh-processor 실패 — animModelUrl 없이 완료: generationId={}, err={}", generationId, e.getMessage()); + return null; } } }