From f43eb3300c9e37c3162c49a2cdd618c1e54cf991 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 27 Aug 2026 21:29:18 +0900 Subject: [PATCH 01/26] =?UTF-8?q?feat(media):=20=EC=BD=98=ED=85=90?= =?UTF-8?q?=EC=B8=A0=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20binding=EA=B3=BC=20pr?= =?UTF-8?q?ojection=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../org/sopt/hashi/media/ImageReference.java | 18 + .../sopt/hashi/media/MediaAssetPurpose.java | 12 + .../org/sopt/hashi/media/MediaAssetUse.java | 13 + .../java/org/sopt/hashi/media/MediaImage.java | 64 +++ .../sopt/hashi/media/MediaImageRequest.java | 13 + .../org/sopt/hashi/media/MediaImageRole.java | 16 + .../sopt/hashi/media/MediaImageStatus.java | 9 + .../java/org/sopt/hashi/media/MediaPort.java | 18 + .../sopt/hashi/media/domain/ImageAsset.java | 19 + .../media/domain/ImageAssetRepository.java | 66 +++ .../domain/ImageRenditionRepository.java | 38 ++ .../media/internal/spec/MediaRoleSpec.java | 7 +- .../internal/spec/MediaSpecRegistry.java | 1 + .../hashi/media/service/MediaPortImpl.java | 338 ++++++++++++++ .../hashi/media/domain/ImageAssetTest.java | 21 + .../internal/spec/MediaSpecRegistryTest.java | 14 + .../media/service/MediaPortImplTest.java | 440 ++++++++++++++++++ .../service/MediaPortIntegrationTest.java | 197 ++++++++ 18 files changed, 1303 insertions(+), 1 deletion(-) create mode 100644 src/main/java/org/sopt/hashi/media/ImageReference.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaAssetPurpose.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaAssetUse.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaImage.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaImageRequest.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaImageRole.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaImageStatus.java create mode 100644 src/main/java/org/sopt/hashi/media/MediaPort.java create mode 100644 src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java create mode 100644 src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java create mode 100644 src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java diff --git a/src/main/java/org/sopt/hashi/media/ImageReference.java b/src/main/java/org/sopt/hashi/media/ImageReference.java new file mode 100644 index 00000000..acebae6a --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/ImageReference.java @@ -0,0 +1,18 @@ +package org.sopt.hashi.media; + +import java.util.UUID; + +/** + * 콘텐츠 모듈이 소유한 이미지 association의 전환기 참조 값이다. + * legacy backfill이 끝난 association은 두 값이 모두 존재할 수 있다. + */ +public record ImageReference(UUID assetId, String legacyUrl) { + + public static ImageReference legacy(String legacyUrl) { + return new ImageReference(null, legacyUrl); + } + + public static ImageReference asset(UUID assetId) { + return new ImageReference(assetId, null); + } +} diff --git a/src/main/java/org/sopt/hashi/media/MediaAssetPurpose.java b/src/main/java/org/sopt/hashi/media/MediaAssetPurpose.java new file mode 100644 index 00000000..bc4dc431 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaAssetPurpose.java @@ -0,0 +1,12 @@ +package org.sopt.hashi.media; + +/** 콘텐츠 모듈이 asset claim 시 사용하는 공개 purpose 계약. */ +public enum MediaAssetPurpose { + + PROFILE, + REVIEW, + RESTAURANT, + RESTAURANT_MENU, + MAGAZINE_BANNER, + MAGAZINE_THUMBNAIL +} diff --git a/src/main/java/org/sopt/hashi/media/MediaAssetUse.java b/src/main/java/org/sopt/hashi/media/MediaAssetUse.java new file mode 100644 index 00000000..77993be8 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaAssetUse.java @@ -0,0 +1,13 @@ +package org.sopt.hashi.media; + +import java.util.Objects; +import java.util.UUID; + +/** claim 또는 retire 대상 asset과 콘텐츠가 기대하는 purpose. */ +public record MediaAssetUse(UUID assetId, MediaAssetPurpose purpose) { + + public MediaAssetUse { + Objects.requireNonNull(assetId, "assetId must not be null"); + Objects.requireNonNull(purpose, "purpose must not be null"); + } +} diff --git a/src/main/java/org/sopt/hashi/media/MediaImage.java b/src/main/java/org/sopt/hashi/media/MediaImage.java new file mode 100644 index 00000000..6afb3022 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaImage.java @@ -0,0 +1,64 @@ +package org.sopt.hashi.media; + +import java.util.List; +import java.util.Objects; +import java.util.UUID; + +/** 클라이언트가 반응형 이미지 후보를 선택할 수 있는 공통 이미지 응답. */ +public record MediaImage( + UUID assetId, + MediaImageRole role, + MediaImageStatus status, + Source defaultSource, + List sourceSets +) { + + public MediaImage { + Objects.requireNonNull(assetId, "assetId must not be null"); + Objects.requireNonNull(role, "role must not be null"); + Objects.requireNonNull(status, "status must not be null"); + sourceSets = List.copyOf(Objects.requireNonNull(sourceSets, "sourceSets must not be null")); + if (status == MediaImageStatus.READY && (defaultSource == null || sourceSets.isEmpty())) { + throw new IllegalArgumentException("READY image requires sources"); + } + if (status != MediaImageStatus.READY && (defaultSource != null || !sourceSets.isEmpty())) { + throw new IllegalArgumentException("non-READY image must not expose sources"); + } + } + + public record Source(String url, int width, int height, String mimeType) { + + public Source { + requireSource(url, width, height, mimeType); + } + } + + public record SourceSet(String mimeType, List candidates) { + + public SourceSet { + if (mimeType == null || mimeType.isBlank()) { + throw new IllegalArgumentException("mimeType must not be blank"); + } + candidates = List.copyOf(Objects.requireNonNull(candidates, "candidates must not be null")); + if (candidates.isEmpty()) { + throw new IllegalArgumentException("candidates must not be empty"); + } + } + } + + public record Candidate(String url, int width, int height) { + + public Candidate { + if (url == null || url.isBlank() || width < 1 || height < 1) { + throw new IllegalArgumentException("image candidate values are invalid"); + } + } + } + + private static void requireSource(String url, int width, int height, String mimeType) { + if (url == null || url.isBlank() || mimeType == null || mimeType.isBlank() + || width < 1 || height < 1) { + throw new IllegalArgumentException("image source values are invalid"); + } + } +} diff --git a/src/main/java/org/sopt/hashi/media/MediaImageRequest.java b/src/main/java/org/sopt/hashi/media/MediaImageRequest.java new file mode 100644 index 00000000..663c41df --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaImageRequest.java @@ -0,0 +1,13 @@ +package org.sopt.hashi.media; + +import java.util.Objects; +import java.util.UUID; + +/** asset과 화면 role로 파생 이미지 projection을 조회하는 bulk key. */ +public record MediaImageRequest(UUID assetId, MediaImageRole role) { + + public MediaImageRequest { + Objects.requireNonNull(assetId, "assetId must not be null"); + Objects.requireNonNull(role, "role must not be null"); + } +} diff --git a/src/main/java/org/sopt/hashi/media/MediaImageRole.java b/src/main/java/org/sopt/hashi/media/MediaImageRole.java new file mode 100644 index 00000000..9f4bb7ca --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaImageRole.java @@ -0,0 +1,16 @@ +package org.sopt.hashi.media; + +/** API 화면 용도별 파생 이미지 role. */ +public enum MediaImageRole { + + PROFILE_AVATAR, + RESTAURANT_THUMBNAIL, + RESTAURANT_CARD, + RESTAURANT_HERO, + MENU_LIST, + MENU_DETAIL, + REVIEW_PREVIEW, + REVIEW_DETAIL, + MAGAZINE_BANNER, + MAGAZINE_THUMBNAIL +} diff --git a/src/main/java/org/sopt/hashi/media/MediaImageStatus.java b/src/main/java/org/sopt/hashi/media/MediaImageStatus.java new file mode 100644 index 00000000..5f45ce26 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaImageStatus.java @@ -0,0 +1,9 @@ +package org.sopt.hashi.media; + +/** 콘텐츠 응답에 노출하는 이미지 처리 상태. */ +public enum MediaImageStatus { + + PROCESSING, + READY, + FAILED +} diff --git a/src/main/java/org/sopt/hashi/media/MediaPort.java b/src/main/java/org/sopt/hashi/media/MediaPort.java new file mode 100644 index 00000000..246941af --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaPort.java @@ -0,0 +1,18 @@ +package org.sopt.hashi.media; + +import java.util.Collection; +import java.util.Map; +import java.util.UUID; + +/** 콘텐츠 모듈의 일반 요청 경로가 사용하는 media 공개 facade. */ +public interface MediaPort { + + /** + * 추가 association의 asset을 claim하고 제거 association의 asset을 retire한다. + * 모든 대상은 media 내부 PK 오름차순으로 잠그며 호출 transaction에 함께 참여한다. + */ + void reconcileBindings(Collection claims, Collection retires); + + /** 요청한 asset-role projection을 고정된 bulk 조회로 반환한다. 조회 불일치는 결과에서 제외한다. */ + Map findImages(Collection requests); +} diff --git a/src/main/java/org/sopt/hashi/media/domain/ImageAsset.java b/src/main/java/org/sopt/hashi/media/domain/ImageAsset.java index 5db7b4ff..27e7fa30 100644 --- a/src/main/java/org/sopt/hashi/media/domain/ImageAsset.java +++ b/src/main/java/org/sopt/hashi/media/domain/ImageAsset.java @@ -247,6 +247,25 @@ public boolean isOwnedBy(MediaOwnerType actorType, Long actorSubjectId) { return ownerActorType == actorType && Objects.equals(ownerSubjectId, actorSubjectId); } + public void bind() { + if (cleanupStatus != MediaCleanupStatus.ACTIVE + || bindingStatus != ImageBindingStatus.UNBOUND) { + throw new IllegalStateException("only active unbound assets can be bound"); + } + bindingStatus = ImageBindingStatus.BOUND; + } + + public void retire() { + boolean active = cleanupStatus == MediaCleanupStatus.ACTIVE; + boolean purgedFailure = cleanupStatus == MediaCleanupStatus.PURGED + && processingStatus == ImageProcessingStatus.FAILED + && objectsPurgedAt != null; + if (bindingStatus != ImageBindingStatus.BOUND || (!active && !purgedFailure)) { + throw new IllegalStateException("asset cannot be retired in its current state"); + } + bindingStatus = ImageBindingStatus.RETIRED; + } + public boolean hasCurrentProcessingJob(UUID jobId) { return cleanupStatus == MediaCleanupStatus.ACTIVE && targetProcessingStatus == TargetProcessingStatus.PROCESSING diff --git a/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java b/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java index 04292b1d..33aa83e8 100644 --- a/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java +++ b/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java @@ -26,6 +26,33 @@ public interface ImageAssetRepository extends JpaRepository { List findAllByPublicIdIn(Collection publicIds); + @Query(""" + select asset.id as id, asset.publicId as publicId + from ImageAsset asset + where asset.publicId in :publicIds + """) + List findIdentitiesByPublicIdIn( + @Param("publicIds") Collection publicIds + ); + + @Query(""" + select asset.publicId as publicId, + asset.purpose as purpose, + asset.processingStatus as processingStatus, + asset.bindingStatus as bindingStatus, + asset.cleanupStatus as cleanupStatus, + asset.activeSpecVersion as activeSpecVersion, + asset.activeSpecDigest as activeSpecDigest, + asset.targetSpecVersion as targetSpecVersion, + asset.targetSpecDigest as targetSpecDigest, + asset.lastFailureSpecVersion as lastFailureSpecVersion + from ImageAsset asset + where asset.publicId in :publicIds + """) + List findImageProjectionsByPublicIdIn( + @Param("publicIds") Collection publicIds + ); + @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" select asset @@ -35,6 +62,15 @@ public interface ImageAssetRepository extends JpaRepository { """) List findAllByPublicIdInForUpdate(@Param("publicIds") Collection publicIds); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select asset + from ImageAsset asset + where asset.id in :ids + order by asset.id asc + """) + List findAllByIdInForUpdate(@Param("ids") Collection ids); + @Query(""" select asset.id as assetId, asset.currentJobId as jobId, @@ -174,6 +210,36 @@ interface ProcessingRecoveryCandidate { LocalDateTime getStartedAt(); } + interface AssetIdentity { + + Long getId(); + + UUID getPublicId(); + } + + interface AssetImageProjection { + + UUID getPublicId(); + + MediaPurpose getPurpose(); + + ImageProcessingStatus getProcessingStatus(); + + ImageBindingStatus getBindingStatus(); + + MediaCleanupStatus getCleanupStatus(); + + Integer getActiveSpecVersion(); + + String getActiveSpecDigest(); + + Integer getTargetSpecVersion(); + + String getTargetSpecDigest(); + + Integer getLastFailureSpecVersion(); + } + interface ProcessingStatusCount { ImageProcessingStatus getStatus(); diff --git a/src/main/java/org/sopt/hashi/media/domain/ImageRenditionRepository.java b/src/main/java/org/sopt/hashi/media/domain/ImageRenditionRepository.java index 823b3aa3..6582c9eb 100644 --- a/src/main/java/org/sopt/hashi/media/domain/ImageRenditionRepository.java +++ b/src/main/java/org/sopt/hashi/media/domain/ImageRenditionRepository.java @@ -1,6 +1,44 @@ package org.sopt.hashi.media.domain; +import java.util.Collection; +import java.util.List; +import java.util.UUID; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface ImageRenditionRepository extends JpaRepository { + + @Query(""" + select asset.publicId as assetId, + rendition.role as role, + rendition.mimeType as mimeType, + rendition.width as width, + rendition.height as height, + rendition.objectKey as objectKey + from ImageRendition rendition + join rendition.imageAsset asset + where asset.publicId in :assetIds + and rendition.role in :roles + and rendition.specVersion = asset.activeSpecVersion + """) + List findActiveImageProjections( + @Param("assetIds") Collection assetIds, + @Param("roles") Collection roles + ); + + interface RenditionImageProjection { + + UUID getAssetId(); + + ImageRole getRole(); + + String getMimeType(); + + int getWidth(); + + int getHeight(); + + String getObjectKey(); + } } diff --git a/src/main/java/org/sopt/hashi/media/internal/spec/MediaRoleSpec.java b/src/main/java/org/sopt/hashi/media/internal/spec/MediaRoleSpec.java index 2323baa3..9f049e5c 100644 --- a/src/main/java/org/sopt/hashi/media/internal/spec/MediaRoleSpec.java +++ b/src/main/java/org/sopt/hashi/media/internal/spec/MediaRoleSpec.java @@ -6,12 +6,14 @@ public record MediaRoleSpec( int aspectRatioWidth, int aspectRatioHeight, + int defaultWidth, int minimumFallbackWidth, List candidates ) { public MediaRoleSpec { - if (aspectRatioWidth < 1 || aspectRatioHeight < 1 || minimumFallbackWidth < 1) { + if (aspectRatioWidth < 1 || aspectRatioHeight < 1 + || defaultWidth < 1 || minimumFallbackWidth < 1) { throw new IllegalArgumentException("media role dimensions must be positive"); } if (candidates == null || candidates.isEmpty()) { @@ -27,6 +29,9 @@ public record MediaRoleSpec( != candidates.size()) { throw new IllegalArgumentException("media role candidate widths must be unique"); } + if (candidates.stream().noneMatch(candidate -> candidate.width() == defaultWidth)) { + throw new IllegalArgumentException("media role default width must be a candidate"); + } } public List selectFor(int sourceWidth, int sourceHeight) { diff --git a/src/main/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistry.java b/src/main/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistry.java index a8eed70c..2321b4c3 100644 --- a/src/main/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistry.java +++ b/src/main/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistry.java @@ -80,6 +80,7 @@ private Map parseRoleSpecs(JsonNode rolesNode) { MediaRoleSpec previous = roleSpecs.put(role, new MediaRoleSpec( requirePositiveInt(aspectRatio, "width"), requirePositiveInt(aspectRatio, "height"), + requirePositiveInt(roleNode, "defaultWidth"), requirePositiveInt(fallback, "minimumWidth"), candidates )); diff --git a/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java new file mode 100644 index 00000000..5e9db779 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java @@ -0,0 +1,338 @@ +package org.sopt.hashi.media.service; + +import java.util.ArrayList; +import java.util.Collection; +import java.util.EnumSet; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Set; +import java.util.UUID; +import java.util.function.Function; +import java.util.stream.Collectors; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImage.Candidate; +import org.sopt.hashi.media.MediaImage.Source; +import org.sopt.hashi.media.MediaImage.SourceSet; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.media.code.MediaErrorCode; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageAssetRepository.AssetImageProjection; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.ImageProcessingStatus; +import org.sopt.hashi.media.domain.ImageRenditionRepository; +import org.sopt.hashi.media.domain.ImageRenditionRepository.RenditionImageProjection; +import org.sopt.hashi.media.domain.ImageRole; +import org.sopt.hashi.media.domain.MediaCleanupStatus; +import org.sopt.hashi.media.domain.MediaOwnerType; +import org.sopt.hashi.media.domain.MediaPurpose; +import org.sopt.hashi.media.internal.spec.MediaRoleSpec; +import org.sopt.hashi.media.internal.spec.MediaSpecDefinition; +import org.sopt.hashi.media.internal.spec.MediaSpecRegistry; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.annotation.Propagation; + +@Component +@Transactional(readOnly = true) +class MediaPortImpl implements MediaPort { + + private static final EnumSet REVIEW_CLAIMABLE_STATUSES = + EnumSet.of(ImageProcessingStatus.PROCESSING, ImageProcessingStatus.READY); + + private final ImageAssetRepository imageAssetRepository; + private final ImageRenditionRepository imageRenditionRepository; + private final CurrentActorProvider currentActorProvider; + private final MediaPurposeAccessPolicy purposeAccessPolicy; + private final MediaSpecRegistry mediaSpecRegistry; + private final FileStorage fileStorage; + + MediaPortImpl(ImageAssetRepository imageAssetRepository, + ImageRenditionRepository imageRenditionRepository, + CurrentActorProvider currentActorProvider, + MediaPurposeAccessPolicy purposeAccessPolicy, + MediaSpecRegistry mediaSpecRegistry, + FileStorage fileStorage) { + this.imageAssetRepository = imageAssetRepository; + this.imageRenditionRepository = imageRenditionRepository; + this.currentActorProvider = currentActorProvider; + this.purposeAccessPolicy = purposeAccessPolicy; + this.mediaSpecRegistry = mediaSpecRegistry; + this.fileStorage = fileStorage; + } + + @Override + @Transactional(propagation = Propagation.MANDATORY) + public void reconcileBindings(Collection claims, Collection retires) { + List normalizedClaims = copyUses(claims); + List normalizedRetires = copyUses(retires); + Set allIds = requireDistinctIds(normalizedClaims, normalizedRetires); + if (allIds.isEmpty()) { + return; + } + + CurrentActor actor = currentActorProvider.currentActor(); + normalizedClaims.forEach(use -> requirePurposeAllowed(actor, use.purpose())); + normalizedRetires.forEach(use -> requirePurposeAllowed(actor, use.purpose())); + + List identities = + imageAssetRepository.findIdentitiesByPublicIdIn(allIds); + if (identities.size() != allIds.size()) { + throw new BusinessException(MediaErrorCode.ASSET_NOT_FOUND); + } + List internalIds = identities.stream() + .map(ImageAssetRepository.AssetIdentity::getId) + .sorted() + .toList(); + List assets = imageAssetRepository.findAllByIdInForUpdate(internalIds); + if (assets.size() != allIds.size()) { + throw new BusinessException(MediaErrorCode.ASSET_NOT_FOUND); + } + Map assetsById = assets.stream() + .collect(Collectors.toMap(ImageAsset::getPublicId, Function.identity())); + + MediaOwnerType ownerType = toOwnerType(actor.type()); + normalizedClaims.forEach(use -> validateClaim( + assetsById.get(use.assetId()), use.purpose(), ownerType, actor.subjectId())); + normalizedRetires.forEach(use -> validateRetire(assetsById.get(use.assetId()), use.purpose())); + + normalizedClaims.forEach(use -> assetsById.get(use.assetId()).bind()); + normalizedRetires.forEach(use -> assetsById.get(use.assetId()).retire()); + } + + @Override + public Map findImages(Collection requests) { + List normalized = copyRequests(requests); + if (normalized.isEmpty()) { + return Map.of(); + } + Set assetIds = normalized.stream() + .map(MediaImageRequest::assetId) + .collect(Collectors.toCollection(LinkedHashSet::new)); + Map assetsById = imageAssetRepository + .findImageProjectionsByPublicIdIn(assetIds).stream() + .collect(Collectors.toMap(AssetImageProjection::getPublicId, Function.identity())); + Set roles = normalized.stream() + .map(request -> ImageRole.valueOf(request.role().name())) + .collect(Collectors.toCollection(() -> EnumSet.noneOf(ImageRole.class))); + Map> renditionsByKey = + imageRenditionRepository.findActiveImageProjections(assetIds, roles).stream() + .collect(Collectors.groupingBy( + rendition -> new RenditionKey( + rendition.getAssetId(), rendition.getRole()), + LinkedHashMap::new, + Collectors.toList() + )); + + Map result = new LinkedHashMap<>(); + normalized.forEach(request -> project( + assetsById.get(request.assetId()), + renditionsByKey.getOrDefault( + new RenditionKey( + request.assetId(), + ImageRole.valueOf(request.role().name())), + List.of()), + request) + .ifPresent(image -> result.put(request, image))); + return Map.copyOf(result); + } + + private java.util.Optional project( + AssetImageProjection asset, + List renditionProjections, + MediaImageRequest request + ) { + if (asset == null + || asset.getBindingStatus() != ImageBindingStatus.BOUND + || asset.getCleanupStatus() == MediaCleanupStatus.PURGING) { + return java.util.Optional.empty(); + } + ImageProcessingStatus processingStatus = asset.getProcessingStatus(); + if (processingStatus != ImageProcessingStatus.PROCESSING + && processingStatus != ImageProcessingStatus.READY + && processingStatus != ImageProcessingStatus.FAILED) { + return java.util.Optional.empty(); + } + + Integer specVersion = projectionSpecVersion(asset); + MediaSpecDefinition definition = specVersion == null + ? null + : mediaSpecRegistry.findDefinition(specVersion).orElse(null); + ImageRole internalRole = ImageRole.valueOf(request.role().name()); + if (definition == null + || !matchesPackagedDigest(asset, definition) + || !supports(definition, asset.getPurpose(), internalRole)) { + return java.util.Optional.empty(); + } + + MediaImageStatus status = MediaImageStatus.valueOf(processingStatus.name()); + if (status != MediaImageStatus.READY) { + return java.util.Optional.of(new MediaImage( + asset.getPublicId(), request.role(), status, null, List.of())); + } + + List renditions = renditionProjections.stream() + .sorted(java.util.Comparator.comparing(RenditionImageProjection::getMimeType) + .thenComparingInt(RenditionImageProjection::getWidth)) + .toList(); + if (renditions.isEmpty()) { + return java.util.Optional.empty(); + } + + MediaRoleSpec roleSpec = definition.roleSpecs().get(internalRole); + RenditionImageProjection defaultRendition = renditions.stream() + .filter(rendition -> rendition.getWidth() == roleSpec.defaultWidth()) + .findFirst() + .orElseGet(() -> renditions.getLast()); + Source defaultSource = toSource(defaultRendition); + List sourceSets = renditions.stream() + .collect(Collectors.groupingBy( + RenditionImageProjection::getMimeType, + LinkedHashMap::new, + Collectors.toList() + )).entrySet().stream() + .map(entry -> new SourceSet( + entry.getKey(), + entry.getValue().stream().map(this::toCandidate).toList() + )) + .toList(); + return java.util.Optional.of(new MediaImage( + asset.getPublicId(), request.role(), status, defaultSource, sourceSets)); + } + + private void validateClaim(ImageAsset asset, MediaAssetPurpose expectedPurpose, + MediaOwnerType actorType, Long actorSubjectId) { + if (!asset.isOwnedBy(actorType, actorSubjectId)) { + throw new BusinessException(MediaErrorCode.ASSET_NOT_FOUND); + } + if (asset.getPurpose() != MediaPurpose.valueOf(expectedPurpose.name()) + || asset.getCleanupStatus() != MediaCleanupStatus.ACTIVE) { + throw new BusinessException(MediaErrorCode.INVALID_STATE); + } + if (asset.getBindingStatus() != ImageBindingStatus.UNBOUND) { + throw new BusinessException(MediaErrorCode.ALREADY_BOUND); + } + boolean claimable = expectedPurpose == MediaAssetPurpose.REVIEW + ? REVIEW_CLAIMABLE_STATUSES.contains(asset.getProcessingStatus()) + : asset.getProcessingStatus() == ImageProcessingStatus.READY; + if (!claimable) { + throw new BusinessException(MediaErrorCode.INVALID_STATE); + } + } + + private void validateRetire(ImageAsset asset, MediaAssetPurpose expectedPurpose) { + boolean active = asset.getCleanupStatus() == MediaCleanupStatus.ACTIVE; + boolean purgedFailure = asset.getCleanupStatus() == MediaCleanupStatus.PURGED + && asset.getProcessingStatus() == ImageProcessingStatus.FAILED + && asset.getObjectsPurgedAt() != null; + if (asset.getPurpose() != MediaPurpose.valueOf(expectedPurpose.name()) + || asset.getBindingStatus() != ImageBindingStatus.BOUND + || (!active && !purgedFailure)) { + throw new BusinessException(MediaErrorCode.INVALID_STATE); + } + } + + private Set requireDistinctIds(List claims, List retires) { + List requestedIds = new ArrayList<>(); + claims.stream().map(MediaAssetUse::assetId).forEach(requestedIds::add); + retires.stream().map(MediaAssetUse::assetId).forEach(requestedIds::add); + Set distinct = new LinkedHashSet<>(requestedIds); + if (distinct.size() != requestedIds.size()) { + throw new BusinessException(MediaErrorCode.DUPLICATE_ASSET); + } + return distinct; + } + + private List copyUses(Collection uses) { + if (uses == null) { + return List.of(); + } + if (uses.stream().anyMatch(Objects::isNull)) { + throw new BusinessException(MediaErrorCode.DUPLICATE_ASSET); + } + return List.copyOf(uses); + } + + private List copyRequests(Collection requests) { + if (requests == null || requests.isEmpty()) { + return List.of(); + } + if (requests.stream().anyMatch(Objects::isNull)) { + throw new IllegalArgumentException("media image requests must not contain null"); + } + return List.copyOf(new LinkedHashSet<>(requests)); + } + + private Integer projectionSpecVersion(AssetImageProjection asset) { + return switch (asset.getProcessingStatus()) { + case READY -> asset.getActiveSpecVersion(); + case PROCESSING -> asset.getTargetSpecVersion(); + case FAILED -> asset.getLastFailureSpecVersion(); + default -> null; + }; + } + + private boolean matchesPackagedDigest( + AssetImageProjection asset, + MediaSpecDefinition definition + ) { + return switch (asset.getProcessingStatus()) { + case READY -> Objects.equals(asset.getActiveSpecDigest(), definition.digest()); + case PROCESSING -> Objects.equals(asset.getTargetSpecDigest(), definition.digest()); + case FAILED -> true; + default -> false; + }; + } + + private boolean supports(MediaSpecDefinition definition, MediaPurpose purpose, ImageRole role) { + List roles = definition.purposeRoles().get(purpose); + return roles != null && roles.contains(role); + } + + private Source toSource(RenditionImageProjection rendition) { + return new Source( + fileStorage.resolveFileUrl(rendition.getObjectKey()), + rendition.getWidth(), + rendition.getHeight(), + rendition.getMimeType() + ); + } + + private Candidate toCandidate(RenditionImageProjection rendition) { + return new Candidate( + fileStorage.resolveFileUrl(rendition.getObjectKey()), + rendition.getWidth(), + rendition.getHeight() + ); + } + + private MediaOwnerType toOwnerType(ActorType actorType) { + return switch (actorType) { + case USER -> MediaOwnerType.USER; + case ADMIN -> MediaOwnerType.ADMIN; + case ONBOARDING -> MediaOwnerType.ONBOARDING; + }; + } + + private void requirePurposeAllowed(CurrentActor actor, MediaAssetPurpose purpose) { + if (!purposeAccessPolicy.isAllowed(actor.type(), MediaPurpose.valueOf(purpose.name()))) { + throw new BusinessException(MediaErrorCode.PURPOSE_FORBIDDEN); + } + } + + private record RenditionKey(UUID assetId, ImageRole role) { + } +} diff --git a/src/test/java/org/sopt/hashi/media/domain/ImageAssetTest.java b/src/test/java/org/sopt/hashi/media/domain/ImageAssetTest.java index c648fa5d..fc8c88e0 100644 --- a/src/test/java/org/sopt/hashi/media/domain/ImageAssetTest.java +++ b/src/test/java/org/sopt/hashi/media/domain/ImageAssetTest.java @@ -226,6 +226,27 @@ class ImageAssetTest { assertThatThrownBy(asset::expireUpload).isInstanceOf(IllegalStateException.class); } + @Test + void ACTIVE_UNBOUND_asset은_BIND할_수_있다() { + ImageAsset asset = readyAsset(); + + asset.bind(); + + assertThat(asset.getBindingStatus()).isEqualTo(ImageBindingStatus.BOUND); + assertThatThrownBy(asset::bind).isInstanceOf(IllegalStateException.class); + } + + @Test + void BOUND_asset은_RETIRED로_전이한다() { + ImageAsset asset = readyAsset(); + asset.bind(); + + asset.retire(); + + assertThat(asset.getBindingStatus()).isEqualTo(ImageBindingStatus.RETIRED); + assertThatThrownBy(asset::retire).isInstanceOf(IllegalStateException.class); + } + private ImageAsset createDirectUpload(MediaOwnerType ownerType, Long ownerId) { UUID assetId = UUID.randomUUID(); return ImageAsset.createDirectUpload( diff --git a/src/test/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistryTest.java b/src/test/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistryTest.java index a03e1443..c86d13a4 100644 --- a/src/test/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistryTest.java +++ b/src/test/java/org/sopt/hashi/media/internal/spec/MediaSpecRegistryTest.java @@ -28,6 +28,7 @@ class MediaSpecRegistryTest { .findDefinition(1) .orElseThrow(); + assertThat(spec.roleSpecs().get(ImageRole.REVIEW_PREVIEW).defaultWidth()).isEqualTo(270); assertThat(spec.expectedRenditions(MediaPurpose.REVIEW, 3024, 4032)) .containsExactly( expected(ImageRole.REVIEW_PREVIEW, 135, 135), @@ -57,6 +58,7 @@ class MediaSpecRegistryTest { assertThatThrownBy(() -> new MediaRoleSpec( 1, 1, + 100, 1, List.of( new MediaRenditionDimensions(100, 50), @@ -66,6 +68,18 @@ class MediaSpecRegistryTest { .hasMessage("media role candidate widths must be unique"); } + @Test + void default_width는_candidate에_포함되어야_한다() { + assertThatThrownBy(() -> new MediaRoleSpec( + 1, + 1, + 200, + 1, + List.of(new MediaRenditionDimensions(100, 100)) + )).isInstanceOf(IllegalArgumentException.class) + .hasMessage("media role default width must be a candidate"); + } + private MediaExpectedRendition expected(ImageRole role, int width, int height) { return new MediaExpectedRendition(role, width, height); } diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java new file mode 100644 index 00000000..c04f4c69 --- /dev/null +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -0,0 +1,440 @@ +package org.sopt.hashi.media.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.Mockito.lenient; +import static org.mockito.Mockito.mock; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import com.fasterxml.jackson.databind.ObjectMapper; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.code.MediaErrorCode; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageAssetRepository.AssetIdentity; +import org.sopt.hashi.media.domain.ImageAssetRepository.AssetImageProjection; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.ImageProcessingStatus; +import org.sopt.hashi.media.domain.ImageRenditionRepository; +import org.sopt.hashi.media.domain.ImageRenditionRepository.RenditionImageProjection; +import org.sopt.hashi.media.domain.ImageRole; +import org.sopt.hashi.media.domain.MediaCleanupStatus; +import org.sopt.hashi.media.domain.MediaOwnerType; +import org.sopt.hashi.media.domain.MediaPurpose; +import org.sopt.hashi.media.internal.spec.MediaSpecRegistry; +import org.sopt.hashi.shared.error.BusinessException; +import org.sopt.hashi.shared.storage.FileStorage; + +@ExtendWith(MockitoExtension.class) +class MediaPortImplTest { + + private static final String SPEC_DIGEST = + "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + + @Mock + private ImageAssetRepository imageAssetRepository; + + @Mock + private ImageRenditionRepository imageRenditionRepository; + + @Mock + private CurrentActorProvider currentActorProvider; + + @Mock + private MediaPurposeAccessPolicy purposeAccessPolicy; + + @Mock + private FileStorage fileStorage; + + private MediaPortImpl mediaPort; + + @BeforeEach + void setUp() { + mediaPort = new MediaPortImpl( + imageAssetRepository, + imageRenditionRepository, + currentActorProvider, + purposeAccessPolicy, + new MediaSpecRegistry(new ObjectMapper()), + fileStorage + ); + } + + @Test + void claim과_retire는_내부_PK_오름차순으로_잠근_뒤_함께_전이한다() { + UUID claimId = UUID.randomUUID(); + UUID retireId = UUID.randomUUID(); + ImageAsset claimAsset = asset( + claimId, MediaPurpose.RESTAURANT, MediaOwnerType.ADMIN, 3L, + ImageProcessingStatus.READY, ImageBindingStatus.UNBOUND, + MediaCleanupStatus.ACTIVE); + ImageAsset retireAsset = asset( + retireId, MediaPurpose.RESTAURANT_MENU, MediaOwnerType.ADMIN, 7L, + ImageProcessingStatus.READY, ImageBindingStatus.BOUND, + MediaCleanupStatus.ACTIVE); + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 3L)); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.RESTAURANT)) + .thenReturn(true); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.RESTAURANT_MENU)) + .thenReturn(true); + when(imageAssetRepository.findIdentitiesByPublicIdIn(anyCollection())) + .thenReturn(List.of(identity(20L, claimId), identity(10L, retireId))); + when(imageAssetRepository.findAllByIdInForUpdate(List.of(10L, 20L))) + .thenReturn(List.of(retireAsset, claimAsset)); + + mediaPort.reconcileBindings( + List.of(new MediaAssetUse(claimId, MediaAssetPurpose.RESTAURANT)), + List.of(new MediaAssetUse(retireId, MediaAssetPurpose.RESTAURANT_MENU)) + ); + + verify(imageAssetRepository).findAllByIdInForUpdate(List.of(10L, 20L)); + verify(claimAsset).bind(); + verify(retireAsset).retire(); + } + + @Test + void claim과_retire에_같은_asset이_있으면_조회와_변경_전에_거부한다() { + UUID assetId = UUID.randomUUID(); + MediaAssetUse use = new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT); + + assertThatThrownBy(() -> mediaPort.reconcileBindings(List.of(use), List.of(use))) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", MediaErrorCode.DUPLICATE_ASSET); + + verify(currentActorProvider, never()).currentActor(); + verify(imageAssetRepository, never()).findIdentitiesByPublicIdIn(anyCollection()); + } + + @Test + void 검증이_하나라도_실패하면_어떤_asset도_변경하지_않는다() { + UUID validId = UUID.randomUUID(); + UUID invalidId = UUID.randomUUID(); + ImageAsset valid = asset( + validId, MediaPurpose.RESTAURANT, MediaOwnerType.ADMIN, 3L, + ImageProcessingStatus.READY, ImageBindingStatus.UNBOUND, + MediaCleanupStatus.ACTIVE); + ImageAsset invalid = asset( + invalidId, MediaPurpose.RESTAURANT_MENU, MediaOwnerType.ADMIN, 3L, + ImageProcessingStatus.PROCESSING, ImageBindingStatus.UNBOUND, + MediaCleanupStatus.ACTIVE); + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 3L)); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.RESTAURANT)) + .thenReturn(true); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.RESTAURANT_MENU)) + .thenReturn(true); + when(imageAssetRepository.findIdentitiesByPublicIdIn(anyCollection())) + .thenReturn(List.of(identity(1L, validId), identity(2L, invalidId))); + when(imageAssetRepository.findAllByIdInForUpdate(List.of(1L, 2L))) + .thenReturn(List.of(valid, invalid)); + + assertThatThrownBy(() -> mediaPort.reconcileBindings( + List.of( + new MediaAssetUse(validId, MediaAssetPurpose.RESTAURANT), + new MediaAssetUse(invalidId, MediaAssetPurpose.RESTAURANT_MENU) + ), + List.of() + )).isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", MediaErrorCode.INVALID_STATE); + + verify(valid, never()).bind(); + verify(invalid, never()).bind(); + } + + @Test + void READY_projection은_defaultWidth와_모든_candidate를_반환한다() { + UUID assetId = UUID.randomUUID(); + MediaImageRequest request = + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_CARD); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of(projection( + assetId, MediaPurpose.RESTAURANT, ImageProcessingStatus.READY, + ImageBindingStatus.BOUND, MediaCleanupStatus.ACTIVE, + 1, SPEC_DIGEST, null, null, null))); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())) + .thenReturn(List.of( + rendition(assetId, ImageRole.RESTAURANT_CARD, 405, 405), + rendition(assetId, ImageRole.RESTAURANT_CARD, 135, 135), + rendition(assetId, ImageRole.RESTAURANT_CARD, 270, 270) + )); + when(fileStorage.resolveFileUrl(anyString())) + .thenAnswer(invocation -> "https://cdn.hashi.test/" + invocation.getArgument(0)); + + Map result = mediaPort.findImages(List.of(request)); + + assertThat(result).containsOnlyKeys(request); + MediaImage image = result.get(request); + assertThat(image.status()).isEqualTo(MediaImageStatus.READY); + assertThat(image.defaultSource().width()).isEqualTo(270); + assertThat(image.sourceSets()).singleElement().satisfies(sourceSet -> + assertThat(sourceSet.candidates()) + .extracting(MediaImage.Candidate::width) + .containsExactly(135, 270, 405)); + verify(imageAssetRepository).findImageProjectionsByPublicIdIn(anyCollection()); + verify(imageRenditionRepository).findActiveImageProjections( + anyCollection(), anyCollection()); + } + + @Test + void defaultWidth가_생성되지_않으면_가장_큰_실제_candidate를_기본값으로_쓴다() { + UUID assetId = UUID.randomUUID(); + MediaImageRequest request = + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_CARD); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of(projection( + assetId, MediaPurpose.RESTAURANT, ImageProcessingStatus.READY, + ImageBindingStatus.BOUND, MediaCleanupStatus.ACTIVE, + 1, SPEC_DIGEST, null, null, null))); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())) + .thenReturn(List.of( + rendition(assetId, ImageRole.RESTAURANT_CARD, 100, 100), + rendition(assetId, ImageRole.RESTAURANT_CARD, 120, 120) + )); + when(fileStorage.resolveFileUrl(anyString())) + .thenReturn("https://cdn.hashi.test/small.webp"); + + MediaImage image = mediaPort.findImages(List.of(request)).get(request); + + assertThat(image.defaultSource().width()).isEqualTo(120); + } + + @Test + void PROCESSING과_FAILED는_source없이_상태만_반환한다() { + UUID processingId = UUID.randomUUID(); + UUID failedId = UUID.randomUUID(); + MediaImageRequest processing = + new MediaImageRequest(processingId, MediaImageRole.REVIEW_PREVIEW); + MediaImageRequest failed = + new MediaImageRequest(failedId, MediaImageRole.REVIEW_DETAIL); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of( + projection( + processingId, MediaPurpose.REVIEW, + ImageProcessingStatus.PROCESSING, + ImageBindingStatus.BOUND, MediaCleanupStatus.ACTIVE, + null, null, 1, SPEC_DIGEST, null), + projection( + failedId, MediaPurpose.REVIEW, ImageProcessingStatus.FAILED, + ImageBindingStatus.BOUND, MediaCleanupStatus.PURGED, + null, null, null, null, 1) + )); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())).thenReturn(List.of()); + + Map result = + mediaPort.findImages(List.of(processing, failed)); + + assertThat(result.get(processing).status()).isEqualTo(MediaImageStatus.PROCESSING); + assertThat(result.get(failed).status()).isEqualTo(MediaImageStatus.FAILED); + assertThat(result.values()).allSatisfy(image -> { + assertThat(image.defaultSource()).isNull(); + assertThat(image.sourceSets()).isEmpty(); + }); + verify(fileStorage, never()).resolveFileUrl(anyString()); + } + + @Test + void spec_digest가_다르거나_asset이_UNBOUND이면_결과에서_제외한다() { + UUID mismatchId = UUID.randomUUID(); + UUID unboundId = UUID.randomUUID(); + MediaImageRequest mismatch = + new MediaImageRequest(mismatchId, MediaImageRole.RESTAURANT_CARD); + MediaImageRequest unbound = + new MediaImageRequest(unboundId, MediaImageRole.RESTAURANT_CARD); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of( + projection( + mismatchId, MediaPurpose.RESTAURANT, + ImageProcessingStatus.READY, + ImageBindingStatus.BOUND, MediaCleanupStatus.ACTIVE, + 1, "0".repeat(64), null, null, null), + projection( + unboundId, MediaPurpose.RESTAURANT, + ImageProcessingStatus.READY, + ImageBindingStatus.UNBOUND, MediaCleanupStatus.ACTIVE, + 1, SPEC_DIGEST, null, null, null) + )); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())).thenReturn(List.of()); + + assertThat(mediaPort.findImages(List.of(mismatch, unbound))).isEmpty(); + } + + @Test + void 공개_enum은_내부_manifest_vocabulary와_동일하다() { + assertThat(MediaAssetPurpose.values()) + .extracting(Enum::name) + .containsExactlyElementsOf( + java.util.Arrays.stream(MediaPurpose.values()).map(Enum::name).toList()); + assertThat(MediaImageRole.values()) + .extracting(Enum::name) + .containsExactlyElementsOf( + java.util.Arrays.stream(ImageRole.values()).map(Enum::name).toList()); + } + + private ImageAsset asset( + UUID publicId, + MediaPurpose purpose, + MediaOwnerType ownerType, + Long ownerSubjectId, + ImageProcessingStatus processingStatus, + ImageBindingStatus bindingStatus, + MediaCleanupStatus cleanupStatus + ) { + ImageAsset asset = mock(ImageAsset.class); + lenient().when(asset.getPublicId()).thenReturn(publicId); + lenient().when(asset.getPurpose()).thenReturn(purpose); + lenient().when(asset.isOwnedBy(ownerType, ownerSubjectId)).thenReturn(true); + lenient().when(asset.getProcessingStatus()).thenReturn(processingStatus); + lenient().when(asset.getBindingStatus()).thenReturn(bindingStatus); + lenient().when(asset.getCleanupStatus()).thenReturn(cleanupStatus); + return asset; + } + + private AssetIdentity identity(Long id, UUID publicId) { + return new TestAssetIdentity(id, publicId); + } + + private AssetImageProjection projection( + UUID publicId, + MediaPurpose purpose, + ImageProcessingStatus status, + ImageBindingStatus bindingStatus, + MediaCleanupStatus cleanupStatus, + Integer activeSpecVersion, + String activeSpecDigest, + Integer targetSpecVersion, + String targetSpecDigest, + Integer lastFailureSpecVersion + ) { + return new TestAssetImageProjection( + publicId, purpose, status, bindingStatus, cleanupStatus, + activeSpecVersion, activeSpecDigest, + targetSpecVersion, targetSpecDigest, lastFailureSpecVersion); + } + + private RenditionImageProjection rendition( + UUID assetId, + ImageRole role, + int width, + int height + ) { + return new TestRenditionImageProjection( + assetId, + role, + "image/webp", + width, + height, + "media/renditions/%s/v1/%s/%d.webp".formatted( + assetId, role.name().toLowerCase(), width) + ); + } + + private record TestAssetIdentity(Long id, UUID publicId) implements AssetIdentity { + + @Override + public Long getId() { + return id; + } + + @Override + public UUID getPublicId() { + return publicId; + } + } + + private record TestAssetImageProjection( + UUID publicId, + MediaPurpose purpose, + ImageProcessingStatus processingStatus, + ImageBindingStatus bindingStatus, + MediaCleanupStatus cleanupStatus, + Integer activeSpecVersion, + String activeSpecDigest, + Integer targetSpecVersion, + String targetSpecDigest, + Integer lastFailureSpecVersion + ) implements AssetImageProjection { + + @Override + public UUID getPublicId() { return publicId; } + + @Override + public MediaPurpose getPurpose() { return purpose; } + + @Override + public ImageProcessingStatus getProcessingStatus() { return processingStatus; } + + @Override + public ImageBindingStatus getBindingStatus() { return bindingStatus; } + + @Override + public MediaCleanupStatus getCleanupStatus() { return cleanupStatus; } + + @Override + public Integer getActiveSpecVersion() { return activeSpecVersion; } + + @Override + public String getActiveSpecDigest() { return activeSpecDigest; } + + @Override + public Integer getTargetSpecVersion() { return targetSpecVersion; } + + @Override + public String getTargetSpecDigest() { return targetSpecDigest; } + + @Override + public Integer getLastFailureSpecVersion() { return lastFailureSpecVersion; } + } + + private record TestRenditionImageProjection( + UUID assetId, + ImageRole role, + String mimeType, + int width, + int height, + String objectKey + ) implements RenditionImageProjection { + + @Override + public UUID getAssetId() { return assetId; } + + @Override + public ImageRole getRole() { return role; } + + @Override + public String getMimeType() { return mimeType; } + + @Override + public int getWidth() { return width; } + + @Override + public int getHeight() { return height; } + + @Override + public String getObjectKey() { return objectKey; } + } +} diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java new file mode 100644 index 00000000..76166b54 --- /dev/null +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -0,0 +1,197 @@ +package org.sopt.hashi.media.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.Mockito.when; + +import jakarta.persistence.EntityManagerFactory; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import org.hibernate.SessionFactory; +import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.ImageFormat; +import org.sopt.hashi.media.domain.ImageRole; +import org.sopt.hashi.media.domain.MediaOwnerType; +import org.sopt.hashi.media.domain.MediaPurpose; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.IllegalTransactionStateException; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "spring.jpa.properties.hibernate.generate_statistics=true", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test" +}) +class MediaPortIntegrationTest { + + private static final String SPEC_DIGEST = + "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + private static final String SOURCE_CHECKSUM = + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi") + .withUsername("hashi") + .withPassword("hashi"); + + @Autowired + private MediaPort mediaPort; + + @Autowired + private ImageAssetRepository imageAssetRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Autowired + private TransactionTemplate transactionTemplate; + + @Autowired + private EntityManagerFactory entityManagerFactory; + + @MockitoBean + private CurrentActorProvider currentActorProvider; + + @BeforeEach + void setUp() { + jdbcTemplate.update("DELETE FROM image_rendition"); + jdbcTemplate.update("DELETE FROM image_asset"); + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 1L)); + } + + @Test + void binding은_외부_transaction을_필수로_하고_claim과_retire를_함께_commit한다() { + ImageAsset claim = readyAsset(MediaPurpose.RESTAURANT, false); + ImageAsset retire = readyAsset(MediaPurpose.RESTAURANT_MENU, true); + transactionTemplate.executeWithoutResult(status -> + imageAssetRepository.saveAll(List.of(claim, retire))); + + assertThatThrownBy(() -> mediaPort.reconcileBindings( + List.of(new MediaAssetUse( + claim.getPublicId(), MediaAssetPurpose.RESTAURANT)), + List.of(new MediaAssetUse( + retire.getPublicId(), MediaAssetPurpose.RESTAURANT_MENU)) + )).isInstanceOf(IllegalTransactionStateException.class); + + transactionTemplate.executeWithoutResult(status -> mediaPort.reconcileBindings( + List.of(new MediaAssetUse( + claim.getPublicId(), MediaAssetPurpose.RESTAURANT)), + List.of(new MediaAssetUse( + retire.getPublicId(), MediaAssetPurpose.RESTAURANT_MENU)) + )); + + assertThat(imageAssetRepository.findByPublicId(claim.getPublicId()).orElseThrow() + .getBindingStatus()).isEqualTo(ImageBindingStatus.BOUND); + assertThat(imageAssetRepository.findByPublicId(retire.getPublicId()).orElseThrow() + .getBindingStatus()).isEqualTo(ImageBindingStatus.RETIRED); + } + + @Test + void projection_SQL은_asset_개수와_무관하게_두_번이다() { + long oneAssetQueries = projectionQueryCount(1); + jdbcTemplate.update("DELETE FROM image_rendition"); + jdbcTemplate.update("DELETE FROM image_asset"); + long manyAssetQueries = projectionQueryCount(8); + + assertThat(oneAssetQueries).isEqualTo(2L); + assertThat(manyAssetQueries).isEqualTo(2L); + } + + private long projectionQueryCount(int count) { + List assets = new ArrayList<>(); + for (int index = 0; index < count; index++) { + assets.add(readyAsset(MediaPurpose.RESTAURANT, true)); + } + transactionTemplate.executeWithoutResult(status -> imageAssetRepository.saveAll(assets)); + List requests = assets.stream() + .map(asset -> new MediaImageRequest( + asset.getPublicId(), MediaImageRole.RESTAURANT_CARD)) + .toList(); + Statistics statistics = entityManagerFactory.unwrap(SessionFactory.class).getStatistics(); + statistics.clear(); + + Map result = mediaPort.findImages(requests); + + assertThat(result).hasSize(count); + assertThat(result.values()).allSatisfy(image -> + assertThat(image.defaultSource().width()).isEqualTo(270)); + return statistics.getPrepareStatementCount(); + } + + private ImageAsset readyAsset(MediaPurpose purpose, boolean bound) { + UUID assetId = UUID.randomUUID(); + ImageAsset asset = ImageAsset.createDirectUpload( + assetId, + purpose, + MediaOwnerType.ADMIN, + 1L, + "media/originals/%s/original".formatted(assetId), + "image/jpeg", + 1024L, + LocalDateTime.now().plusMinutes(5) + ); + UUID jobId = UUID.randomUUID(); + asset.beginInitialProcessing( + "version-1", "\"etag-1\"", 1, SPEC_DIGEST, jobId, LocalDateTime.now()); + if (purpose == MediaPurpose.RESTAURANT) { + addRendition(asset, jobId, ImageRole.RESTAURANT_CARD, 135); + addRendition(asset, jobId, ImageRole.RESTAURANT_CARD, 270); + addRendition(asset, jobId, ImageRole.RESTAURANT_CARD, 405); + } else { + addRendition(asset, jobId, ImageRole.MENU_LIST, 200); + } + asset.completeCurrentProcessing( + jobId, 1, SPEC_DIGEST, "image/jpeg", 1024L, 3024, 4032, + SOURCE_CHECKSUM); + if (bound) { + asset.bind(); + } + return asset; + } + + private void addRendition(ImageAsset asset, UUID jobId, ImageRole role, int width) { + asset.addRendition( + jobId, + 1, + SPEC_DIGEST, + role, + ImageFormat.WEBP, + width, + width, + 100L, + "media/renditions/%s/v1/%s/%d.webp".formatted( + asset.getPublicId(), role.name().toLowerCase(), width) + ); + } +} From f2a82ebb17c09cf99c0022e4b50095fa03675834 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 27 Aug 2026 21:35:36 +0900 Subject: [PATCH 02/26] =?UTF-8?q?feat(restaurant):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20asset=20=EC=A0=80=EC=9E=A5=20=EC=8A=A4=ED=82=A4?= =?UTF-8?q?=EB=A7=88=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restaurant/domain/RestaurantImage.java | 56 ++++++- .../restaurant/domain/RestaurantMenu.java | 38 ++++- ...link_restaurant_images_to_media_assets.sql | 36 +++++ .../domain/RestaurantImageMediaTest.java | 62 +++++++ .../RestaurantMediaSchemaValidationTest.java | 152 ++++++++++++++++++ 5 files changed, 335 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql create mode 100644 src/test/java/org/sopt/hashi/restaurant/domain/RestaurantImageMediaTest.java create mode 100644 src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantImage.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantImage.java index daaddf48..02900022 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantImage.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantImage.java @@ -9,14 +9,25 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; +import java.util.Objects; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import org.sopt.hashi.BaseTimeEntity; @Getter @Entity -@Table(name = "restaurant_image") +@Table( + name = "restaurant_image", + uniqueConstraints = @UniqueConstraint( + name = "uq_restaurant_image_asset_id", + columnNames = "image_asset_id" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RestaurantImage extends BaseTimeEntity { @@ -28,19 +39,54 @@ public class RestaurantImage extends BaseTimeEntity { @JoinColumn(name = "restaurant_id", nullable = false) private Restaurant restaurant; - @Column(name = "file_key", length = 500, nullable = false) + @Column(name = "file_key", length = 500) private String fileKey; + @JdbcTypeCode(SqlTypes.CHAR) + @Column(name = "image_asset_id", length = 36) + private UUID imageAssetId; + @Column(name = "display_order", nullable = false) private int displayOrder; - private RestaurantImage(String fileKey, int displayOrder) { + private RestaurantImage(String fileKey, UUID imageAssetId, int displayOrder) { + if (fileKey != null && fileKey.isBlank()) { + throw new IllegalArgumentException("restaurant image fileKey must not be blank"); + } + if (fileKey == null && imageAssetId == null) { + throw new IllegalArgumentException("restaurant image source is required"); + } this.fileKey = fileKey; - this.displayOrder = displayOrder; + this.imageAssetId = imageAssetId; + setDisplayOrder(displayOrder); + } + + public static RestaurantImage createLegacy(String fileKey, int displayOrder) { + return new RestaurantImage(fileKey, null, displayOrder); + } + + public static RestaurantImage createAsset(UUID imageAssetId, int displayOrder) { + return new RestaurantImage(null, Objects.requireNonNull(imageAssetId), displayOrder); } + public static RestaurantImage createBackfilled( + String fileKey, + UUID imageAssetId, + int displayOrder + ) { + return new RestaurantImage(fileKey, Objects.requireNonNull(imageAssetId), displayOrder); + } + + /** legacy 호출부를 점진 전환하는 동안 유지하는 생성 함수. */ public static RestaurantImage create(String fileKey, int displayOrder) { - return new RestaurantImage(fileKey, displayOrder); + return createLegacy(fileKey, displayOrder); + } + + public void setDisplayOrder(int displayOrder) { + if (displayOrder < 1) { + throw new IllegalArgumentException("displayOrder must be positive"); + } + this.displayOrder = displayOrder; } void assignRestaurant(Restaurant restaurant) { diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java index 04047bc2..dc69ebe6 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java @@ -11,15 +11,25 @@ import jakarta.persistence.JoinColumn; import jakarta.persistence.ManyToOne; import jakarta.persistence.Table; +import jakarta.persistence.UniqueConstraint; import java.math.BigDecimal; +import java.util.UUID; import lombok.AccessLevel; import lombok.Getter; import lombok.NoArgsConstructor; +import org.hibernate.annotations.JdbcTypeCode; +import org.hibernate.type.SqlTypes; import org.sopt.hashi.BaseTimeEntity; @Getter @Entity -@Table(name = "restaurant_menu") +@Table( + name = "restaurant_menu", + uniqueConstraints = @UniqueConstraint( + name = "uq_restaurant_menu_asset_id", + columnNames = "image_asset_id" + ) +) @NoArgsConstructor(access = AccessLevel.PROTECTED) public class RestaurantMenu extends BaseTimeEntity { @@ -40,6 +50,10 @@ public class RestaurantMenu extends BaseTimeEntity { @Column(name = "image_key", length = 500) private String imageKey; + @JdbcTypeCode(SqlTypes.CHAR) + @Column(name = "image_asset_id", length = 36) + private UUID imageAssetId; + @Enumerated(EnumType.STRING) @Column(name = "price_currency", length = 3) private PriceCurrency priceCurrency; @@ -50,26 +64,42 @@ public class RestaurantMenu extends BaseTimeEntity { @Column(name = "is_main", nullable = false) private boolean main; - private RestaurantMenu(String name, String description, String imageKey, + private RestaurantMenu(String name, String description, String imageKey, UUID imageAssetId, PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { this.name = name; this.description = description; this.imageKey = imageKey; + this.imageAssetId = imageAssetId; this.priceCurrency = priceCurrency; this.priceAmount = priceAmount; this.main = main; } public static RestaurantMenu create(String name, String description, String imageKey, - PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { - return new RestaurantMenu(name, description, imageKey, priceCurrency, priceAmount, main); + PriceCurrency priceCurrency, BigDecimal priceAmount, + boolean main) { + return new RestaurantMenu( + name, description, imageKey, null, priceCurrency, priceAmount, main); + } + + public static RestaurantMenu createWithAsset(String name, String description, UUID imageAssetId, + PriceCurrency priceCurrency, BigDecimal priceAmount, + boolean main) { + return new RestaurantMenu(name, description, null, java.util.Objects.requireNonNull(imageAssetId), + priceCurrency, priceAmount, main); } public void update(String name, String description, String imageKey, PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { + update(name, description, imageKey, null, priceCurrency, priceAmount, main); + } + + public void update(String name, String description, String imageKey, UUID imageAssetId, + PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { this.name = name; this.description = description; this.imageKey = imageKey; + this.imageAssetId = imageAssetId; this.priceCurrency = priceCurrency; this.priceAmount = priceAmount; this.main = main; diff --git a/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql b/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql new file mode 100644 index 00000000..ffa38c8d --- /dev/null +++ b/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql @@ -0,0 +1,36 @@ +CREATE TEMPORARY TABLE v18_restaurant_media_guard ( + violation VARCHAR(100) NOT NULL, + valid TINYINT NOT NULL, + CONSTRAINT ck_v18_restaurant_media_guard CHECK (valid = 1) +); + +INSERT INTO v18_restaurant_media_guard (violation, valid) +SELECT 'restaurant_image.file_key is blank', 0 +WHERE EXISTS ( + SELECT 1 + FROM restaurant_image + WHERE CHAR_LENGTH(TRIM(file_key)) = 0 +); + +DROP TEMPORARY TABLE v18_restaurant_media_guard; + +ALTER TABLE restaurant_image + MODIFY COLUMN file_key VARCHAR(500) NULL, + ADD COLUMN image_asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER file_key, + ADD CONSTRAINT ck_restaurant_image_source CHECK ( + (file_key IS NULL OR CHAR_LENGTH(TRIM(file_key)) > 0) + AND (file_key IS NOT NULL OR image_asset_id IS NOT NULL) + ), + ADD CONSTRAINT ck_restaurant_image_asset_id CHECK ( + image_asset_id IS NULL + OR image_asset_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ), + ADD UNIQUE INDEX uq_restaurant_image_asset_id (image_asset_id); + +ALTER TABLE restaurant_menu + ADD COLUMN image_asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER image_key, + ADD CONSTRAINT ck_restaurant_menu_asset_id CHECK ( + image_asset_id IS NULL + OR image_asset_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ), + ADD UNIQUE INDEX uq_restaurant_menu_asset_id (image_asset_id); diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantImageMediaTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantImageMediaTest.java new file mode 100644 index 00000000..879d0d56 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantImageMediaTest.java @@ -0,0 +1,62 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.UUID; +import org.junit.jupiter.api.Test; + +class RestaurantImageMediaTest { + + @Test + void 식당_이미지는_legacy_asset_backfill_source를_각각_표현한다() { + UUID assetId = UUID.randomUUID(); + + RestaurantImage legacy = RestaurantImage.createLegacy("restaurants/1.jpg", 1); + RestaurantImage asset = RestaurantImage.createAsset(assetId, 2); + RestaurantImage backfilled = + RestaurantImage.createBackfilled("restaurants/3.jpg", assetId, 3); + + assertThat(legacy.getFileKey()).isEqualTo("restaurants/1.jpg"); + assertThat(legacy.getImageAssetId()).isNull(); + assertThat(asset.getFileKey()).isNull(); + assertThat(asset.getImageAssetId()).isEqualTo(assetId); + assertThat(backfilled.getFileKey()).isEqualTo("restaurants/3.jpg"); + assertThat(backfilled.getImageAssetId()).isEqualTo(assetId); + } + + @Test + void 식당_이미지는_source와_양수_순서가_필수다() { + assertThatThrownBy(() -> RestaurantImage.createLegacy(" ", 1)) + .isInstanceOf(IllegalArgumentException.class); + assertThatThrownBy(() -> RestaurantImage.createAsset(null, 1)) + .isInstanceOf(NullPointerException.class); + assertThatThrownBy(() -> RestaurantImage.createLegacy("restaurants/1.jpg", 0)) + .isInstanceOf(IllegalArgumentException.class); + } + + @Test + void 메뉴는_legacy_asset_또는_이미지_없음을_표현한다() { + UUID assetId = UUID.randomUUID(); + + RestaurantMenu legacy = menu("menu/legacy.jpg"); + RestaurantMenu asset = RestaurantMenu.createWithAsset( + "asset 메뉴", "설명", assetId, + PriceCurrency.JPY, BigDecimal.valueOf(1_000), false); + RestaurantMenu noImage = menu(null); + + assertThat(legacy.getImageKey()).isEqualTo("menu/legacy.jpg"); + assertThat(legacy.getImageAssetId()).isNull(); + assertThat(asset.getImageKey()).isNull(); + assertThat(asset.getImageAssetId()).isEqualTo(assetId); + assertThat(noImage.getImageKey()).isNull(); + assertThat(noImage.getImageAssetId()).isNull(); + } + + private RestaurantMenu menu(String imageKey) { + return RestaurantMenu.create( + "메뉴", "설명", imageKey, + PriceCurrency.JPY, BigDecimal.valueOf(1_000), false); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java new file mode 100644 index 00000000..d860a2e4 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java @@ -0,0 +1,152 @@ +package org.sopt.hashi.restaurant.domain; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +import java.math.BigDecimal; +import java.util.Map; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test" +}) +@Transactional +class RestaurantMediaSchemaValidationTest { + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi") + .withUsername("hashi") + .withPassword("hashi"); + + @Autowired + private RestaurantRepository restaurantRepository; + + @Autowired + private JdbcTemplate jdbcTemplate; + + @Test + void asset_ID_컬럼은_모듈_FK없이_ASCII_UUID와_local_unique를_사용한다() { + assertAssetColumn("restaurant_image", "uq_restaurant_image_asset_id"); + assertAssetColumn("restaurant_menu", "uq_restaurant_menu_asset_id"); + + Integer mediaForeignKeys = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.key_column_usage + WHERE constraint_schema = DATABASE() + AND table_name IN ('restaurant_image', 'restaurant_menu') + AND referenced_table_name = 'image_asset' + """, Integer.class); + assertThat(mediaForeignKeys).isZero(); + } + + @Test + void 식당_이미지는_source가_필수이고_메뉴는_이미지_없음을_허용한다() { + Restaurant restaurant = restaurantRepository.saveAndFlush(createRestaurant()); + UUID restaurantAssetId = UUID.randomUUID(); + UUID menuAssetId = UUID.randomUUID(); + + assertThat(insertRestaurantImage( + restaurant.getId(), null, restaurantAssetId, 1)).isEqualTo(1); + assertThatThrownBy(() -> insertRestaurantImage( + restaurant.getId(), null, restaurantAssetId, 2)) + .isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> insertRestaurantImage( + restaurant.getId(), " ", UUID.randomUUID(), 2)) + .isInstanceOf(DataAccessException.class); + assertThatThrownBy(() -> insertRestaurantImage( + restaurant.getId(), null, null, 2)) + .isInstanceOf(DataAccessException.class); + + assertThat(insertMenu(restaurant.getId(), "이미지 없음 1", null)).isEqualTo(1); + assertThat(insertMenu(restaurant.getId(), "이미지 없음 2", null)).isEqualTo(1); + assertThat(insertMenu(restaurant.getId(), "asset 메뉴", menuAssetId)).isEqualTo(1); + assertThatThrownBy(() -> insertMenu( + restaurant.getId(), "asset 중복 메뉴", menuAssetId)) + .isInstanceOf(DataAccessException.class); + } + + private void assertAssetColumn(String tableName, String indexName) { + Map column = jdbcTemplate.queryForMap(""" + SELECT character_maximum_length, + character_set_name, + collation_name, + is_nullable + FROM information_schema.columns + WHERE table_schema = DATABASE() + AND table_name = ? + AND column_name = 'image_asset_id' + """, tableName); + Integer uniqueIndexColumns = jdbcTemplate.queryForObject(""" + SELECT COUNT(*) + FROM information_schema.statistics + WHERE table_schema = DATABASE() + AND table_name = ? + AND index_name = ? + AND non_unique = 0 + AND column_name = 'image_asset_id' + """, Integer.class, tableName, indexName); + + assertThat(column.get("character_maximum_length")).isEqualTo(36L); + assertThat(column.get("character_set_name")).isEqualTo("ascii"); + assertThat(column.get("collation_name")).isEqualTo("ascii_bin"); + assertThat(column.get("is_nullable")).isEqualTo("YES"); + assertThat(uniqueIndexColumns).isEqualTo(1); + } + + private int insertRestaurantImage( + Long restaurantId, + String fileKey, + UUID assetId, + int displayOrder + ) { + return jdbcTemplate.update(""" + INSERT INTO restaurant_image ( + restaurant_id, file_key, image_asset_id, display_order, + created_at, updated_at + ) VALUES (?, ?, ?, ?, CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)) + """, restaurantId, fileKey, assetId == null ? null : assetId.toString(), displayOrder); + } + + private int insertMenu(Long restaurantId, String name, UUID assetId) { + return jdbcTemplate.update(""" + INSERT INTO restaurant_menu ( + restaurant_id, name, description, image_key, image_asset_id, + price_currency, price_amount, is_main, created_at, updated_at + ) VALUES (?, ?, '설명', NULL, ?, 'JPY', 1000, FALSE, + CURRENT_TIMESTAMP(6), CURRENT_TIMESTAMP(6)) + """, restaurantId, name, assetId == null ? null : assetId.toString()); + } + + private Restaurant createRestaurant() { + return Restaurant.create( + "스키마 검증 식당", + "schema restaurant", + "식당 소개", + "식당 상세 설명", + "도쿄도 신주쿠구", + "도쿄", + RestaurantGenre.SUSHI, + "초밥", + PriceCurrency.JPY, + BigDecimal.valueOf(1_000), + BigDecimal.valueOf(3_000) + ); + } +} From a45c7d73b7363f11f8a090d773031130efb82d81 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 27 Aug 2026 21:57:56 +0900 Subject: [PATCH 03/26] =?UTF-8?q?feat(restaurant):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20asset=20=EC=97=B0=EA=B2=B0=20=EC=93=B0=EA=B8=B0=20?= =?UTF-8?q?=EA=B2=BD=EB=A1=9C=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/CreateRestaurantRequest.java | 57 +- .../admin/dto/UpdateRestaurantRequest.java | 69 +++ .../admin/service/AdminRestaurantService.java | 22 +- .../restaurant/AdminRestaurantCommand.java | 48 +- .../hashi/restaurant/domain/Restaurant.java | 17 + .../domain/RestaurantRepository.java | 8 +- .../restaurant/service/RestaurantService.java | 530 ++++++++++++++++-- .../dto/RestaurantRequestValidationTest.java | 94 +++- ...aurantMediaTransactionIntegrationTest.java | 156 ++++++ .../RestaurantServiceIntegrationTest.java | 49 ++ .../service/RestaurantServiceTest.java | 242 +++++++- 11 files changed, 1220 insertions(+), 72 deletions(-) create mode 100644 src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java diff --git a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java index 0d78d1bf..bbae69d4 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java @@ -1,7 +1,9 @@ package org.sopt.hashi.admin.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -11,6 +13,7 @@ import java.time.DayOfWeek; import java.time.LocalTime; import java.util.List; +import java.util.UUID; /** * 어드민 식당 등록 요청. imageKeys·메뉴 imageKey는 presigned URL로 업로드 완료된 S3 object key다. @@ -51,10 +54,12 @@ public record CreateRestaurantRequest( @NotNull(message = "최대 가격은 필수입니다") @PositiveOrZero(message = "최대 가격은 0 이상입니다") BigDecimal maxPrice, @Schema(description = "식당 이미지 S3 key 목록", example = "[\"restaurants/a1b2c3-1.jpg\"]") - @NotNull(message = "식당 이미지는 필수입니다") @Size(min = 1, message = "식당 이미지는 최소 1개 이상 필요합니다") List<@NotBlank(message = "이미지 키는 비어 있을 수 없습니다") @Size(max = 500, message = "이미지 키는 500자 이내입니다") String> imageKeys, + @Schema(description = "식당 이미지 asset ID 목록") + @Size(min = 1, message = "식당 이미지 asset은 최소 1개 이상 필요합니다") + List<@NotNull(message = "이미지 asset ID는 null일 수 없습니다") UUID> imageAssetIds, List<@NotNull(message = "메뉴 항목은 null일 수 없습니다") @Valid MenuRequest> menus, @Schema(description = "해시태그 목록", example = "[\"현지인맛집\"]") @NotNull(message = "해시태그는 필수입니다") @@ -67,6 +72,37 @@ public record CreateRestaurantRequest( @Size(min = 7, max = 7, message = "영업시간은 모든 요일(7개)을 포함해야 합니다") List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours) { + /** legacy 생성 호출부와 테스트를 신규 필드 활성화 전까지 호환한다. */ + public CreateRestaurantRequest( + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + List imageKeys, + List menus, + List hashtags, + List curationTypes, + List businessHours + ) { + this( + name, localName, summary, description, address, area, genre, foodCategory, + priceCurrency, minPrice, maxPrice, imageKeys, null, menus, hashtags, + curationTypes, businessHours); + } + + @AssertTrue(message = "식당 이미지는 imageKeys 또는 imageAssetIds 중 하나만 필요합니다") + @JsonIgnore + public boolean isImageSourceValid() { + return (imageKeys == null) != (imageAssetIds == null); + } + /** 메뉴 항목 — 목록 전체가 함께 저장되므로 각 항목은 완전한 값으로 받는다. */ public record MenuRequest( @Schema(description = "메뉴명", example = "특선 모둠 야키니쿠") @@ -78,6 +114,8 @@ public record MenuRequest( @Schema(description = "메뉴 이미지 S3 key(선택)", example = "restaurant-menus/a1b2c3-menu.jpg") @Pattern(regexp = ".*\\S.*", message = "메뉴 이미지 키는 공백일 수 없습니다") @Size(max = 500, message = "메뉴 이미지 키는 500자 이내입니다") String imageKey, + @Schema(description = "메뉴 이미지 asset ID(선택)") + UUID imageAssetId, @Schema(description = "통화 코드", example = "JPY") @NotBlank(message = "통화는 필수입니다") @Size(min = 3, max = 3, message = "통화는 3자리 코드여야 합니다") String priceCurrency, @@ -86,6 +124,23 @@ public record MenuRequest( @PositiveOrZero(message = "가격은 0 이상입니다") BigDecimal priceAmount, @Schema(description = "대표 메뉴 여부", example = "true") @NotNull(message = "대표 메뉴 여부는 필수입니다") Boolean main) { + + public MenuRequest( + String name, + String description, + String imageKey, + String priceCurrency, + BigDecimal priceAmount, + Boolean main + ) { + this(name, description, imageKey, null, priceCurrency, priceAmount, main); + } + + @AssertTrue(message = "메뉴 이미지는 imageKey와 imageAssetId를 함께 사용할 수 없습니다") + @JsonIgnore + public boolean isImageSourceValid() { + return imageKey == null || imageAssetId == null; + } } /** diff --git a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java index 2a656a91..859795c0 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java @@ -1,7 +1,9 @@ package org.sopt.hashi.admin.dto; +import com.fasterxml.jackson.annotation.JsonIgnore; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; +import jakarta.validation.constraints.AssertTrue; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; import jakarta.validation.constraints.Pattern; @@ -12,6 +14,7 @@ import java.time.DayOfWeek; import java.time.LocalTime; import java.util.List; +import java.util.UUID; /** * 어드민 식당 부분 수정(PATCH) 요청 — null 필드는 변경하지 않는다(값 비우기 불가). @@ -48,6 +51,9 @@ public record UpdateRestaurantRequest( @Size(min = 1, message = "식당 이미지는 최소 1개 이상 필요합니다") List<@NotBlank(message = "이미지 키는 비어 있을 수 없습니다") @Size(max = 500, message = "이미지 키는 500자 이내입니다") String> imageKeys, + @Schema(description = "식당 이미지 ordered wrapper(선택) — 보내면 전체 교체") + @Size(min = 1, message = "식당 이미지는 최소 1개 이상 필요합니다") + List<@NotNull(message = "식당 이미지 항목은 null일 수 없습니다") @Valid ImageRequest> images, List<@NotNull(message = "메뉴 항목은 null일 수 없습니다") @Valid MenuRequest> menus, @Schema(description = "해시태그 목록(선택) — 보내면 전체 교체, 최소 1개", example = "[\"오마카세\"]") @Size(min = 1, message = "해시태그는 최소 1개 이상 필요합니다") @@ -58,6 +64,49 @@ public record UpdateRestaurantRequest( @Size(min = 7, max = 7, message = "영업시간은 모든 요일(7개)을 포함해야 합니다") List<@NotNull(message = "영업시간 항목은 null일 수 없습니다") @Valid BusinessHourRequest> businessHours) { + /** legacy 수정 호출부와 테스트를 신규 필드 활성화 전까지 호환한다. */ + public UpdateRestaurantRequest( + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + List imageKeys, + List menus, + List hashtags, + List curationTypes, + List businessHours + ) { + this( + name, localName, summary, description, address, area, genre, foodCategory, + priceCurrency, minPrice, maxPrice, imageKeys, null, menus, hashtags, + curationTypes, businessHours); + } + + @AssertTrue(message = "imageKeys와 images는 함께 사용할 수 없습니다") + @JsonIgnore + public boolean isImageCollectionSourceValid() { + return imageKeys == null || images == null; + } + + public record ImageRequest( + @Positive(message = "식당 이미지 ID는 1 이상이어야 합니다") Long restaurantImageId, + UUID imageAssetId + ) { + + @AssertTrue(message = "restaurantImageId 또는 imageAssetId 중 하나만 필요합니다") + @JsonIgnore + public boolean isReferenceValid() { + return (restaurantImageId == null) != (imageAssetId == null); + } + } + /** 메뉴 항목 — 기존 메뉴는 menuId를, 신규 메뉴는 null을 전달한다. */ public record MenuRequest( @Schema(description = "기존 메뉴 ID(신규 메뉴는 생략)", example = "10") @@ -71,6 +120,8 @@ public record MenuRequest( @Schema(description = "메뉴 이미지 S3 key(선택)", example = "restaurant-menus/a1b2c3-menu.jpg") @Pattern(regexp = ".*\\S.*", message = "메뉴 이미지 키는 공백일 수 없습니다") @Size(max = 500, message = "메뉴 이미지 키는 500자 이내입니다") String imageKey, + @Schema(description = "메뉴 이미지 asset ID(선택)") + UUID imageAssetId, @Schema(description = "통화 코드", example = "JPY") @NotBlank(message = "통화는 필수입니다") @Size(min = 3, max = 3, message = "통화는 3자리 코드여야 합니다") String priceCurrency, @@ -79,6 +130,24 @@ public record MenuRequest( @PositiveOrZero(message = "가격은 0 이상입니다") BigDecimal priceAmount, @Schema(description = "대표 메뉴 여부", example = "true") @NotNull(message = "대표 메뉴 여부는 필수입니다") Boolean main) { + + public MenuRequest( + Long menuId, + String name, + String description, + String imageKey, + String priceCurrency, + BigDecimal priceAmount, + Boolean main + ) { + this(menuId, name, description, imageKey, null, priceCurrency, priceAmount, main); + } + + @AssertTrue(message = "메뉴 이미지는 imageKey와 imageAssetId를 함께 사용할 수 없습니다") + @JsonIgnore + public boolean isImageSourceValid() { + return imageKey == null || imageAssetId == null; + } } /** diff --git a/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java b/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java index b99402db..d3f7b9b9 100644 --- a/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java +++ b/src/main/java/org/sopt/hashi/admin/service/AdminRestaurantService.java @@ -6,6 +6,7 @@ import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.RestaurantPort; import org.springframework.stereotype.Service; @@ -49,6 +50,8 @@ private AdminRestaurantCommand toCommand(CreateRestaurantRequest request) { request.minPrice(), request.maxPrice(), request.imageKeys(), + request.imageAssetIds(), + null, toMenuCommands(request.menus()), request.hashtags(), request.curationTypes(), @@ -69,6 +72,8 @@ private AdminRestaurantCommand toCommand(UpdateRestaurantRequest request) { request.minPrice(), request.maxPrice(), request.imageKeys(), + null, + toImageCommands(request.images()), toUpdateMenuCommands(request.menus()), request.hashtags(), request.curationTypes(), @@ -80,7 +85,8 @@ private List toMenuCommands(List new MenuCommand(menu.name(), menu.description(), menu.imageKey(), + .map(menu -> new MenuCommand(null, menu.name(), menu.description(), menu.imageKey(), + menu.imageAssetId(), menu.priceCurrency(), menu.priceAmount(), menu.main())) .toList(); } @@ -90,11 +96,23 @@ private List toUpdateMenuCommands(List new MenuCommand(menu.menuId(), menu.name(), menu.description(), menu.imageKey(), + .map(menu -> new MenuCommand( + menu.menuId(), menu.name(), menu.description(), menu.imageKey(), + menu.imageAssetId(), menu.priceCurrency(), menu.priceAmount(), menu.main())) .toList(); } + private List toImageCommands(List images) { + if (images == null) { + return null; + } + return images.stream() + .map(image -> new ImageCommand( + image.restaurantImageId(), image.imageAssetId())) + .toList(); + } + private List toBusinessHourCommands( List businessHours) { if (businessHours == null) { diff --git a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java index c8008679..59975186 100644 --- a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java +++ b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java @@ -4,6 +4,7 @@ import java.time.DayOfWeek; import java.time.LocalTime; import java.util.List; +import java.util.UUID; /** * 어드민 식당 등록·수정 커맨드 — 진입점(admin)이 {@link RestaurantPort}로 넘기는 계약. @@ -27,17 +28,49 @@ public record AdminRestaurantCommand( BigDecimal minPrice, BigDecimal maxPrice, List imageKeys, + List imageAssetIds, + List images, List menus, List hashtags, List curationTypes, List businessHours) { + /** legacy 진입점과 개발 데이터 호출을 신규 필드 활성화 전까지 호환한다. */ + public AdminRestaurantCommand( + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + List imageKeys, + List menus, + List hashtags, + List curationTypes, + List businessHours + ) { + this( + name, localName, summary, description, address, area, genre, foodCategory, + priceCurrency, minPrice, maxPrice, imageKeys, null, null, menus, hashtags, + curationTypes, businessHours); + } + + /** 수정 collection의 유지 association 또는 신규 asset. 배열 위치가 최종 순서다. */ + public record ImageCommand(Long restaurantImageId, UUID imageAssetId) { + } + /** 메뉴 항목 — 수정 시 기존 메뉴는 menuId를, 신규 메뉴는 null을 전달한다. */ public record MenuCommand( Long menuId, String name, String description, String imageKey, + UUID imageAssetId, String priceCurrency, BigDecimal priceAmount, boolean main) { @@ -51,7 +84,20 @@ public MenuCommand( BigDecimal priceAmount, boolean main ) { - this(null, name, description, imageKey, priceCurrency, priceAmount, main); + this(null, name, description, imageKey, null, priceCurrency, priceAmount, main); + } + + /** legacy 수정 호출부를 위한 생성자. */ + public MenuCommand( + Long menuId, + String name, + String description, + String imageKey, + String priceCurrency, + BigDecimal priceAmount, + boolean main + ) { + this(menuId, name, description, imageKey, null, priceCurrency, priceAmount, main); } } diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java index e35114e8..3489335e 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java @@ -241,6 +241,23 @@ public void addImage(RestaurantImage image) { this.images.add(image); } + public void removeImagesNotIn(Set retainedImageIds) { + this.images.removeIf(image -> image.getId() != null + && !retainedImageIds.contains(image.getId())); + } + + public void moveImagesToTemporaryOrders(int firstTemporaryOrder) { + int nextOrder = firstTemporaryOrder; + for (RestaurantImage image : images) { + image.setDisplayOrder(nextOrder); + nextOrder = Math.addExact(nextOrder, 1); + } + } + + public void sortImagesByDisplayOrder() { + this.images.sort(Comparator.comparingInt(RestaurantImage::getDisplayOrder)); + } + public void replaceBusinessHours(List businessHours) { this.businessHours.clear(); if (businessHours != null) { diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java index 340cc372..5540c6da 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java @@ -1,5 +1,6 @@ package org.sopt.hashi.restaurant.domain; +import jakarta.persistence.LockModeType; import java.time.DayOfWeek; import java.util.List; import java.util.Optional; @@ -7,14 +8,19 @@ import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.JpaSpecificationExecutor; -import org.springframework.data.jpa.repository.Query; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; public interface RestaurantRepository extends JpaRepository, JpaSpecificationExecutor { Optional findByIdAndDeletedFalse(Long id); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select restaurant from Restaurant restaurant where restaurant.id = :restaurantId") + Optional findByIdForUpdate(@Param("restaurantId") Long restaurantId); + boolean existsByIdAndDeletedFalse(Long id); @EntityGraph(attributePaths = "businessHours") diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index 11c0651b..e80b2279 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -6,19 +6,27 @@ import java.time.LocalDate; import java.time.LocalTime; import java.time.format.DateTimeFormatter; +import java.util.ArrayDeque; import java.util.ArrayList; import java.util.Comparator; +import java.util.Deque; import java.util.HashSet; +import java.util.LinkedHashMap; import java.util.List; import java.util.Map; import java.util.Objects; import java.util.Set; +import java.util.UUID; import java.util.function.Function; import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.AdminRestaurantInfo.AdminRestaurantBusinessHourInfo; @@ -87,15 +95,18 @@ public class RestaurantService { private final RestaurantRepository restaurantRepository; private final FileStorage fileStorage; + private final MediaPort mediaPort; private final Clock japanClock; public RestaurantService( RestaurantRepository restaurantRepository, FileStorage fileStorage, + MediaPort mediaPort, @Qualifier("japanClock") Clock japanClock ) { this.restaurantRepository = restaurantRepository; this.fileStorage = fileStorage; + this.mediaPort = mediaPort; this.japanClock = japanClock; } @@ -285,6 +296,22 @@ public RestaurantMenuDetailResponse getRestaurantMenu(Long restaurantId, Long me public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { validateRequiredForCreate(command); + List images = toImagesForCreate(command); + List menus = toMenus(command.menus()); + List businessHours = toBusinessHours(command.businessHours()); + List curationTypes = toCurationTypes(command.curationTypes()); + List claims = new ArrayList<>(); + images.stream() + .map(RestaurantImage::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)) + .forEach(claims::add); + menus.stream() + .map(RestaurantMenu::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT_MENU)) + .forEach(claims::add); + Restaurant restaurant = Restaurant.create( command.name(), command.localName(), @@ -297,13 +324,14 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { toPriceCurrency(command.priceCurrency()), command.minPrice(), command.maxPrice()); - restaurant.replaceImages(toImages(command.imageKeys())); - restaurant.replaceMenus(toMenus(command.menus())); + restaurant.replaceImages(images); + restaurant.replaceMenus(menus); restaurant.replaceHashtags(command.hashtags()); - restaurant.replaceCurationTypes(toCurationTypes(command.curationTypes())); - restaurant.replaceBusinessHours(toBusinessHours(command.businessHours())); + restaurant.replaceCurationTypes(curationTypes); + restaurant.replaceBusinessHours(businessHours); validatePriceRange(restaurant); + reconcileMediaBindings(claims, List.of()); Restaurant saved = restaurantRepository.save(restaurant); // 생성된 id는 응답 body에만 있어 로그로 남겨야 추적 가능하다 (adminId는 MDC) log.info("어드민 식당 등록. restaurantId={}", saved.getId()); @@ -317,8 +345,36 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma // 자유 텍스트 전환(#145) 후에도 값 비우기 불가 정책 유지 — enum 시절엔 빈 값이 변환 단계에서 거부됐다 validateNonBlankIfPresent(command.foodCategory()); validateNonEmptyIfPresent(command.imageKeys()); + validateNonEmptyIfPresent(command.images()); validateNonEmptyIfPresent(command.hashtags()); - Restaurant restaurant = findRestaurantForAdmin(restaurantId); + validateExclusiveImageCollections(command.imageKeys(), command.images()); + Restaurant restaurant = findRestaurantForAdminUpdate(restaurantId); + + RestaurantGenre genre = command.genre() == null ? null : toGenre(command.genre()); + PriceCurrency priceCurrency = command.priceCurrency() == null + ? null + : toPriceCurrency(command.priceCurrency()); + BigDecimal nextMinPrice = command.minPrice() == null + ? restaurant.getMinPrice() + : command.minPrice(); + BigDecimal nextMaxPrice = command.maxPrice() == null + ? restaurant.getMaxPrice() + : command.maxPrice(); + validatePriceRange(nextMinPrice, nextMaxPrice); + List curationTypes = command.curationTypes() == null + ? null + : toCurationTypes(command.curationTypes()); + List businessHours = command.businessHours() == null + ? null + : toBusinessHours(command.businessHours()); + RestaurantImageUpdatePlan imagePlan = planImageUpdate(restaurant, command); + RestaurantMenuUpdatePlan menuPlan = planMenuUpdate(restaurant, command.menus()); + + List claims = new ArrayList<>(imagePlan.claims()); + claims.addAll(menuPlan.claims()); + List retires = new ArrayList<>(imagePlan.retires()); + retires.addAll(menuPlan.retires()); + reconcileMediaBindings(claims, retires); restaurant.updateBasicInfo( command.name(), @@ -327,27 +383,22 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma command.description(), command.address(), command.area(), - command.genre() == null ? null : toGenre(command.genre()), + genre, command.foodCategory(), - command.priceCurrency() == null ? null : toPriceCurrency(command.priceCurrency()), + priceCurrency, command.minPrice(), command.maxPrice()); - validatePriceRange(restaurant); - if (command.imageKeys() != null) { - replaceImagesWithFlush(restaurant, command.imageKeys()); - } - if (command.menus() != null) { - synchronizeMenus(restaurant, command.menus()); - } + applyImageUpdate(restaurant, imagePlan); + applyMenuUpdate(restaurant, menuPlan); if (command.hashtags() != null) { restaurant.replaceHashtags(command.hashtags()); } if (command.curationTypes() != null) { - restaurant.replaceCurationTypes(toCurationTypes(command.curationTypes())); + restaurant.replaceCurationTypes(curationTypes); } - if (command.businessHours() != null) { - replaceBusinessHoursWithFlush(restaurant, command.businessHours()); + if (businessHours != null) { + replaceBusinessHoursWithFlush(restaurant, businessHours); } restaurantRepository.flush(); @@ -533,6 +584,11 @@ private Restaurant findRestaurantForAdmin(Long restaurantId) { .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); } + private Restaurant findRestaurantForAdminUpdate(Long restaurantId) { + return restaurantRepository.findByIdForUpdate(restaurantId) + .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); + } + private void validateRequiredForCreate(AdminRestaurantCommand command) { boolean missingRequired = command.name() == null || command.localName() == null || command.localName().isBlank() || command.address() == null @@ -540,7 +596,7 @@ private void validateRequiredForCreate(AdminRestaurantCommand command) { || command.area() == null || command.genre() == null || command.foodCategory() == null || command.foodCategory().isBlank() || command.priceCurrency() == null || command.minPrice() == null || command.maxPrice() == null - || command.imageKeys() == null || command.imageKeys().isEmpty() + || !hasExactlyOneCreateImageSource(command.imageKeys(), command.imageAssetIds()) || command.hashtags() == null || command.hashtags().isEmpty(); if (missingRequired) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); @@ -560,27 +616,45 @@ private void validateNonBlankIfPresent(String value) { } private void validatePriceRange(Restaurant restaurant) { - BigDecimal minPrice = restaurant.getMinPrice(); - BigDecimal maxPrice = restaurant.getMaxPrice(); + validatePriceRange(restaurant.getMinPrice(), restaurant.getMaxPrice()); + } + + private void validatePriceRange(BigDecimal minPrice, BigDecimal maxPrice) { boolean invalidRange = minPrice != null && maxPrice != null && minPrice.compareTo(maxPrice) > 0; if (invalidRange) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); } } - // (restaurant_id, display_order) 유니크 제약이 있어, Hibernate가 delete보다 insert를 먼저 실행하면 - // 같은 순서 값끼리 충돌한다. 기존 이미지를 비워 flush로 delete를 먼저 내보낸 뒤 새 이미지를 넣는다. - private void replaceImagesWithFlush(Restaurant restaurant, List imageKeys) { - restaurant.replaceImages(List.of()); - restaurantRepository.flush(); - restaurant.replaceImages(toImages(imageKeys)); + private boolean hasExactlyOneCreateImageSource( + List imageKeys, + List imageAssetIds + ) { + boolean legacyProvided = imageKeys != null; + boolean assetsProvided = imageAssetIds != null; + if (legacyProvided == assetsProvided) { + return false; + } + return legacyProvided ? !imageKeys.isEmpty() : !imageAssetIds.isEmpty(); + } + + private void validateExclusiveImageCollections( + List imageKeys, + List images + ) { + if (imageKeys != null && images != null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } } // (restaurant_id, day_of_week) 유니크 제약도 이미지와 동일한 insert-before-delete 충돌이 있어 같은 방식으로 교체한다. - private void replaceBusinessHoursWithFlush(Restaurant restaurant, List businessHours) { + private void replaceBusinessHoursWithFlush( + Restaurant restaurant, + List businessHours + ) { restaurant.replaceBusinessHours(List.of()); restaurantRepository.flush(); - restaurant.replaceBusinessHours(toBusinessHours(businessHours)); + restaurant.replaceBusinessHours(businessHours); } private RestaurantGenre toGenre(String value) { @@ -593,12 +667,23 @@ private PriceCurrency toPriceCurrency(String value) { .orElseThrow(() -> new BusinessException(CommonErrorCode.INVALID_INPUT)); } - private List toImages(List imageKeys) { - if (imageKeys == null) { - return List.of(); + private List toImagesForCreate(AdminRestaurantCommand command) { + if (!hasExactlyOneCreateImageSource(command.imageKeys(), command.imageAssetIds())) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + if (command.imageKeys() != null) { + return toLegacyImages(command.imageKeys()); } + return IntStream.range(0, command.imageAssetIds().size()) + .mapToObj(index -> RestaurantImage.createAsset( + requireAssetId(command.imageAssetIds().get(index)), index + 1)) + .toList(); + } + + private List toLegacyImages(List imageKeys) { return IntStream.range(0, imageKeys.size()) - .mapToObj(index -> RestaurantImage.create(imageKeys.get(index), index + 1)) + .mapToObj(index -> RestaurantImage.createLegacy( + requireLegacyKey(imageKeys.get(index)), index + 1)) .toList(); } @@ -606,59 +691,326 @@ private List toMenus(List menus) { if (menus == null) { return List.of(); } + if (menus.stream().anyMatch(Objects::isNull)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } if (menus.stream().anyMatch(menu -> menu.menuId() != null)) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); } return menus.stream() - .map(menu -> RestaurantMenu.create(menu.name(), menu.description(), menu.imageKey(), - toPriceCurrency(menu.priceCurrency()), menu.priceAmount(), menu.main())) + .map(this::toNewMenu) + .toList(); + } + + private RestaurantMenu toNewMenu(MenuCommand command) { + validateMenuImageSource(command); + PriceCurrency currency = toPriceCurrency(command.priceCurrency()); + if (command.imageAssetId() != null) { + return RestaurantMenu.createWithAsset( + command.name(), + command.description(), + requireAssetId(command.imageAssetId()), + currency, + command.priceAmount(), + command.main() + ); + } + return RestaurantMenu.create( + command.name(), + command.description(), + command.imageKey() == null ? null : requireLegacyKey(command.imageKey()), + currency, + command.priceAmount(), + command.main() + ); + } + + private RestaurantImageUpdatePlan planImageUpdate( + Restaurant restaurant, + AdminRestaurantCommand command + ) { + if (command.imageKeys() == null && command.images() == null) { + return RestaurantImageUpdatePlan.unchanged(); + } + List existingImages = restaurant.getImages().stream() + .sorted(Comparator.comparingInt(RestaurantImage::getDisplayOrder)) + .toList(); + if (command.imageKeys() != null) { + return planLegacyImageUpdate(existingImages, command.imageKeys()); + } + return planAssetImageUpdate(existingImages, command.images()); + } + + private RestaurantImageUpdatePlan planLegacyImageUpdate( + List existingImages, + List imageKeys + ) { + if (existingImages.stream().anyMatch(image -> image.getFileKey() == null)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + Map> imagesByKey = new LinkedHashMap<>(); + existingImages.forEach(image -> imagesByKey + .computeIfAbsent(image.getFileKey(), ignored -> new ArrayDeque<>()) + .addLast(image)); + + List finalImages = new ArrayList<>(); + Set retainedIds = new HashSet<>(); + for (String rawKey : imageKeys) { + String imageKey = requireLegacyKey(rawKey); + Deque candidates = imagesByKey.get(imageKey); + RestaurantImage retained = candidates == null ? null : candidates.pollFirst(); + if (retained == null) { + finalImages.add(RestaurantImage.createLegacy(imageKey, 1)); + } else { + finalImages.add(retained); + retainedIds.add(retained.getId()); + } + } + List retires = existingImages.stream() + .filter(image -> !retainedIds.contains(image.getId())) + .map(RestaurantImage::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)) + .toList(); + return new RestaurantImageUpdatePlan( + true, finalImages, retainedIds, List.of(), retires); + } + + private RestaurantImageUpdatePlan planAssetImageUpdate( + List existingImages, + List commands + ) { + if (commands == null || commands.isEmpty()) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + Map existingById = existingImages.stream() + .collect(Collectors.toMap(RestaurantImage::getId, Function.identity())); + Set retainedIds = new HashSet<>(); + Set newAssetIds = new HashSet<>(); + List finalImages = new ArrayList<>(); + List claims = new ArrayList<>(); + + for (ImageCommand image : commands) { + if (image == null + || (image.restaurantImageId() == null) == (image.imageAssetId() == null)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + if (image.restaurantImageId() != null) { + if (!retainedIds.add(image.restaurantImageId())) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + RestaurantImage retained = existingById.get(image.restaurantImageId()); + if (retained == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + finalImages.add(retained); + continue; + } + UUID assetId = requireAssetId(image.imageAssetId()); + if (!newAssetIds.add(assetId)) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + finalImages.add(RestaurantImage.createAsset(assetId, 1)); + claims.add(new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)); + } + + List retires = existingImages.stream() + .filter(image -> !retainedIds.contains(image.getId())) + .map(RestaurantImage::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)) .toList(); + return new RestaurantImageUpdatePlan( + true, finalImages, retainedIds, claims, retires); } - private void synchronizeMenus(Restaurant restaurant, List commands) { - Map existingMenusById = restaurant.getMenus().stream() + private void applyImageUpdate( + Restaurant restaurant, + RestaurantImageUpdatePlan plan + ) { + if (!plan.requested()) { + return; + } + int maxCurrentOrder = restaurant.getImages().stream() + .mapToInt(RestaurantImage::getDisplayOrder) + .max() + .orElse(0); + int temporaryStart = Math.addExact( + Math.max(maxCurrentOrder, plan.finalImages().size()), 1); + restaurant.moveImagesToTemporaryOrders(temporaryStart); + restaurantRepository.flush(); + + restaurant.removeImagesNotIn(plan.retainedImageIds()); + plan.finalImages().stream() + .filter(image -> image.getId() == null) + .forEach(restaurant::addImage); + IntStream.range(0, plan.finalImages().size()) + .forEach(index -> plan.finalImages().get(index).setDisplayOrder(index + 1)); + restaurant.sortImagesByDisplayOrder(); + restaurantRepository.flush(); + } + + private RestaurantMenuUpdatePlan planMenuUpdate( + Restaurant restaurant, + List commands + ) { + if (commands == null) { + return RestaurantMenuUpdatePlan.unchanged(); + } + Map existingById = restaurant.getMenus().stream() .filter(menu -> menu.getId() != null) .collect(Collectors.toMap(RestaurantMenu::getId, Function.identity())); - Set retainedMenuIds = new HashSet<>(); + Set retainedIds = new HashSet<>(); + List mutations = new ArrayList<>(); + List claims = new ArrayList<>(); + List retires = new ArrayList<>(); for (MenuCommand command : commands) { + if (command == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + validateMenuImageSource(command); + PriceCurrency currency = toPriceCurrency(command.priceCurrency()); if (command.menuId() == null) { + UUID newAssetId = command.imageAssetId() == null + ? null + : requireAssetId(command.imageAssetId()); + if (newAssetId != null) { + claims.add(new MediaAssetUse( + newAssetId, MediaAssetPurpose.RESTAURANT_MENU)); + } + mutations.add(new MenuMutation( + null, + command, + command.imageKey() == null ? null : requireLegacyKey(command.imageKey()), + newAssetId, + currency + )); continue; } - if (!retainedMenuIds.add(command.menuId())) { + + if (!retainedIds.add(command.menuId())) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); } - if (!existingMenusById.containsKey(command.menuId())) { + RestaurantMenu existing = existingById.get(command.menuId()); + if (existing == null) { throw new BusinessException(RestaurantErrorCode.MENU_NOT_FOUND); } + ResolvedMenuImage image = resolveMenuImage(existing, command, claims, retires); + mutations.add(new MenuMutation( + existing, command, image.imageKey(), image.imageAssetId(), currency)); } - commands.forEach(command -> toPriceCurrency(command.priceCurrency())); - - commands.stream() - .filter(command -> command.menuId() != null) - .forEach(command -> existingMenusById.get(command.menuId()).update( - command.name(), - command.description(), - command.imageKey(), - toPriceCurrency(command.priceCurrency()), - command.priceAmount(), - command.main() - )); - restaurant.removeMenusNotIn(retainedMenuIds); - commands.stream() - .filter(command -> command.menuId() == null) - .map(command -> RestaurantMenu.create( - command.name(), - command.description(), - command.imageKey(), - toPriceCurrency(command.priceCurrency()), - command.priceAmount(), - command.main() - )) + existingById.values().stream() + .filter(menu -> !retainedIds.contains(menu.getId())) + .map(RestaurantMenu::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT_MENU)) + .forEach(retires::add); + return new RestaurantMenuUpdatePlan( + true, mutations, retainedIds, claims, retires); + } + + private ResolvedMenuImage resolveMenuImage( + RestaurantMenu existing, + MenuCommand command, + List claims, + List retires + ) { + UUID currentAssetId = existing.getImageAssetId(); + if (command.imageAssetId() != null) { + UUID nextAssetId = requireAssetId(command.imageAssetId()); + if (Objects.equals(currentAssetId, nextAssetId)) { + return new ResolvedMenuImage(existing.getImageKey(), currentAssetId); + } + addMenuRetire(currentAssetId, retires); + claims.add(new MediaAssetUse(nextAssetId, MediaAssetPurpose.RESTAURANT_MENU)); + return new ResolvedMenuImage(null, nextAssetId); + } + if (command.imageKey() != null) { + String nextImageKey = requireLegacyKey(command.imageKey()); + if (Objects.equals(existing.getImageKey(), nextImageKey)) { + return new ResolvedMenuImage(existing.getImageKey(), currentAssetId); + } + addMenuRetire(currentAssetId, retires); + return new ResolvedMenuImage(nextImageKey, null); + } + addMenuRetire(currentAssetId, retires); + return new ResolvedMenuImage(null, null); + } + + private void applyMenuUpdate( + Restaurant restaurant, + RestaurantMenuUpdatePlan plan + ) { + if (!plan.requested()) { + return; + } + plan.mutations().stream() + .filter(mutation -> mutation.existing() != null) + .forEach(mutation -> mutation.existing().update( + mutation.command().name(), + mutation.command().description(), + mutation.imageKey(), + mutation.imageAssetId(), + mutation.currency(), + mutation.command().priceAmount(), + mutation.command().main() + )); + restaurant.removeMenusNotIn(plan.retainedMenuIds()); + plan.mutations().stream() + .filter(mutation -> mutation.existing() == null) + .map(this::toNewMenu) .forEach(restaurant::addMenu); } + private RestaurantMenu toNewMenu(MenuMutation mutation) { + MenuCommand command = mutation.command(); + if (mutation.imageAssetId() != null) { + return RestaurantMenu.createWithAsset( + command.name(), command.description(), mutation.imageAssetId(), + mutation.currency(), command.priceAmount(), command.main()); + } + return RestaurantMenu.create( + command.name(), command.description(), mutation.imageKey(), + mutation.currency(), command.priceAmount(), command.main()); + } + + private void validateMenuImageSource(MenuCommand command) { + if (command.imageKey() != null && command.imageAssetId() != null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } + + private void addMenuRetire(UUID assetId, List retires) { + if (assetId != null) { + retires.add(new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT_MENU)); + } + } + + private String requireLegacyKey(String imageKey) { + if (imageKey == null || imageKey.isBlank()) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + return imageKey; + } + + private UUID requireAssetId(UUID assetId) { + if (assetId == null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + return assetId; + } + + private void reconcileMediaBindings( + List claims, + List retires + ) { + if (!claims.isEmpty() || !retires.isEmpty()) { + mediaPort.reconcileBindings(claims, retires); + } + } + private List toBusinessHours(List businessHours) { validateBusinessHourCoverage(businessHours); try { @@ -753,6 +1105,60 @@ private AdminRestaurantMenuInfo toAdminMenuInfo(RestaurantMenu menu) { menu.isMain()); } + private record RestaurantImageUpdatePlan( + boolean requested, + List finalImages, + Set retainedImageIds, + List claims, + List retires + ) { + + private RestaurantImageUpdatePlan { + finalImages = List.copyOf(finalImages); + retainedImageIds = Set.copyOf(retainedImageIds); + claims = List.copyOf(claims); + retires = List.copyOf(retires); + } + + private static RestaurantImageUpdatePlan unchanged() { + return new RestaurantImageUpdatePlan( + false, List.of(), Set.of(), List.of(), List.of()); + } + } + + private record RestaurantMenuUpdatePlan( + boolean requested, + List mutations, + Set retainedMenuIds, + List claims, + List retires + ) { + + private RestaurantMenuUpdatePlan { + mutations = List.copyOf(mutations); + retainedMenuIds = Set.copyOf(retainedMenuIds); + claims = List.copyOf(claims); + retires = List.copyOf(retires); + } + + private static RestaurantMenuUpdatePlan unchanged() { + return new RestaurantMenuUpdatePlan( + false, List.of(), Set.of(), List.of(), List.of()); + } + } + + private record MenuMutation( + RestaurantMenu existing, + MenuCommand command, + String imageKey, + UUID imageAssetId, + PriceCurrency currency + ) { + } + + private record ResolvedMenuImage(String imageKey, UUID imageAssetId) { + } + private String formatTime(LocalTime time) { return time == null ? null : time.format(TIME_FORMATTER); } diff --git a/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java b/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java index 2cc7ac41..c6d7338a 100644 --- a/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java +++ b/src/test/java/org/sopt/hashi/admin/dto/RestaurantRequestValidationTest.java @@ -10,6 +10,7 @@ import java.util.Arrays; import java.util.List; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeAll; import org.junit.jupiter.api.Test; @@ -47,7 +48,7 @@ static void setUpValidator() { assertThat(violations) .extracting(violation -> violation.getPropertyPath().toString()) - .containsExactlyInAnyOrder("imageKeys", "hashtags"); + .containsExactlyInAnyOrder("imageSourceValid", "hashtags"); } @Test @@ -142,6 +143,97 @@ static void setUpValidator() { .containsExactly("menus[0].menuId"); } + @Test + void 식당_등록은_legacy_key와_asset_ID를_함께_받지_않는다() { + CreateRestaurantRequest request = createRequest( + List.of("restaurants/legacy.jpg"), + List.of(UUID.randomUUID()), + List.of() + ); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .containsExactly("imageSourceValid"); + } + + @Test + void 식당_등록은_asset_ID만으로_가능하다() { + CreateRestaurantRequest request = createRequest( + null, + List.of(UUID.randomUUID()), + List.of(new CreateRestaurantRequest.MenuRequest( + "메뉴", "설명", null, UUID.randomUUID(), + "JPY", BigDecimal.valueOf(1_000), true + )) + ); + + assertThat(validator.validate(request)).isEmpty(); + } + + @Test + void 메뉴는_key와_asset_ID를_동시에_받지_않는다() { + CreateRestaurantRequest request = createRequest( + List.of("restaurants/legacy.jpg"), + null, + List.of(new CreateRestaurantRequest.MenuRequest( + "메뉴", "설명", "menus/legacy.jpg", UUID.randomUUID(), + "JPY", BigDecimal.valueOf(1_000), true + )) + ); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .containsExactly("menus[0].imageSourceValid"); + } + + @Test + void 식당_수정은_legacy_collection과_ordered_wrapper를_함께_받지_않는다() { + UpdateRestaurantRequest request = updateRequest( + List.of("restaurants/legacy.jpg"), + List.of(new UpdateRestaurantRequest.ImageRequest(1L, null)) + ); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .containsExactly("imageCollectionSourceValid"); + } + + @Test + void 식당_수정_wrapper는_association_ID와_asset_ID중_하나만_받는다() { + UpdateRestaurantRequest request = updateRequest( + null, + List.of(new UpdateRestaurantRequest.ImageRequest(1L, UUID.randomUUID())) + ); + + assertThat(validator.validate(request)) + .extracting(violation -> violation.getPropertyPath().toString()) + .containsExactly("images[0].referenceValid"); + } + + private CreateRestaurantRequest createRequest( + List imageKeys, + List imageAssetIds, + List menus + ) { + return new CreateRestaurantRequest( + "하시 스시", "Hashi Sushi", "한 줄 소개", "상세 설명", + "도쿄도 시부야구", "도쿄", "sushi", "sushi", "JPY", + BigDecimal.valueOf(1_000), BigDecimal.valueOf(3_000), + imageKeys, imageAssetIds, menus, List.of("스시"), List.of(), + createBusinessHours() + ); + } + + private UpdateRestaurantRequest updateRequest( + List imageKeys, + List images + ) { + return new UpdateRestaurantRequest( + null, null, null, null, null, null, null, null, null, null, null, + imageKeys, images, null, null, null, null + ); + } + private static List createBusinessHours() { return Arrays.stream(DayOfWeek.values()) .map(day -> new CreateRestaurantRequest.BusinessHourRequest( diff --git a/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java new file mode 100644 index 00000000..aa0d7191 --- /dev/null +++ b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java @@ -0,0 +1,156 @@ +package org.sopt.hashi.media.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.config.TimeConfig; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.MediaOwnerType; +import org.sopt.hashi.media.domain.MediaPurpose; +import org.sopt.hashi.media.internal.spec.MediaSpecRegistry; +import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantImage; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.sopt.hashi.restaurant.service.RestaurantService; +import org.sopt.hashi.shared.storage.FileStorage; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.context.annotation.Import; +import org.springframework.test.context.TestPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.support.TransactionTemplate; + +@DataJpaTest +@Import({ + MediaPortImpl.class, + MediaPurposeAccessPolicy.class, + RestaurantService.class, + TimeConfig.class +}) +@TestPropertySource(properties = { + "spring.flyway.enabled=false", + "spring.jpa.hibernate.ddl-auto=create-drop", + "spring.datasource.url=jdbc:h2:mem:restaurant-media-transaction-test;MODE=MySQL;DATABASE_TO_LOWER=TRUE" +}) +@Transactional(propagation = Propagation.NOT_SUPPORTED) +class RestaurantMediaTransactionIntegrationTest { + + private static final String SPEC_DIGEST = + "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + private static final String SOURCE_CHECKSUM = + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + + @Autowired + private RestaurantService restaurantService; + + @Autowired + private RestaurantRepository restaurantRepository; + + @Autowired + private ImageAssetRepository imageAssetRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + @MockitoBean + private CurrentActorProvider currentActorProvider; + + @MockitoBean + private MediaSpecRegistry mediaSpecRegistry; + + @MockitoBean + private FileStorage fileStorage; + + @Test + void 외부_transaction이_rollback되면_restaurant_연결과_media_claim이_함께_복원된다() { + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 1L)); + ImageAsset asset = imageAssetRepository.saveAndFlush(readyRestaurantAsset()); + Restaurant restaurant = restaurant(); + restaurant.replaceImages(List.of( + RestaurantImage.createLegacy("restaurants/legacy.jpg", 1))); + restaurantRepository.saveAndFlush(restaurant); + Long restaurantId = restaurant.getId(); + Long legacyImageId = restaurant.getImages().getFirst().getId(); + + transactionTemplate.executeWithoutResult(status -> { + restaurantService.updateByAdmin( + restaurantId, + updateImageCommand(asset.getPublicId()) + ); + status.setRollbackOnly(); + }); + + assertThat(imageAssetRepository.findByPublicId(asset.getPublicId()).orElseThrow() + .getBindingStatus()).isEqualTo(ImageBindingStatus.UNBOUND); + Restaurant reloaded = restaurantRepository + .findActiveByIdWithImages(restaurantId) + .orElseThrow(); + assertThat(reloaded.getImages()).singleElement().satisfies(image -> { + assertThat(image.getId()).isEqualTo(legacyImageId); + assertThat(image.getFileKey()).isEqualTo("restaurants/legacy.jpg"); + assertThat(image.getImageAssetId()).isNull(); + }); + } + + private ImageAsset readyRestaurantAsset() { + UUID assetId = UUID.randomUUID(); + ImageAsset asset = ImageAsset.createDirectUpload( + assetId, + MediaPurpose.RESTAURANT, + MediaOwnerType.ADMIN, + 1L, + "media/originals/%s/original".formatted(assetId), + "image/jpeg", + 1024L, + LocalDateTime.now().plusMinutes(5) + ); + UUID jobId = UUID.randomUUID(); + asset.beginInitialProcessing( + "version-1", "\"etag-1\"", 1, SPEC_DIGEST, jobId, LocalDateTime.now()); + asset.completeCurrentProcessing( + jobId, 1, SPEC_DIGEST, "image/jpeg", 1024L, 3024, 4032, + SOURCE_CHECKSUM); + return asset; + } + + private Restaurant restaurant() { + return Restaurant.create( + "트랜잭션 식당", + "Transaction Restaurant", + "식당 소개", + "식당 상세 설명", + "도쿄도 신주쿠구", + "도쿄", + RestaurantGenre.SUSHI, + "초밥", + PriceCurrency.JPY, + BigDecimal.valueOf(1_000), + BigDecimal.valueOf(3_000) + ); + } + + private AdminRestaurantCommand updateImageCommand(UUID assetId) { + return new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, null, List.of(new ImageCommand(null, assetId)), + null, null, null, null + ); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java index 2cd777c9..50471b77 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java @@ -1,12 +1,15 @@ package org.sopt.hashi.restaurant.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.verifyNoInteractions; import java.math.BigDecimal; import java.util.List; import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.config.TimeConfig; import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.AdminRestaurantInfo.AdminRestaurantMenuInfo; @@ -14,6 +17,7 @@ import org.sopt.hashi.restaurant.domain.PriceCurrency; import org.sopt.hashi.restaurant.domain.Restaurant; import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantImage; import org.sopt.hashi.restaurant.domain.RestaurantMenu; import org.sopt.hashi.restaurant.domain.RestaurantRepository; import org.sopt.hashi.restaurant.dto.RestaurantListResponse; @@ -49,6 +53,9 @@ class RestaurantServiceIntegrationTest { @MockitoBean private FileStorage fileStorage; + @MockitoBean + private MediaPort mediaPort; + @Test void 식당_목록은_음식_분류로_필터링하지_않고_응답에는_음식_분류를_유지한다() { restaurantRepository.saveAllAndFlush(List.of( @@ -122,6 +129,41 @@ class RestaurantServiceIntegrationTest { .isPresent(); } + @Test + void 식당_이미지_재정렬은_unique_충돌없이_association_ID를_유지한다() { + Restaurant restaurant = createRestaurant(); + restaurant.replaceImages(List.of( + RestaurantImage.createLegacy("restaurants/A.jpg", 1), + RestaurantImage.createLegacy("restaurants/B.jpg", 2), + RestaurantImage.createLegacy("restaurants/C.jpg", 3) + )); + restaurantRepository.saveAndFlush(restaurant); + Long restaurantId = restaurant.getId(); + Long firstId = restaurant.getImages().get(0).getId(); + Long secondId = restaurant.getImages().get(1).getId(); + Long thirdId = restaurant.getImages().get(2).getId(); + + restaurantPort.updateByAdmin( + restaurantId, + updateImagesCommand(List.of( + new ImageCommand(thirdId, null), + new ImageCommand(firstId, null), + new ImageCommand(secondId, null) + )) + ); + + Restaurant reloaded = restaurantRepository + .findActiveByIdWithImages(restaurantId) + .orElseThrow(); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getId) + .containsExactly(thirdId, firstId, secondId); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getDisplayOrder) + .containsExactly(1, 2, 3); + verifyNoInteractions(mediaPort); + } + private AdminRestaurantCommand updateMenusCommand(List menus) { return new AdminRestaurantCommand( null, @@ -143,6 +185,13 @@ private AdminRestaurantCommand updateMenusCommand(List menus) { ); } + private AdminRestaurantCommand updateImagesCommand(List images) { + return new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, null, images, null, null, null, null + ); + } + private Restaurant createRestaurant() { return createRestaurant("메뉴 수정 식당", "초밥"); } diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index e02c8f88..ee117eb5 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -4,7 +4,9 @@ import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyString; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.doThrow; import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; @@ -17,6 +19,8 @@ import java.time.ZoneId; import java.util.List; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import java.util.stream.LongStream; import java.util.stream.IntStream; import org.junit.jupiter.api.Test; @@ -25,7 +29,11 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.media.MediaAssetPurpose; +import org.sopt.hashi.media.MediaAssetUse; import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.code.RestaurantErrorCode; import org.sopt.hashi.restaurant.domain.PriceCurrency; @@ -66,8 +74,11 @@ class RestaurantServiceTest { @Mock private FileStorage fileStorage; + @Mock + private MediaPort mediaPort; + private RestaurantService createRestaurantService() { - return new RestaurantService(restaurantRepository, fileStorage, JAPAN_CLOCK); + return new RestaurantService(restaurantRepository, fileStorage, mediaPort, JAPAN_CLOCK); } @Test @@ -211,7 +222,7 @@ private RestaurantService createRestaurantService() { RestaurantMenu removedMenu = createMenu(20L, "삭제 메뉴", false); restaurant.addMenu(retainedMenu); restaurant.addMenu(removedMenu); - given(restaurantRepository.findById(1L)).willReturn(Optional.of(restaurant)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); AdminRestaurantCommand command = updateMenuCommand(List.of( new MenuCommand(10L, "수정 메뉴", "수정 설명", "restaurant-menus/updated.jpg", "JPY", BigDecimal.valueOf(1_500), false), @@ -242,7 +253,7 @@ private RestaurantService createRestaurantService() { RestaurantService restaurantService = createRestaurantService(); Restaurant restaurant = createRestaurant(1L, 4.8, 100L); restaurant.addMenu(createMenu(10L, "기존 메뉴", true)); - given(restaurantRepository.findById(1L)).willReturn(Optional.of(restaurant)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); AdminRestaurantCommand command = updateMenuCommand(List.of( new MenuCommand(999L, "다른 메뉴", "설명", null, "JPY", BigDecimal.valueOf(1_000), false) @@ -262,7 +273,7 @@ private RestaurantService createRestaurantService() { RestaurantService restaurantService = createRestaurantService(); Restaurant restaurant = createRestaurant(1L, 4.8, 100L); restaurant.addMenu(createMenu(10L, "기존 메뉴", true)); - given(restaurantRepository.findById(1L)).willReturn(Optional.of(restaurant)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); AdminRestaurantCommand command = updateMenuCommand(List.of( new MenuCommand(10L, "첫 번째", "설명", null, "JPY", BigDecimal.valueOf(1_000), false), @@ -275,6 +286,172 @@ private RestaurantService createRestaurantService() { assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); } + @Test + void 어드민_식당_등록은_식당과_메뉴_asset을_한번에_claim한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID restaurantAssetId = UUID.randomUUID(); + UUID menuAssetId = UUID.randomUUID(); + AdminRestaurantCommand command = createAssetAdminCommand( + List.of(restaurantAssetId), + List.of(new MenuCommand( + null, "asset 메뉴", "설명", null, menuAssetId, + "JPY", BigDecimal.valueOf(1_000), true)) + ); + given(restaurantRepository.save(any(Restaurant.class))) + .willAnswer(invocation -> invocation.getArgument(0)); + + restaurantService.createByAdmin(command); + + verify(mediaPort).reconcileBindings( + argThat(claims -> Set.copyOf(claims).equals(Set.of( + new MediaAssetUse(restaurantAssetId, MediaAssetPurpose.RESTAURANT), + new MediaAssetUse(menuAssetId, MediaAssetPurpose.RESTAURANT_MENU) + ))), + argThat(Collection -> Collection.isEmpty()) + ); + ArgumentCaptor restaurantCaptor = ArgumentCaptor.forClass(Restaurant.class); + verify(restaurantRepository).save(restaurantCaptor.capture()); + assertThat(restaurantCaptor.getValue().getImages()) + .extracting(RestaurantImage::getImageAssetId) + .containsExactly(restaurantAssetId); + assertThat(restaurantCaptor.getValue().getMenus()) + .extracting(RestaurantMenu::getImageAssetId) + .containsExactly(menuAssetId); + } + + @Test + void ordered_wrapper는_기존_association_ID를_유지하고_추가와_제거만_전이한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID removedAssetId = UUID.randomUUID(); + UUID retainedAssetId = UUID.randomUUID(); + UUID newAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + RestaurantImage removed = image(101L, "restaurants/removed.jpg", removedAssetId, 1); + RestaurantImage retained = image(102L, "restaurants/retained.jpg", retainedAssetId, 2); + restaurant.replaceImages(List.of(removed, retained)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + + restaurantService.updateByAdmin(1L, updateImagesCommand( + null, + List.of( + new ImageCommand(102L, null), + new ImageCommand(null, newAssetId) + ) + )); + + assertThat(restaurant.getImages()) + .extracting(RestaurantImage::getId) + .containsExactly(102L, null); + assertThat(restaurant.getImages()) + .extracting(RestaurantImage::getDisplayOrder) + .containsExactly(1, 2); + verify(mediaPort).reconcileBindings( + argThat(claims -> List.copyOf(claims).equals(List.of( + new MediaAssetUse(newAssetId, MediaAssetPurpose.RESTAURANT)))), + argThat(retires -> List.copyOf(retires).equals(List.of( + new MediaAssetUse(removedAssetId, MediaAssetPurpose.RESTAURANT)))) + ); + } + + @Test + void legacy_중복_key_재정렬은_기존_ID와_backfill_asset을_보존한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID backfilledAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of( + image(101L, "A", backfilledAssetId, 1), + image(102L, "A", null, 2), + image(103L, "B", null, 3) + )); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + + restaurantService.updateByAdmin( + 1L, + updateImagesCommand(List.of("B", "A", "A"), null) + ); + + assertThat(restaurant.getImages()) + .extracting(RestaurantImage::getId) + .containsExactly(103L, 101L, 102L); + assertThat(restaurant.getImages().get(1).getImageAssetId()) + .isEqualTo(backfilledAssetId); + verifyNoInteractions(mediaPort); + } + + @Test + void media_binding이_실패하면_Aggregate를_변경하지_않는다() { + RestaurantService restaurantService = createRestaurantService(); + UUID currentAssetId = UUID.randomUUID(); + UUID newAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + RestaurantImage current = image(101L, null, currentAssetId, 1); + restaurant.replaceImages(List.of(current)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + doThrow(new BusinessException(CommonErrorCode.INVALID_INPUT)) + .when(mediaPort).reconcileBindings(any(), any()); + + assertThatThrownBy(() -> restaurantService.updateByAdmin( + 1L, + updateImagesCommand(null, List.of(new ImageCommand(null, newAssetId))) + )).isInstanceOf(BusinessException.class); + + assertThat(restaurant.getImages()).containsExactly(current); + assertThat(current.getDisplayOrder()).isEqualTo(1); + verify(restaurantRepository, never()).flush(); + } + + @Test + void 같은_legacy_메뉴_key는_backfill_asset을_보존하고_media를_변경하지_않는다() { + RestaurantService restaurantService = createRestaurantService(); + UUID backfilledAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + RestaurantMenu menu = createMenu(10L, "기존 메뉴", true); + ReflectionTestUtils.setField(menu, "imageAssetId", backfilledAssetId); + restaurant.addMenu(menu); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + + restaurantService.updateByAdmin(1L, updateMenuCommand(List.of( + new MenuCommand( + 10L, "수정 메뉴", "설명", menu.getImageKey(), + "JPY", BigDecimal.valueOf(1_200), true) + ))); + + assertThat(menu.getImageAssetId()).isEqualTo(backfilledAssetId); + assertThat(menu.getImageKey()).isEqualTo("restaurant-menus/10.jpg"); + verifyNoInteractions(mediaPort); + } + + @Test + void 메뉴_asset_교체는_새_asset만_claim하고_기존_asset만_retire한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID oldAssetId = UUID.randomUUID(); + UUID newAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + RestaurantMenu menu = createMenu(10L, "기존 메뉴", true); + ReflectionTestUtils.setField(menu, "imageKey", null); + ReflectionTestUtils.setField(menu, "imageAssetId", oldAssetId); + restaurant.addMenu(menu); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + + restaurantService.updateByAdmin(1L, new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, null, null, + List.of(new MenuCommand( + 10L, "수정 메뉴", "설명", null, newAssetId, + "JPY", BigDecimal.valueOf(1_200), true)), + null, null, null + )); + + assertThat(menu.getImageAssetId()).isEqualTo(newAssetId); + assertThat(menu.getImageKey()).isNull(); + verify(mediaPort).reconcileBindings( + argThat(claims -> List.copyOf(claims).equals(List.of( + new MediaAssetUse(newAssetId, MediaAssetPurpose.RESTAURANT_MENU)))), + argThat(retires -> List.copyOf(retires).equals(List.of( + new MediaAssetUse(oldAssetId, MediaAssetPurpose.RESTAURANT_MENU)))) + ); + } + @Test void 기본값으로_식당_목록을_조회하고_다음_커서를_반환한다() { RestaurantService restaurantService = createRestaurantService(); @@ -910,6 +1087,63 @@ private AdminRestaurantCommand updateMenuCommand(List menus) { ); } + private AdminRestaurantCommand createAssetAdminCommand( + List imageAssetIds, + List menus + ) { + return new AdminRestaurantCommand( + "히마와리 스시", + "Himawari Sushi", + "식당 소개", + "매장 상세 설명", + "도쿄도 신주쿠구", + "도쿄", + "sushi", + "sushi", + "JPY", + BigDecimal.valueOf(1_000), + BigDecimal.valueOf(3_000), + null, + imageAssetIds, + null, + menus, + List.of("스시"), + List.of(), + java.util.Arrays.stream(DayOfWeek.values()) + .map(day -> new AdminRestaurantCommand.BusinessHourCommand( + day, null, null, null, null, true)) + .toList() + ); + } + + private AdminRestaurantCommand updateImagesCommand( + List imageKeys, + List images + ) { + return new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + imageKeys, null, images, null, null, null, null + ); + } + + private RestaurantImage image( + Long id, + String fileKey, + UUID assetId, + int displayOrder + ) { + RestaurantImage image; + if (fileKey != null && assetId != null) { + image = RestaurantImage.createBackfilled(fileKey, assetId, displayOrder); + } else if (assetId != null) { + image = RestaurantImage.createAsset(assetId, displayOrder); + } else { + image = RestaurantImage.createLegacy(fileKey, displayOrder); + } + ReflectionTestUtils.setField(image, "id", id); + return image; + } + private RestaurantMenu createMenu(Long id, String name, boolean representative) { RestaurantMenu menu = RestaurantMenu.create( name, From ae8b4069984c0f1ecc1c3f45951c05e0df0d722d Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 28 Aug 2026 00:50:13 +0900 Subject: [PATCH 04/26] =?UTF-8?q?feat(restaurant):=20=EC=B5=9C=EC=A0=81?= =?UTF-8?q?=ED=99=94=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/AdminRestaurantResponse.java | 13 +- .../hashi/restaurant/AdminRestaurantInfo.java | 51 ++- .../hashi/restaurant/RestaurantImageInfo.java | 11 + .../dto/RestaurantListResponse.java | 21 ++ .../dto/RestaurantMainResponse.java | 21 ++ .../dto/RestaurantMenuDetailResponse.java | 18 + .../dto/RestaurantMenuListResponse.java | 14 + .../restaurant/service/RestaurantService.java | 250 ++++++++++++-- .../service/RestaurantServiceTest.java | 312 +++++++++++++++++- .../web/RestaurantControllerTest.java | 68 ++++ 10 files changed, 746 insertions(+), 33 deletions(-) create mode 100644 src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java diff --git a/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java b/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java index 512fff54..b613e18f 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java +++ b/src/main/java/org/sopt/hashi/admin/dto/AdminRestaurantResponse.java @@ -3,11 +3,14 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.restaurant.AdminRestaurantInfo; +import org.sopt.hashi.restaurant.RestaurantImageInfo; /** - * 어드민 식당 단건 응답(등록·수정 결과). thumbnailUrl·imageUrls·메뉴 imageUrl은 저장된 키를 - * 변환한 조회 URL이다. genre·curationTypes는 사용자 API와 같은 소문자 케밥 값이다. + * 어드민 식당 단건 응답(등록·수정 결과). 기존 URL 필드는 legacy 또는 READY media 호환용이며, + * 신규 이미지 필드는 상태와 반응형 후보를 전달한다. genre·curationTypes는 사용자 API와 같은 + * 소문자 케밥 값이다. */ public record AdminRestaurantResponse( Long restaurantId, @@ -20,11 +23,13 @@ public record AdminRestaurantResponse( String genre, String foodCategory, String thumbnailUrl, + RestaurantImageInfo thumbnailImage, String priceCurrency, BigDecimal minPrice, BigDecimal maxPrice, boolean deleted, List imageUrls, + List heroImages, List menus, List hashtags, List curationTypes, @@ -43,11 +48,13 @@ public static AdminRestaurantResponse from(AdminRestaurantInfo info) { info.genre(), info.foodCategory(), info.thumbnailUrl(), + info.thumbnailImage(), info.priceCurrency(), info.minPrice(), info.maxPrice(), info.deleted(), info.imageUrls(), + info.heroImages(), info.menus().stream() .map(AdminRestaurantMenuResponse::from) .toList(), @@ -64,6 +71,7 @@ public record AdminRestaurantMenuResponse( String name, String description, String imageUrl, + MediaImage listImage, String priceCurrency, BigDecimal priceAmount, boolean main) { @@ -74,6 +82,7 @@ static AdminRestaurantMenuResponse from(AdminRestaurantInfo.AdminRestaurantMenuI info.name(), info.description(), info.imageUrl(), + info.listImage(), info.priceCurrency(), info.priceAmount(), info.main()); diff --git a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java index 800d91c3..44a5fff0 100644 --- a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantInfo.java @@ -3,11 +3,13 @@ import java.math.BigDecimal; import java.time.LocalDateTime; import java.util.List; +import org.sopt.hashi.media.MediaImage; /** * 모듈 간 전달용 어드민 식당 상세 DTO — 진입점(admin)이 식당을 관리(등록·수정)할 때 받는 계약. - * thumbnailUrl·imageUrls·메뉴 imageUrl은 저장된 S3 키를 restaurant가 조회 URL로 변환한 값이다 - * (키 자체는 노출하지 않는다). genre·curationTypes는 사용자 API와 같은 소문자 케밥 값이다. + * 기존 URL 필드는 legacy key 또는 READY media의 기본 후보를 호환 projection한 값이며, 신규 이미지 + * 필드는 상태와 반응형 후보를 함께 전달한다. object key 자체는 노출하지 않는다. + * genre·curationTypes는 사용자 API와 같은 소문자 케밥 값이다. */ public record AdminRestaurantInfo( Long restaurantId, @@ -20,25 +22,70 @@ public record AdminRestaurantInfo( String genre, String foodCategory, String thumbnailUrl, + RestaurantImageInfo thumbnailImage, String priceCurrency, BigDecimal minPrice, BigDecimal maxPrice, boolean deleted, List imageUrls, + List heroImages, List menus, List hashtags, List curationTypes, List businessHours, LocalDateTime createdAt) { + public AdminRestaurantInfo( + Long restaurantId, + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String thumbnailUrl, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + boolean deleted, + List imageUrls, + List menus, + List hashtags, + List curationTypes, + List businessHours, + LocalDateTime createdAt + ) { + this( + restaurantId, name, localName, summary, description, address, area, genre, + foodCategory, thumbnailUrl, null, priceCurrency, minPrice, maxPrice, deleted, + imageUrls, List.of(), menus, hashtags, curationTypes, businessHours, createdAt); + } + public record AdminRestaurantMenuInfo( Long menuId, String name, String description, String imageUrl, + MediaImage listImage, String priceCurrency, BigDecimal priceAmount, boolean main) { + + public AdminRestaurantMenuInfo( + Long menuId, + String name, + String description, + String imageUrl, + String priceCurrency, + BigDecimal priceAmount, + boolean main + ) { + this( + menuId, name, description, imageUrl, null, + priceCurrency, priceAmount, main); + } } /** 요일별 영업시간 — dayOfWeek는 MONDAY~SUNDAY, 시간은 HH:mm 문자열(휴무일은 null). */ diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java new file mode 100644 index 00000000..4cecbe63 --- /dev/null +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java @@ -0,0 +1,11 @@ +package org.sopt.hashi.restaurant; + +import org.sopt.hashi.media.MediaImage; + +/** 식당 Aggregate가 소유하는 stable 이미지 association과 media projection. */ +public record RestaurantImageInfo( + Long restaurantImageId, + int displayOrder, + MediaImage image +) { +} diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantListResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantListResponse.java index 19aa3454..a9251458 100644 --- a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantListResponse.java +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantListResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.math.BigDecimal; import java.util.List; +import org.sopt.hashi.restaurant.RestaurantImageInfo; @JsonInclude(JsonInclude.Include.NON_NULL) public record RestaurantListResponse( @@ -17,7 +18,9 @@ public record RestaurantSummaryResponse( String name, BigDecimal rating, String thumbnailUrl, + RestaurantImageInfo thumbnailImage, List imageUrls, + List cardImages, String area, String genre, String foodCategory, @@ -25,6 +28,24 @@ public record RestaurantSummaryResponse( List hashtags, TodayBusinessHourResponse todayBusinessHour ) { + + public RestaurantSummaryResponse( + Long restaurantId, + String name, + BigDecimal rating, + String thumbnailUrl, + List imageUrls, + String area, + String genre, + String foodCategory, + String summary, + List hashtags, + TodayBusinessHourResponse todayBusinessHour + ) { + this( + restaurantId, name, rating, thumbnailUrl, null, imageUrls, List.of(), + area, genre, foodCategory, summary, hashtags, todayBusinessHour); + } } @JsonInclude(JsonInclude.Include.NON_NULL) diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMainResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMainResponse.java index c6f0119f..723e7d88 100644 --- a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMainResponse.java +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMainResponse.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; import java.util.List; +import org.sopt.hashi.restaurant.RestaurantImageInfo; public record RestaurantMainResponse( Long restaurantId, @@ -13,7 +14,27 @@ public record RestaurantMainResponse( String foodCategory, String address, String thumbnailUrl, + RestaurantImageInfo thumbnailImage, List imageUrls, + List heroImages, Long reservationFee ) { + + public RestaurantMainResponse( + Long restaurantId, + String name, + String localName, + BigDecimal rating, + Long reviewCount, + String summary, + String foodCategory, + String address, + String thumbnailUrl, + List imageUrls, + Long reservationFee + ) { + this( + restaurantId, name, localName, rating, reviewCount, summary, foodCategory, + address, thumbnailUrl, null, imageUrls, List.of(), reservationFee); + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuDetailResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuDetailResponse.java index 771a50e7..9ac29394 100644 --- a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuDetailResponse.java +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuDetailResponse.java @@ -1,13 +1,31 @@ package org.sopt.hashi.restaurant.dto; +import org.sopt.hashi.media.MediaImage; + public record RestaurantMenuDetailResponse( Long menuId, String name, String description, String imageUrl, + MediaImage detailImage, String currency, Long price, boolean main, long otherMenuCount ) { + + public RestaurantMenuDetailResponse( + Long menuId, + String name, + String description, + String imageUrl, + String currency, + Long price, + boolean main, + long otherMenuCount + ) { + this( + menuId, name, description, imageUrl, null, + currency, price, main, otherMenuCount); + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuListResponse.java b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuListResponse.java index 5012ac94..16fa6fa1 100644 --- a/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuListResponse.java +++ b/src/main/java/org/sopt/hashi/restaurant/dto/RestaurantMenuListResponse.java @@ -1,6 +1,7 @@ package org.sopt.hashi.restaurant.dto; import java.util.List; +import org.sopt.hashi.media.MediaImage; public record RestaurantMenuListResponse( List content, @@ -13,9 +14,22 @@ public record RestaurantMenuResponse( String name, String description, String imageUrl, + MediaImage listImage, String currency, Long price, boolean main ) { + + public RestaurantMenuResponse( + Long menuId, + String name, + String description, + String imageUrl, + String currency, + Long price, + boolean main + ) { + this(menuId, name, description, imageUrl, null, currency, price, main); + } } } diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index e80b2279..9bf10163 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -23,6 +23,10 @@ import lombok.extern.slf4j.Slf4j; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; @@ -31,6 +35,7 @@ import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.AdminRestaurantInfo.AdminRestaurantBusinessHourInfo; import org.sopt.hashi.restaurant.AdminRestaurantInfo.AdminRestaurantMenuInfo; +import org.sopt.hashi.restaurant.RestaurantImageInfo; import org.sopt.hashi.restaurant.code.RestaurantErrorCode; import org.sopt.hashi.restaurant.domain.Restaurant; import org.sopt.hashi.restaurant.domain.RestaurantCurationType; @@ -145,13 +150,16 @@ public RestaurantListResponse getRestaurants( pageContent, businessDate.getDayOfWeek() ); + MediaProjection mediaProjection = loadRestaurantProjection( + pageContent, MediaImageRole.RESTAURANT_CARD, 3); return new RestaurantListResponse( pageContent.stream() .map(restaurant -> toSummaryResponse( restaurant, businessDate, - businessHours.get(restaurant.getId()) + businessHours.get(restaurant.getId()), + mediaProjection )) .toList(), nextCursor, @@ -203,6 +211,15 @@ public RestaurantMainResponse getRandomRestaurantRecommendation(Long excludeRest public RestaurantMainResponse getRestaurantSummary(Long restaurantId) { Restaurant restaurant = restaurantRepository.findActiveByIdWithImages(restaurantId) .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); + MediaProjection mediaProjection = loadRestaurantProjection( + List.of(restaurant), MediaImageRole.RESTAURANT_HERO, Integer.MAX_VALUE); + List orderedImages = orderedImages(restaurant, Integer.MAX_VALUE); + ProjectedImage thumbnail = orderedImages.isEmpty() + ? ProjectedImage.empty() + : projectImage( + orderedImages.getFirst(), + MediaImageRole.RESTAURANT_THUMBNAIL, + mediaProjection); return new RestaurantMainResponse( restaurant.getId(), @@ -213,8 +230,10 @@ public RestaurantMainResponse getRestaurantSummary(Long restaurantId) { restaurant.getSummary(), restaurant.getFoodCategory(), restaurant.getAddress(), - fileStorage.resolveFileUrl(restaurant.getThumbnailFileKey()), - toImageUrls(restaurant), + thumbnail.url(), + toImageInfo(orderedImages.isEmpty() ? null : orderedImages.getFirst(), thumbnail), + toImageUrls(orderedImages, MediaImageRole.RESTAURANT_HERO, mediaProjection), + toImageInfos(orderedImages, MediaImageRole.RESTAURANT_HERO, mediaProjection), RestaurantReservationPolicy.RESERVATION_FEE ); } @@ -264,10 +283,12 @@ public RestaurantMenuListResponse getRestaurantMenus( ? new ArrayList<>(menus.subList(0, pageSize)) : menus; Long nextCursor = hasNext ? pageContent.getLast().getId() : null; + MediaProjection mediaProjection = loadMenuProjection( + pageContent, MediaImageRole.MENU_LIST); return new RestaurantMenuListResponse( pageContent.stream() - .map(this::toMenuResponse) + .map(menu -> toMenuResponse(menu, mediaProjection)) .toList(), nextCursor, hasNext @@ -278,12 +299,17 @@ public RestaurantMenuDetailResponse getRestaurantMenu(Long restaurantId, Long me RestaurantMenu menu = restaurantRepository.findMenuByRestaurantIdAndMenuId(restaurantId, menuId) .orElseThrow(() -> menuNotFoundException(restaurantId)); long otherMenuCount = restaurantRepository.countOtherMenusByRestaurantId(restaurantId, menuId); + MediaProjection mediaProjection = loadMenuProjection( + List.of(menu), MediaImageRole.MENU_DETAIL); + ProjectedImage menuImage = projectMenuImage( + menu, MediaImageRole.MENU_DETAIL, mediaProjection); return new RestaurantMenuDetailResponse( menu.getId(), menu.getName(), menu.getDescription(), - fileStorage.resolveFileUrl(menu.getImageKey()), + menuImage.url(), + menuImage.image(), menu.getPriceCurrency() == null ? null : menu.getPriceCurrency().value(), toWholeAmount(menu.getPriceAmount()), menu.isMain(), @@ -333,6 +359,7 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { reconcileMediaBindings(claims, List.of()); Restaurant saved = restaurantRepository.save(restaurant); + restaurantRepository.flush(); // 생성된 id는 응답 body에만 있어 로그로 남겨야 추적 가능하다 (adminId는 MDC) log.info("어드민 식당 등록. restaurantId={}", saved.getId()); return toAdminInfo(saved); @@ -502,14 +529,25 @@ private Map findBusinessHours( private RestaurantSummaryResponse toSummaryResponse( Restaurant restaurant, LocalDate businessDate, - RestaurantBusinessHour businessHour + RestaurantBusinessHour businessHour, + MediaProjection mediaProjection ) { + List orderedImages = orderedImages(restaurant, 3); + ProjectedImage thumbnail = orderedImages.isEmpty() + ? ProjectedImage.empty() + : projectImage( + orderedImages.getFirst(), + MediaImageRole.RESTAURANT_THUMBNAIL, + mediaProjection); + return new RestaurantSummaryResponse( restaurant.getId(), restaurant.getName(), restaurant.getRating(), - fileStorage.resolveFileUrl(restaurant.getThumbnailFileKey()), - toImageUrls(restaurant, 3), + thumbnail.url(), + toImageInfo(orderedImages.isEmpty() ? null : orderedImages.getFirst(), thumbnail), + toImageUrls(orderedImages, MediaImageRole.RESTAURANT_CARD, mediaProjection), + toImageInfos(orderedImages, MediaImageRole.RESTAURANT_CARD, mediaProjection), restaurant.getArea(), restaurant.getGenre().description(), restaurant.getFoodCategory(), @@ -536,19 +574,140 @@ private TodayBusinessHourResponse toTodayBusinessHourResponse( ); } - private List toImageUrls(Restaurant restaurant) { - return toImageUrls(restaurant, Integer.MAX_VALUE); + private MediaProjection loadRestaurantProjection( + List restaurants, + MediaImageRole collectionRole, + int imageLimit + ) { + List requests = new ArrayList<>(); + for (Restaurant restaurant : restaurants) { + List images = orderedImages(restaurant, imageLimit); + images.stream() + .map(RestaurantImage::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest(assetId, collectionRole)) + .forEach(requests::add); + if (!images.isEmpty() && images.getFirst().getImageAssetId() != null) { + requests.add(new MediaImageRequest( + images.getFirst().getImageAssetId(), + MediaImageRole.RESTAURANT_THUMBNAIL)); + } + } + return loadProjection(requests); } - private List toImageUrls(Restaurant restaurant, int limit) { + private MediaProjection loadMenuProjection( + List menus, + MediaImageRole role + ) { + return loadProjection(menus.stream() + .map(RestaurantMenu::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest(assetId, role)) + .toList()); + } + + private MediaProjection loadAdminProjection( + List images, + List menus + ) { + List requests = new ArrayList<>(); + images.stream() + .map(RestaurantImage::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_HERO)) + .forEach(requests::add); + if (!images.isEmpty() && images.getFirst().getImageAssetId() != null) { + requests.add(new MediaImageRequest( + images.getFirst().getImageAssetId(), + MediaImageRole.RESTAURANT_THUMBNAIL)); + } + menus.stream() + .map(RestaurantMenu::getImageAssetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest(assetId, MediaImageRole.MENU_LIST)) + .forEach(requests::add); + return loadProjection(requests); + } + + private MediaProjection loadProjection(List requests) { + List distinctRequests = requests.stream().distinct().toList(); + if (distinctRequests.isEmpty()) { + return MediaProjection.empty(); + } + return new MediaProjection(mediaPort.findImages(distinctRequests)); + } + + private List orderedImages(Restaurant restaurant, int limit) { return restaurant.getImages().stream() .sorted(Comparator.comparingInt(RestaurantImage::getDisplayOrder)) .limit(limit) - .map(RestaurantImage::getFileKey) - .map(fileStorage::resolveFileUrl) .toList(); } + private List toImageUrls( + List images, + MediaImageRole role, + MediaProjection mediaProjection + ) { + return images.stream() + .map(image -> projectImage(image, role, mediaProjection).url()) + .filter(Objects::nonNull) + .toList(); + } + + private List toImageInfos( + List images, + MediaImageRole role, + MediaProjection mediaProjection + ) { + return images.stream() + .map(image -> toImageInfo(image, projectImage(image, role, mediaProjection))) + .toList(); + } + + private RestaurantImageInfo toImageInfo(RestaurantImage image, ProjectedImage projectedImage) { + if (image == null) { + return null; + } + return new RestaurantImageInfo( + image.getId(), image.getDisplayOrder(), projectedImage.image()); + } + + private ProjectedImage projectImage( + RestaurantImage image, + MediaImageRole role, + MediaProjection mediaProjection + ) { + if (image.getImageAssetId() == null) { + return new ProjectedImage(resolveLegacyUrl(image.getFileKey()), null); + } + MediaImage mediaImage = mediaProjection.find(image.getImageAssetId(), role); + return new ProjectedImage(readyUrl(mediaImage), mediaImage); + } + + private ProjectedImage projectMenuImage( + RestaurantMenu menu, + MediaImageRole role, + MediaProjection mediaProjection + ) { + if (menu.getImageAssetId() == null) { + return new ProjectedImage(resolveLegacyUrl(menu.getImageKey()), null); + } + MediaImage mediaImage = mediaProjection.find(menu.getImageAssetId(), role); + return new ProjectedImage(readyUrl(mediaImage), mediaImage); + } + + private String resolveLegacyUrl(String fileKey) { + return fileKey == null ? null : fileStorage.resolveFileUrl(fileKey); + } + + private String readyUrl(MediaImage mediaImage) { + return mediaImage != null && mediaImage.status() == MediaImageStatus.READY + ? mediaImage.defaultSource().url() + : null; + } + private BusinessHourResponse toBusinessHourResponse(RestaurantBusinessHour businessHour) { return new BusinessHourResponse( businessHour.getDayOfWeek().name(), @@ -560,12 +719,18 @@ private BusinessHourResponse toBusinessHourResponse(RestaurantBusinessHour busin ); } - private RestaurantMenuResponse toMenuResponse(RestaurantMenu menu) { + private RestaurantMenuResponse toMenuResponse( + RestaurantMenu menu, + MediaProjection mediaProjection + ) { + ProjectedImage menuImage = projectMenuImage( + menu, MediaImageRole.MENU_LIST, mediaProjection); return new RestaurantMenuResponse( menu.getId(), menu.getName(), menu.getDescription(), - fileStorage.resolveFileUrl(menu.getImageKey()), + menuImage.url(), + menuImage.image(), menu.getPriceCurrency() == null ? null : menu.getPriceCurrency().value(), toWholeAmount(menu.getPriceAmount()), menu.isMain() @@ -1051,6 +1216,16 @@ private List toCurationTypes(List values) { } private AdminRestaurantInfo toAdminInfo(Restaurant restaurant) { + List orderedImages = orderedImages(restaurant, Integer.MAX_VALUE); + List menus = List.copyOf(restaurant.getMenus()); + MediaProjection mediaProjection = loadAdminProjection(orderedImages, menus); + ProjectedImage thumbnail = orderedImages.isEmpty() + ? ProjectedImage.empty() + : projectImage( + orderedImages.getFirst(), + MediaImageRole.RESTAURANT_THUMBNAIL, + mediaProjection); + return new AdminRestaurantInfo( restaurant.getId(), restaurant.getName(), @@ -1061,17 +1236,16 @@ private AdminRestaurantInfo toAdminInfo(Restaurant restaurant) { restaurant.getArea(), restaurant.getGenre().value(), restaurant.getFoodCategory(), - fileStorage.resolveFileUrl(restaurant.getThumbnailFileKey()), + thumbnail.url(), + toImageInfo(orderedImages.isEmpty() ? null : orderedImages.getFirst(), thumbnail), restaurant.getPriceCurrency().value(), restaurant.getMinPrice(), restaurant.getMaxPrice(), restaurant.isDeleted(), - restaurant.getImages().stream() - .map(RestaurantImage::getFileKey) - .map(fileStorage::resolveFileUrl) - .toList(), - restaurant.getMenus().stream() - .map(this::toAdminMenuInfo) + toImageUrls(orderedImages, MediaImageRole.RESTAURANT_HERO, mediaProjection), + toImageInfos(orderedImages, MediaImageRole.RESTAURANT_HERO, mediaProjection), + menus.stream() + .map(menu -> toAdminMenuInfo(menu, mediaProjection)) .toList(), List.copyOf(restaurant.getHashtags()), restaurant.getCurationTypes().stream() @@ -1094,12 +1268,18 @@ private AdminRestaurantBusinessHourInfo toAdminBusinessHourInfo(RestaurantBusine businessHour.isClosed()); } - private AdminRestaurantMenuInfo toAdminMenuInfo(RestaurantMenu menu) { + private AdminRestaurantMenuInfo toAdminMenuInfo( + RestaurantMenu menu, + MediaProjection mediaProjection + ) { + ProjectedImage menuImage = projectMenuImage( + menu, MediaImageRole.MENU_LIST, mediaProjection); return new AdminRestaurantMenuInfo( menu.getId(), menu.getName(), menu.getDescription(), - fileStorage.resolveFileUrl(menu.getImageKey()), + menuImage.url(), + menuImage.image(), menu.getPriceCurrency() == null ? null : menu.getPriceCurrency().value(), menu.getPriceAmount(), menu.isMain()); @@ -1159,6 +1339,28 @@ private record MenuMutation( private record ResolvedMenuImage(String imageKey, UUID imageAssetId) { } + private record MediaProjection(Map images) { + + private MediaProjection { + images = Map.copyOf(images); + } + + private static MediaProjection empty() { + return new MediaProjection(Map.of()); + } + + private MediaImage find(UUID assetId, MediaImageRole role) { + return images.get(new MediaImageRequest(assetId, role)); + } + } + + private record ProjectedImage(String url, MediaImage image) { + + private static ProjectedImage empty() { + return new ProjectedImage(null, null); + } + } + private String formatTime(LocalTime time) { return time == null ? null : time.format(TIME_FORMATTER); } diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index ee117eb5..41af9d73 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -18,6 +18,7 @@ import java.time.LocalTime; import java.time.ZoneId; import java.util.List; +import java.util.Map; import java.util.Optional; import java.util.Set; import java.util.UUID; @@ -29,9 +30,13 @@ import org.mockito.ArgumentMatchers; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; -import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaAssetUse; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; @@ -319,6 +324,55 @@ private RestaurantService createRestaurantService() { .containsExactly(menuAssetId); } + @Test + void 어드민_응답은_식당과_메뉴_이미지를_한번에_bulk_조회한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID restaurantAssetId = UUID.randomUUID(); + UUID menuAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of(image(101L, null, restaurantAssetId, 1))); + restaurant.addMenu(createAssetMenu(201L, "asset 메뉴", true, menuAssetId)); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + MediaImage thumbnail = readyImage( + restaurantAssetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + "https://cdn.example.com/media/admin-thumbnail.webp"); + MediaImage hero = readyImage( + restaurantAssetId, + MediaImageRole.RESTAURANT_HERO, + "https://cdn.example.com/media/admin-hero.webp"); + MediaImage menuList = readyImage( + menuAssetId, + MediaImageRole.MENU_LIST, + "https://cdn.example.com/media/admin-menu.webp"); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(restaurantAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), thumbnail, + new MediaImageRequest(restaurantAssetId, MediaImageRole.RESTAURANT_HERO), hero, + new MediaImageRequest(menuAssetId, MediaImageRole.MENU_LIST), menuList + )); + + var response = restaurantService.updateByAdmin(1L, updateMenuCommand(null)); + + assertThat(response.thumbnailUrl()).isEqualTo(thumbnail.defaultSource().url()); + assertThat(response.thumbnailImage().restaurantImageId()).isEqualTo(101L); + assertThat(response.imageUrls()).containsExactly(hero.defaultSource().url()); + assertThat(response.heroImages()).singleElement() + .satisfies(image -> assertThat(image.image()).isEqualTo(hero)); + assertThat(response.menus()).singleElement() + .satisfies(menu -> { + assertThat(menu.imageUrl()).isEqualTo(menuList.defaultSource().url()); + assertThat(menu.listImage()).isEqualTo(menuList); + }); + verify(mediaPort).findImages(argThat(requests -> Set.copyOf(requests).equals(Set.of( + new MediaImageRequest( + restaurantAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + new MediaImageRequest(restaurantAssetId, MediaImageRole.RESTAURANT_HERO), + new MediaImageRequest(menuAssetId, MediaImageRole.MENU_LIST) + )))); + verify(mediaPort, never()).reconcileBindings(any(), any()); + verify(fileStorage, never()).resolveFileUrl(any()); + } + @Test void ordered_wrapper는_기존_association_ID를_유지하고_추가와_제거만_전이한다() { RestaurantService restaurantService = createRestaurantService(); @@ -375,7 +429,7 @@ private RestaurantService createRestaurantService() { .containsExactly(103L, 101L, 102L); assertThat(restaurant.getImages().get(1).getImageAssetId()) .isEqualTo(backfilledAssetId); - verifyNoInteractions(mediaPort); + verify(mediaPort, never()).reconcileBindings(any(), any()); } @Test @@ -418,7 +472,7 @@ private RestaurantService createRestaurantService() { assertThat(menu.getImageAssetId()).isEqualTo(backfilledAssetId); assertThat(menu.getImageKey()).isEqualTo("restaurant-menus/10.jpg"); - verifyNoInteractions(mediaPort); + verify(mediaPort, never()).reconcileBindings(any(), any()); } @Test @@ -761,8 +815,8 @@ void getRestaurantSummary_returns_main_information() { Restaurant restaurant = createRestaurant(1L, 4.8, 100L); ReflectionTestUtils.setField(restaurant, "reviewCount", 256L); restaurant.replaceImages(List.of( - RestaurantImage.create("restaurants/1/images/1.jpg", 1), - RestaurantImage.create("restaurants/1/images/2.jpg", 2) + image(101L, "restaurants/1/images/1.jpg", null, 1), + image(102L, "restaurants/1/images/2.jpg", null, 2) )); given(restaurantRepository.findActiveByIdWithImages(1L)).willReturn(Optional.of(restaurant)); given(fileStorage.resolveFileUrl("restaurants/1/images/1.jpg")) @@ -781,7 +835,161 @@ void getRestaurantSummary_returns_main_information() { "https://cdn.example.com/restaurants/1/images/1.jpg", "https://cdn.example.com/restaurants/1/images/2.jpg" ); + assertThat(response.thumbnailImage().restaurantImageId()).isEqualTo(101L); + assertThat(response.thumbnailImage().image()).isNull(); + assertThat(response.heroImages()) + .extracting(image -> image.restaurantImageId(), image -> image.displayOrder()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(101L, 1), + org.assertj.core.groups.Tuple.tuple(102L, 2)); + assertThat(response.heroImages()) + .allSatisfy(image -> assertThat(image.image()).isNull()); assertThat(response.reservationFee()).isEqualTo(4_000L); + verifyNoInteractions(mediaPort); + } + + @Test + void media_READY_식당은_role별_URL과_신규_이미지_객체를_반환한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID assetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of( + image(101L, "restaurants/1/legacy.jpg", assetId, 1) + )); + MediaImage thumbnail = readyImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + "https://cdn.example.com/media/thumbnail.webp"); + MediaImage hero = readyImage( + assetId, + MediaImageRole.RESTAURANT_HERO, + "https://cdn.example.com/media/hero.webp"); + given(restaurantRepository.findActiveByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL), thumbnail, + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_HERO), hero + )); + + var response = restaurantService.getRestaurantSummary(1L); + + assertThat(response.thumbnailUrl()).isEqualTo(thumbnail.defaultSource().url()); + assertThat(response.thumbnailImage().restaurantImageId()).isEqualTo(101L); + assertThat(response.thumbnailImage().image()).isEqualTo(thumbnail); + assertThat(response.imageUrls()).containsExactly(hero.defaultSource().url()); + assertThat(response.heroImages()).singleElement() + .satisfies(image -> assertThat(image.image()).isEqualTo(hero)); + verify(mediaPort).findImages(argThat(requests -> Set.copyOf(requests).equals(Set.of( + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL), + new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_HERO) + )))); + verify(fileStorage, never()).resolveFileUrl(any()); + } + + @Test + void media_PROCESSING과_FAILED는_legacy_URL로_우회하지_않고_슬롯을_유지한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID processingAssetId = UUID.randomUUID(); + UUID failedAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of( + image(101L, "restaurants/1/processing.jpg", processingAssetId, 1), + image(102L, "restaurants/1/failed.jpg", failedAssetId, 2) + )); + given(restaurantRepository.findActiveByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(processingAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + statusImage( + processingAssetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.PROCESSING), + new MediaImageRequest(processingAssetId, MediaImageRole.RESTAURANT_HERO), + statusImage( + processingAssetId, + MediaImageRole.RESTAURANT_HERO, + MediaImageStatus.PROCESSING), + new MediaImageRequest(failedAssetId, MediaImageRole.RESTAURANT_HERO), + statusImage( + failedAssetId, + MediaImageRole.RESTAURANT_HERO, + MediaImageStatus.FAILED) + )); + + var response = restaurantService.getRestaurantSummary(1L); + + assertThat(response.thumbnailUrl()).isNull(); + assertThat(response.thumbnailImage().image().status()) + .isEqualTo(MediaImageStatus.PROCESSING); + assertThat(response.imageUrls()).isEmpty(); + assertThat(response.heroImages()) + .extracting(image -> image.restaurantImageId(), image -> image.image().status()) + .containsExactly( + org.assertj.core.groups.Tuple.tuple(101L, MediaImageStatus.PROCESSING), + org.assertj.core.groups.Tuple.tuple(102L, MediaImageStatus.FAILED)); + verify(fileStorage, never()).resolveFileUrl(any()); + } + + @Test + void media_asset_조회가_불일치해도_legacy_URL로_우회하지_않는다() { + RestaurantService restaurantService = createRestaurantService(); + UUID assetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of( + image(101L, "restaurants/1/backfilled.jpg", assetId, 1) + )); + given(restaurantRepository.findActiveByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + given(mediaPort.findImages(any())).willReturn(Map.of()); + + var response = restaurantService.getRestaurantSummary(1L); + + assertThat(response.thumbnailUrl()).isNull(); + assertThat(response.thumbnailImage().image()).isNull(); + assertThat(response.imageUrls()).isEmpty(); + assertThat(response.heroImages()).singleElement() + .satisfies(image -> { + assertThat(image.restaurantImageId()).isEqualTo(101L); + assertThat(image.image()).isNull(); + }); + verify(fileStorage, never()).resolveFileUrl(any()); + } + + @Test + void 식당_목록은_페이지에_포함된_asset만_한번에_bulk_조회한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID firstAssetId = UUID.randomUUID(); + UUID secondAssetId = UUID.randomUUID(); + UUID excludedAssetId = UUID.randomUUID(); + Restaurant first = createRestaurant(1L, 4.8, 100L); + Restaurant second = createRestaurant(2L, 4.7, 90L); + Restaurant excluded = createRestaurant(3L, 4.6, 80L); + first.replaceImages(List.of(image(101L, null, firstAssetId, 1))); + second.replaceImages(List.of(image(201L, null, secondAssetId, 1))); + excluded.replaceImages(List.of(image(301L, null, excludedAssetId, 1))); + givenRestaurants(List.of(first, second, excluded)); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(firstAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + readyImage(firstAssetId, MediaImageRole.RESTAURANT_THUMBNAIL, "https://cdn/1-thumb.webp"), + new MediaImageRequest(firstAssetId, MediaImageRole.RESTAURANT_CARD), + readyImage(firstAssetId, MediaImageRole.RESTAURANT_CARD, "https://cdn/1-card.webp"), + new MediaImageRequest(secondAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + readyImage(secondAssetId, MediaImageRole.RESTAURANT_THUMBNAIL, "https://cdn/2-thumb.webp"), + new MediaImageRequest(secondAssetId, MediaImageRole.RESTAURANT_CARD), + readyImage(secondAssetId, MediaImageRole.RESTAURANT_CARD, "https://cdn/2-card.webp") + )); + + RestaurantListResponse response = restaurantService.getRestaurants( + null, null, null, null, null, 2); + + assertThat(response.content()).hasSize(2); + assertThat(response.content().getFirst().thumbnailUrl()) + .isEqualTo("https://cdn/1-thumb.webp"); + assertThat(response.content().getFirst().imageUrls()) + .containsExactly("https://cdn/1-card.webp"); + verify(mediaPort).findImages(argThat(requests -> Set.copyOf(requests).equals(Set.of( + new MediaImageRequest(firstAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + new MediaImageRequest(firstAssetId, MediaImageRole.RESTAURANT_CARD), + new MediaImageRequest(secondAssetId, MediaImageRole.RESTAURANT_THUMBNAIL), + new MediaImageRequest(secondAssetId, MediaImageRole.RESTAURANT_CARD) + )))); } @Test @@ -959,6 +1167,60 @@ void getRestaurantMenus_returns_cursor_page() { verify(restaurantRepository, never()).existsByIdAndDeletedFalse(any()); } + @Test + void media_메뉴_목록은_MENU_LIST_role을_bulk_조회한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID assetId = UUID.randomUUID(); + RestaurantMenu menu = createAssetMenu(10L, "Shio Ramen", true, assetId); + MediaImage listImage = readyImage( + assetId, MediaImageRole.MENU_LIST, "https://cdn.example.com/media/menu-list.webp"); + given(restaurantRepository.existsByIdAndDeletedFalse(1L)).willReturn(true); + given(restaurantRepository.findMenusByRestaurantId( + ArgumentMatchers.eq(1L), + ArgumentMatchers.isNull(), + ArgumentMatchers.isNull(), + ArgumentMatchers.isNull(), + ArgumentMatchers.isNull(), + any(Pageable.class) + )).willReturn(List.of(menu)); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(assetId, MediaImageRole.MENU_LIST), listImage)); + + RestaurantMenuListResponse response = restaurantService.getRestaurantMenus( + 1L, null, null, 10); + + assertThat(response.content()).singleElement() + .satisfies(item -> { + assertThat(item.imageUrl()).isEqualTo(listImage.defaultSource().url()); + assertThat(item.listImage()).isEqualTo(listImage); + }); + verify(mediaPort).findImages(argThat(requests -> List.copyOf(requests).equals(List.of( + new MediaImageRequest(assetId, MediaImageRole.MENU_LIST))))); + } + + @Test + void media_메뉴_상세_PROCESSING은_원본_URL로_우회하지_않는다() { + RestaurantService restaurantService = createRestaurantService(); + UUID assetId = UUID.randomUUID(); + RestaurantMenu menu = createAssetMenu(10L, "Shio Ramen", true, assetId); + ReflectionTestUtils.setField(menu, "imageKey", "restaurant-menus/legacy.jpg"); + MediaImage processing = statusImage( + assetId, MediaImageRole.MENU_DETAIL, MediaImageStatus.PROCESSING); + given(restaurantRepository.findMenuByRestaurantIdAndMenuId(1L, 10L)) + .willReturn(Optional.of(menu)); + given(restaurantRepository.countOtherMenusByRestaurantId(1L, 10L)).willReturn(6L); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(assetId, MediaImageRole.MENU_DETAIL), processing)); + + RestaurantMenuDetailResponse response = restaurantService.getRestaurantMenu(1L, 10L); + + assertThat(response.imageUrl()).isNull(); + assertThat(response.detailImage()).isEqualTo(processing); + verify(mediaPort).findImages(argThat(requests -> List.copyOf(requests).equals(List.of( + new MediaImageRequest(assetId, MediaImageRole.MENU_DETAIL))))); + verify(fileStorage, never()).resolveFileUrl(any()); + } + @Test void 존재하는_식당에서_메뉴를_찾을_수_없으면_메뉴_도메인_에러를_반환한다() { RestaurantService restaurantService = createRestaurantService(); @@ -1156,4 +1418,44 @@ private RestaurantMenu createMenu(Long id, String name, boolean representative) ReflectionTestUtils.setField(menu, "id", id); return menu; } + + private RestaurantMenu createAssetMenu( + Long id, + String name, + boolean representative, + UUID assetId + ) { + RestaurantMenu menu = RestaurantMenu.createWithAsset( + name, + "menu description", + assetId, + PriceCurrency.JPY, + BigDecimal.valueOf(1200), + representative + ); + ReflectionTestUtils.setField(menu, "id", id); + return menu; + } + + private MediaImage readyImage(UUID assetId, MediaImageRole role, String url) { + MediaImage.Source source = new MediaImage.Source(url, 270, 270, "image/webp"); + return new MediaImage( + assetId, + role, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(url, 270, 270)) + )) + ); + } + + private MediaImage statusImage( + UUID assetId, + MediaImageRole role, + MediaImageStatus status + ) { + return new MediaImage(assetId, role, status, null, List.of()); + } } diff --git a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java index e8dd55c6..7000f338 100644 --- a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java @@ -9,9 +9,14 @@ import java.math.BigDecimal; import java.util.List; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.restaurant.RestaurantImageInfo; import org.sopt.hashi.restaurant.code.RestaurantErrorCode; import org.sopt.hashi.restaurant.dto.RestaurantListResponse; import org.sopt.hashi.restaurant.dto.RestaurantMainResponse; @@ -65,6 +70,69 @@ class RestaurantControllerTest { .getRestaurants("sushi", "sushi", "popular", "sns-hot", null, 20); } + @Test + void 식당_요약은_stable_association과_반응형_이미지_계약을_직렬화한다() throws Exception { + UUID assetId = UUID.randomUUID(); + String imageUrl = "https://cdn.example.com/media/hero.webp"; + MediaImage.Source source = new MediaImage.Source( + imageUrl, 860, 512, "image/webp"); + MediaImage heroImage = new MediaImage( + assetId, + MediaImageRole.RESTAURANT_HERO, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(imageUrl, 860, 512)) + )) + ); + String thumbnailUrl = "https://cdn.example.com/media/thumbnail.webp"; + MediaImage thumbnailImage = new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.READY, + new MediaImage.Source(thumbnailUrl, 192, 192, "image/webp"), + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(thumbnailUrl, 192, 192)) + )) + ); + RestaurantImageInfo thumbnailInfo = new RestaurantImageInfo(101L, 1, thumbnailImage); + RestaurantImageInfo heroInfo = new RestaurantImageInfo(101L, 1, heroImage); + RestaurantMainResponse response = new RestaurantMainResponse( + 1L, + "히마와리 스시", + "ひまわり寿司", + BigDecimal.valueOf(4.8), + 100L, + "장인의 스시를 즐겨보세요.", + "초밥", + "도쿄도 신주쿠구", + thumbnailUrl, + thumbnailInfo, + List.of(imageUrl), + List.of(heroInfo), + 4_000L + ); + given(restaurantService.getRestaurantSummary(1L)).willReturn(response); + + mockMvc.perform(get("/api/v1/restaurants/{restaurantId}/summary", 1L)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.data.thumbnailImage.restaurantImageId").value(101)) + .andExpect(jsonPath("$.data.thumbnailImage.displayOrder").value(1)) + .andExpect(jsonPath("$.data.thumbnailImage.image.assetId") + .value(assetId.toString())) + .andExpect(jsonPath("$.data.thumbnailImage.image.role") + .value("RESTAURANT_THUMBNAIL")) + .andExpect(jsonPath("$.data.thumbnailImage.image.status").value("READY")) + .andExpect(jsonPath("$.data.heroImages[0].image.defaultSource.url") + .value(imageUrl)) + .andExpect(jsonPath("$.data.heroImages[0].image.sourceSets[0].candidates[0].width") + .value(860)); + + verify(restaurantService).getRestaurantSummary(1L); + } + @Test void 랜덤_추천_요청이면_현재_식당을_제외하고_공통_성공_응답을_반환한다() throws Exception { RestaurantMainResponse response = createResponse(2L); From 5e4e0a0edaf325145ad5e43a5372280ed7e1eb78 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 28 Aug 2026 01:18:32 +0900 Subject: [PATCH 05/26] =?UTF-8?q?feat(reservation):=20=EC=8B=9D=EB=8B=B9?= =?UTF-8?q?=20=EC=8D=B8=EB=84=A4=EC=9D=BC=20media=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/AdminReservationListResponse.java | 10 +- .../admin/dto/AdminReservationResponse.java | 11 +- .../service/AdminReservationService.java | 100 ++++++- .../reservation/AdminReservationInfo.java | 3 +- .../dto/ReservationDetailResponse.java | 31 ++- .../reservation/dto/ReservationResponse.java | 22 +- .../service/ReservationService.java | 118 ++++++++- .../restaurant/RestaurantDetailInfo.java | 32 ++- .../sopt/hashi/restaurant/RestaurantInfo.java | 31 ++- .../hashi/restaurant/domain/Restaurant.java | 10 +- .../domain/RestaurantRepository.java | 10 + .../service/RestaurantPortImpl.java | 25 +- .../service/AdminReservationServiceTest.java | 145 +++++++++++ .../RestaurantPortContractTest.java | 8 +- .../service/ReservationServiceTest.java | 245 ++++++++++++++++++ .../service/RestaurantPortImplTest.java | 58 ++++- .../RestaurantServiceIntegrationTest.java | 23 ++ 17 files changed, 839 insertions(+), 43 deletions(-) create mode 100644 src/test/java/org/sopt/hashi/admin/service/AdminReservationServiceTest.java create mode 100644 src/test/java/org/sopt/hashi/reservation/service/ReservationServiceTest.java diff --git a/src/main/java/org/sopt/hashi/admin/dto/AdminReservationListResponse.java b/src/main/java/org/sopt/hashi/admin/dto/AdminReservationListResponse.java index 80ba3bdd..50a50ce6 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/AdminReservationListResponse.java +++ b/src/main/java/org/sopt/hashi/admin/dto/AdminReservationListResponse.java @@ -12,12 +12,12 @@ public record AdminReservationListResponse( long totalCount, int totalPages) { - public static AdminReservationListResponse from(Page page) { - List reservations = page.getContent().stream() - .map(AdminReservationResponse::from) - .toList(); + public static AdminReservationListResponse from( + Page page, + List reservations + ) { return new AdminReservationListResponse( - reservations, + List.copyOf(reservations), page.getNumber(), page.getSize(), page.getTotalElements(), diff --git a/src/main/java/org/sopt/hashi/admin/dto/AdminReservationResponse.java b/src/main/java/org/sopt/hashi/admin/dto/AdminReservationResponse.java index 28505a0c..3be3854e 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/AdminReservationResponse.java +++ b/src/main/java/org/sopt/hashi/admin/dto/AdminReservationResponse.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.reservation.AdminReservationInfo; import org.sopt.hashi.reservation.PaymentStatus; import org.sopt.hashi.reservation.ReservationStatus; @@ -19,6 +20,7 @@ public record AdminReservationResponse( Long restaurantId, String restaurantName, String restaurantImageUrl, + MediaImage restaurantThumbnailImage, String restaurantAddress, LocalDateTime reservedAt, int adultCount, @@ -31,7 +33,11 @@ public record AdminReservationResponse( BigDecimal amount, Long confirmDDay) { - public static AdminReservationResponse from(AdminReservationInfo info) { + public static AdminReservationResponse from( + AdminReservationInfo info, + String restaurantImageUrl, + MediaImage restaurantThumbnailImage + ) { return new AdminReservationResponse( info.reservationId(), info.userId(), @@ -39,7 +45,8 @@ public static AdminReservationResponse from(AdminReservationInfo info) { info.reserverName(), info.restaurantId(), info.restaurantName(), - info.restaurantImageUrl(), + restaurantImageUrl, + restaurantThumbnailImage, info.restaurantAddress(), info.reservedAt(), info.adultCount(), diff --git a/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java b/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java index 66a53edb..bd0c8b73 100644 --- a/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java +++ b/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java @@ -1,10 +1,22 @@ package org.sopt.hashi.admin.service; +import java.util.Collection; +import java.util.List; +import java.util.Map; +import java.util.Objects; import org.sopt.hashi.admin.dto.AdminReservationListResponse; import org.sopt.hashi.admin.dto.AdminReservationResponse; import org.sopt.hashi.admin.dto.AdminReservationUserResponse; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.reservation.AdminReservationInfo; import org.sopt.hashi.reservation.ReservationPort; import org.sopt.hashi.reservation.ReservationStatus; +import org.springframework.data.domain.Page; import org.springframework.stereotype.Service; /** @@ -15,24 +27,104 @@ public class AdminReservationService { private final ReservationPort reservationPort; + private final MediaPort mediaPort; - public AdminReservationService(ReservationPort reservationPort) { + public AdminReservationService(ReservationPort reservationPort, MediaPort mediaPort) { this.reservationPort = reservationPort; + this.mediaPort = mediaPort; } /** 예약 상태 변경(자유 전이) — CANCELED 진입 시 포인트 복원 규칙은 reservation이 적용한다. */ public AdminReservationResponse changeStatus(Long reservationId, ReservationStatus targetStatus) { - return AdminReservationResponse.from( - reservationPort.changeStatusByAdmin(reservationId, targetStatus)); + AdminReservationInfo info = reservationPort.changeStatusByAdmin(reservationId, targetStatus); + MediaProjection mediaProjection = loadThumbnailProjection( + referenceList(info.restaurantImageReference())); + return toResponse(info, mediaProjection); } /** 예약 목록 — 전체 사용자 대상 offset 페이지네이션(최신순), status가 null이면 전체. */ public AdminReservationListResponse getReservations(ReservationStatus status, int page, int size) { - return AdminReservationListResponse.from(reservationPort.findPageByAdmin(status, page, size)); + Page reservations = reservationPort.findPageByAdmin(status, page, size); + MediaProjection mediaProjection = loadThumbnailProjection( + reservations.getContent().stream() + .map(AdminReservationInfo::restaurantImageReference) + .toList()); + List content = reservations.getContent().stream() + .map(info -> toResponse(info, mediaProjection)) + .toList(); + return AdminReservationListResponse.from(reservations, content); } /** 예약자 정보 조회 — 예약의 예약자 enrich는 reservation 담당(§5-3), 여기서는 응답 변환만. */ public AdminReservationUserResponse getReserver(Long reservationId) { return AdminReservationUserResponse.from(reservationPort.findReserverByAdmin(reservationId)); } + + private AdminReservationResponse toResponse( + AdminReservationInfo info, + MediaProjection mediaProjection + ) { + ProjectedImage thumbnail = projectThumbnail( + info.restaurantImageReference(), mediaProjection); + return AdminReservationResponse.from(info, thumbnail.url(), thumbnail.image()); + } + + private MediaProjection loadThumbnailProjection(Collection references) { + List requests = references.stream() + .filter(Objects::nonNull) + .map(ImageReference::assetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest( + assetId, MediaImageRole.RESTAURANT_THUMBNAIL)) + .distinct() + .toList(); + if (requests.isEmpty()) { + return MediaProjection.empty(); + } + return new MediaProjection(mediaPort.findImages(requests)); + } + + private List referenceList(ImageReference reference) { + return reference == null ? List.of() : List.of(reference); + } + + private ProjectedImage projectThumbnail( + ImageReference reference, + MediaProjection mediaProjection + ) { + if (reference == null) { + return ProjectedImage.empty(); + } + if (reference.assetId() == null) { + return new ProjectedImage(reference.legacyUrl(), null); + } + MediaImage mediaImage = mediaProjection.find(reference); + String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY + ? mediaImage.defaultSource().url() + : null; + return new ProjectedImage(url, mediaImage); + } + + private record MediaProjection(Map images) { + + private MediaProjection { + images = Map.copyOf(images); + } + + private static MediaProjection empty() { + return new MediaProjection(Map.of()); + } + + private MediaImage find(ImageReference reference) { + return images.get(new MediaImageRequest( + reference.assetId(), MediaImageRole.RESTAURANT_THUMBNAIL)); + } + } + + private record ProjectedImage(String url, MediaImage image) { + + private static ProjectedImage empty() { + return new ProjectedImage(null, null); + } + } } diff --git a/src/main/java/org/sopt/hashi/reservation/AdminReservationInfo.java b/src/main/java/org/sopt/hashi/reservation/AdminReservationInfo.java index 21f8d42b..16617d55 100644 --- a/src/main/java/org/sopt/hashi/reservation/AdminReservationInfo.java +++ b/src/main/java/org/sopt/hashi/reservation/AdminReservationInfo.java @@ -2,6 +2,7 @@ import java.math.BigDecimal; import java.time.LocalDateTime; +import org.sopt.hashi.media.ImageReference; /** * 모듈 간 전달용 어드민 예약 요약 DTO — 진입점(admin)이 예약을 관리(목록·상태 변경)할 때 받는 계약. @@ -16,7 +17,7 @@ public record AdminReservationInfo( String reserverName, Long restaurantId, String restaurantName, - String restaurantImageUrl, + ImageReference restaurantImageReference, String restaurantAddress, LocalDateTime reservedAt, int adultCount, diff --git a/src/main/java/org/sopt/hashi/reservation/dto/ReservationDetailResponse.java b/src/main/java/org/sopt/hashi/reservation/dto/ReservationDetailResponse.java index 14d5f57a..cce2bab1 100644 --- a/src/main/java/org/sopt/hashi/reservation/dto/ReservationDetailResponse.java +++ b/src/main/java/org/sopt/hashi/reservation/dto/ReservationDetailResponse.java @@ -1,6 +1,7 @@ package org.sopt.hashi.reservation.dto; import java.time.LocalDateTime; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.reservation.ReservationStatus; import org.sopt.hashi.reservation.domain.Reservation; import org.sopt.hashi.reservation.ReservationType; @@ -20,6 +21,7 @@ public record ReservationDetailResponse( String restaurantNameJa, String restaurantAddress, String restaurantImageUrl, + MediaImage restaurantThumbnailImage, LocalDateTime reservedAt, int adultCount, int teenCount, @@ -31,9 +33,14 @@ public record ReservationDetailResponse( long amount) { /** 엔티티 + 유형별로 해석된 식당 표시 정보(name·nameJa·address·imageUrl)로 상세 응답을 만든다. */ - public static ReservationDetailResponse of(Reservation reservation, String restaurantName, - String restaurantNameJa, String restaurantAddress, - String restaurantImageUrl) { + public static ReservationDetailResponse of( + Reservation reservation, + String restaurantName, + String restaurantNameJa, + String restaurantAddress, + String restaurantImageUrl, + MediaImage restaurantThumbnailImage + ) { return new ReservationDetailResponse( reservation.getId(), reservation.getReservationType(), @@ -44,6 +51,7 @@ public static ReservationDetailResponse of(Reservation reservation, String resta restaurantNameJa, restaurantAddress, restaurantImageUrl, + restaurantThumbnailImage, reservation.getReservedAt(), reservation.getAdultCount(), reservation.getTeenCount(), @@ -54,4 +62,21 @@ public static ReservationDetailResponse of(Reservation reservation, String resta reservation.getUsedPoint(), reservation.getAmount().longValueExact()); } + + /** 기존 호출부의 점진 전환용 factory. */ + public static ReservationDetailResponse of( + Reservation reservation, + String restaurantName, + String restaurantNameJa, + String restaurantAddress, + String restaurantImageUrl + ) { + return of( + reservation, + restaurantName, + restaurantNameJa, + restaurantAddress, + restaurantImageUrl, + null); + } } diff --git a/src/main/java/org/sopt/hashi/reservation/dto/ReservationResponse.java b/src/main/java/org/sopt/hashi/reservation/dto/ReservationResponse.java index 865bc63e..0a729714 100644 --- a/src/main/java/org/sopt/hashi/reservation/dto/ReservationResponse.java +++ b/src/main/java/org/sopt/hashi/reservation/dto/ReservationResponse.java @@ -1,6 +1,7 @@ package org.sopt.hashi.reservation.dto; import java.time.LocalDateTime; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.reservation.ReservationStatus; import org.sopt.hashi.reservation.domain.Reservation; import org.sopt.hashi.reservation.ReservationType; @@ -16,6 +17,7 @@ public record ReservationResponse( Long restaurantId, String restaurantName, String restaurantImageUrl, + MediaImage restaurantThumbnailImage, String restaurantAddress, LocalDateTime reservedAt, int adultCount, @@ -26,8 +28,13 @@ public record ReservationResponse( Long confirmDDay) { /** 엔티티 + 유형별로 해석된 식당명·대표이미지·주소로 응답을 만든다. */ - public static ReservationResponse of(Reservation reservation, String restaurantName, - String restaurantImageUrl, String restaurantAddress) { + public static ReservationResponse of( + Reservation reservation, + String restaurantName, + String restaurantImageUrl, + MediaImage restaurantThumbnailImage, + String restaurantAddress + ) { return new ReservationResponse( reservation.getId(), reservation.getReservationType(), @@ -35,6 +42,7 @@ public static ReservationResponse of(Reservation reservation, String restaurantN reservation.getRestaurantId(), restaurantName, restaurantImageUrl, + restaurantThumbnailImage, restaurantAddress, reservation.getReservedAt(), reservation.getAdultCount(), @@ -44,4 +52,14 @@ public static ReservationResponse of(Reservation reservation, String restaurantN reservation.getReservationStatus(), reservation.confirmDDay()); } + + /** 기존 호출부의 점진 전환용 factory. */ + public static ReservationResponse of( + Reservation reservation, + String restaurantName, + String restaurantImageUrl, + String restaurantAddress + ) { + return of(reservation, restaurantName, restaurantImageUrl, null, restaurantAddress); + } } diff --git a/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java b/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java index 667d5e64..a68d00cf 100644 --- a/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java +++ b/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java @@ -1,12 +1,20 @@ package org.sopt.hashi.reservation.service; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.Optional; import java.util.Set; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.point.PointPort; import org.sopt.hashi.point.PointSourceType; import org.sopt.hashi.reservation.AdminReservationInfo; @@ -56,17 +64,20 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final RestaurantPort restaurantPort; + private final MediaPort mediaPort; private final PointPort pointPort; private final UserPort userPort; private final CurrentUserProvider currentUserProvider; public ReservationService(ReservationRepository reservationRepository, RestaurantPort restaurantPort, + MediaPort mediaPort, PointPort pointPort, UserPort userPort, CurrentUserProvider currentUserProvider) { this.reservationRepository = reservationRepository; this.restaurantPort = restaurantPort; + this.mediaPort = mediaPort; this.pointPort = pointPort; this.userPort = userPort; this.currentUserProvider = currentUserProvider; @@ -185,8 +196,14 @@ private long countByFilter(Long userId, ReservationStatusFilter filter) { /** 페이지 단위 응답 변환 — STANDARD 식당 요약(이름·대표이미지)은 포트 다건 조회(findSummaries)로 한 번에 enrich한다(N+1 방지, §5-2). */ private List toResponses(List reservations) { Map summaries = fetchRestaurantSummaries(reservations); + MediaProjection mediaProjection = loadThumbnailProjection(summaries.values().stream() + .map(RestaurantInfo::thumbnailImageReference) + .toList()); return reservations.stream() - .map(reservation -> toResponse(reservation, findSummary(summaries, reservation))) + .map(reservation -> toResponse( + reservation, + findSummary(summaries, reservation), + mediaProjection)) .toList(); } @@ -210,7 +227,11 @@ private Map fetchRestaurantSummaries(List res // 한번에 받아온 일반 예약과 어디든 예약 — 유형별로 식당명·대표이미지·주소를 해석해 응답으로 변환 // (STANDARD 주소는 저장하지 않고 포트로 live enrich — 식당 주소 변경 시 항상 최신) - private ReservationResponse toResponse(Reservation reservation, RestaurantInfo summary) { + private ReservationResponse toResponse( + Reservation reservation, + RestaurantInfo summary, + MediaProjection mediaProjection + ) { if (reservation.getReservationType() == ReservationType.ANYWHERE) { return ReservationResponse.of(reservation, reservation.getRestaurantName(), null, reservation.getRestaurantAddress()); @@ -218,7 +239,14 @@ private ReservationResponse toResponse(Reservation reservation, RestaurantInfo s if (summary == null) { return ReservationResponse.of(reservation, UNKNOWN_RESTAURANT_NAME, null, null); } - return ReservationResponse.of(reservation, summary.name(), summary.imageUrl(), summary.address()); + ProjectedImage thumbnail = projectThumbnail( + summary.thumbnailImageReference(), mediaProjection); + return ReservationResponse.of( + reservation, + summary.name(), + thumbnail.url(), + thumbnail.image(), + summary.address()); } private List fetchPage(Long userId, ReservationStatusFilter filter, Long cursor, Pageable pageable) { @@ -257,11 +285,17 @@ private ReservationDetailResponse toDetailResponse(Reservation reservation) { reservation.getRestaurantName(), null, reservation.getRestaurantAddress(), null); } Optional detail = restaurantPort.findDetailById(reservation.getRestaurantId()); + ImageReference imageReference = detail + .map(RestaurantDetailInfo::thumbnailImageReference) + .orElse(null); + MediaProjection mediaProjection = loadThumbnailProjection(referenceList(imageReference)); + ProjectedImage thumbnail = projectThumbnail(imageReference, mediaProjection); return ReservationDetailResponse.of(reservation, detail.map(RestaurantDetailInfo::name).orElse(UNKNOWN_RESTAURANT_NAME), detail.map(RestaurantDetailInfo::nameJa).orElse(null), detail.map(RestaurantDetailInfo::address).orElse(null), - detail.map(RestaurantDetailInfo::imageUrl).orElse(null)); + thumbnail.url(), + thumbnail.image()); } // 쿼리스트링으로 받은 size값이 정상적인 사이즈 값인지 검증. 정상적이지 않으면 size = 20으로 반환 @@ -277,7 +311,11 @@ private int normalizeSize(Integer size) { * {@link #toResponse(Reservation, RestaurantInfo)}에 위임한다. 목록은 {@link #toResponses(List)}가 다건 조회로 처리한다. */ private ReservationResponse toResponse(Reservation reservation) { - return toResponse(reservation, findSummaryFor(reservation)); + RestaurantInfo summary = findSummaryFor(reservation); + MediaProjection mediaProjection = loadThumbnailProjection(summary == null + ? List.of() + : referenceList(summary.thumbnailImageReference())); + return toResponse(reservation, summary, mediaProjection); } /** 단건 식당 요약 조회 — STANDARD만 RestaurantPort로 얻고, ANYWHERE·미존재 식당은 null(호출 측 fallback). */ @@ -341,11 +379,16 @@ private AdminReservationInfo toAdminInfo(Reservation reservation, RestaurantInfo if (summary == null) { return toAdminInfo(reservation, UNKNOWN_RESTAURANT_NAME, null, null); } - return toAdminInfo(reservation, summary.name(), summary.imageUrl(), summary.address()); + return toAdminInfo( + reservation, + summary.name(), + summary.thumbnailImageReference(), + summary.address()); } private AdminReservationInfo toAdminInfo(Reservation reservation, String restaurantName, - String restaurantImageUrl, String restaurantAddress) { + ImageReference restaurantImageReference, + String restaurantAddress) { return new AdminReservationInfo( reservation.getId(), reservation.getUserId(), @@ -353,7 +396,7 @@ private AdminReservationInfo toAdminInfo(Reservation reservation, String restaur reservation.getReserverName(), reservation.getRestaurantId(), restaurantName, - restaurantImageUrl, + restaurantImageReference, restaurantAddress, reservation.getReservedAt(), reservation.getAdultCount(), @@ -366,4 +409,63 @@ private AdminReservationInfo toAdminInfo(Reservation reservation, String restaur reservation.getAmount(), reservation.confirmDDay()); } + + private MediaProjection loadThumbnailProjection(Collection references) { + List requests = references.stream() + .filter(Objects::nonNull) + .map(ImageReference::assetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest( + assetId, MediaImageRole.RESTAURANT_THUMBNAIL)) + .distinct() + .toList(); + if (requests.isEmpty()) { + return MediaProjection.empty(); + } + return new MediaProjection(mediaPort.findImages(requests)); + } + + private List referenceList(ImageReference reference) { + return reference == null ? List.of() : List.of(reference); + } + + private ProjectedImage projectThumbnail( + ImageReference reference, + MediaProjection mediaProjection + ) { + if (reference == null) { + return ProjectedImage.empty(); + } + if (reference.assetId() == null) { + return new ProjectedImage(reference.legacyUrl(), null); + } + MediaImage mediaImage = mediaProjection.find(reference); + String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY + ? mediaImage.defaultSource().url() + : null; + return new ProjectedImage(url, mediaImage); + } + + private record MediaProjection(Map images) { + + private MediaProjection { + images = Map.copyOf(images); + } + + private static MediaProjection empty() { + return new MediaProjection(Map.of()); + } + + private MediaImage find(ImageReference reference) { + return images.get(new MediaImageRequest( + reference.assetId(), MediaImageRole.RESTAURANT_THUMBNAIL)); + } + } + + private record ProjectedImage(String url, MediaImage image) { + + private static ProjectedImage empty() { + return new ProjectedImage(null, null); + } + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java index 73876636..78e53a2b 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java @@ -1,14 +1,40 @@ package org.sopt.hashi.restaurant; +import org.sopt.hashi.media.ImageReference; + /** * 모듈 간 전달용 식당 상세 DTO. 예약 상세 등 요약(id·name)보다 많은 표시 정보가 필요한 조회에 쓴다. - * {@code imageUrl}은 대표 이미지의 조회 URL이다(restaurant가 object key를 조회 URL로 변환해 노출, - * coding-style §4-2). 대표 이미지가 없으면 null. + * 대표 이미지는 object key가 아닌 전환기 {@link ImageReference}로 전달한다. 대표 이미지가 없으면 null. */ public record RestaurantDetailInfo( Long id, String name, String nameJa, String address, - String imageUrl) { + ImageReference thumbnailImageReference +) { + + /** legacy 테스트와 호출부의 점진 전환용 생성자. */ + public RestaurantDetailInfo( + Long id, + String name, + String nameJa, + String address, + String imageUrl + ) { + this( + id, + name, + nameJa, + address, + imageUrl == null ? null : ImageReference.legacy(imageUrl)); + } + + /** 신규 asset을 legacy URL로 우회하지 않는 임시 호환 accessor. */ + @Deprecated(forRemoval = true) + public String imageUrl() { + return thumbnailImageReference == null || thumbnailImageReference.assetId() != null + ? null + : thumbnailImageReference.legacyUrl(); + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java index d97e672e..266f1f0c 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java @@ -1,9 +1,36 @@ package org.sopt.hashi.restaurant; +import org.sopt.hashi.media.ImageReference; + /** * 모듈 간 전달용 식당 요약 DTO. 의존 모듈의 목록 표시·enrich에 필요한 최소 필드만 담는다(§2-2 {@code Info}). * {@code address}는 목록에서도 식당 위치를 보여주기 위한 live enrich 값(예약에 스냅샷하지 않음 — 항상 최신). - * {@code imageUrl}은 대표 이미지의 조회 URL이다(coding-style §4-2). 대표 이미지가 없으면 null. + * 대표 이미지는 object key가 아닌 전환기 {@link ImageReference}로 전달한다. 대표 이미지가 없으면 null. */ -public record RestaurantInfo(Long id, String name, String address, String imageUrl) { +public record RestaurantInfo( + Long id, + String name, + String address, + ImageReference thumbnailImageReference +) { + + /** legacy 테스트와 호출부의 점진 전환용 생성자. */ + public RestaurantInfo(Long id, String name, String address, String imageUrl) { + this( + id, + name, + address, + imageUrl == null ? null : ImageReference.legacy(imageUrl)); + } + + /** + * 신규 asset을 legacy URL로 우회하지 않는 임시 호환 accessor다. + * 최종 HTTP 응답 Service는 {@link #thumbnailImageReference()}와 MediaPort를 사용해야 한다. + */ + @Deprecated(forRemoval = true) + public String imageUrl() { + return thumbnailImageReference == null || thumbnailImageReference.assetId() != null + ? null + : thumbnailImageReference.legacyUrl(); + } } diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java index 3489335e..b83eb0ec 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/Restaurant.java @@ -20,6 +20,7 @@ import java.util.Comparator; import java.util.LinkedHashSet; import java.util.List; +import java.util.Optional; import java.util.Set; import lombok.AccessLevel; import lombok.Getter; @@ -200,12 +201,17 @@ public void replaceHashtags(List hashtags) { /** 정렬 순서가 가장 빠른 식당 이미지를 대표 이미지로 사용한다. */ public String getThumbnailFileKey() { - return images.stream() - .min(Comparator.comparingInt(RestaurantImage::getDisplayOrder)) + return getThumbnailImage() .map(RestaurantImage::getFileKey) .orElse(null); } + /** 대표 association 자체를 반환해 assetId와 legacy key를 함께 보존한다. */ + public Optional getThumbnailImage() { + return images.stream() + .min(Comparator.comparingInt(RestaurantImage::getDisplayOrder)); + } + public void replaceCurationTypes(List curationTypes) { this.curationTypes.clear(); if (curationTypes != null) { diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java index 5540c6da..86e5ad07 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantRepository.java @@ -31,6 +31,16 @@ public interface RestaurantRepository extends JpaRepository, J @Query("select distinct r from Restaurant r where r.id = :restaurantId and r.deleted = false") Optional findActiveByIdWithImages(@Param("restaurantId") Long restaurantId); + /** 예약·리뷰의 과거 표시를 위해 soft-delete 식당도 대표 이미지 association과 함께 조회한다. */ + @EntityGraph(attributePaths = "images") + @Query("select distinct r from Restaurant r where r.id = :restaurantId") + Optional findByIdWithImages(@Param("restaurantId") Long restaurantId); + + /** 교차 모듈 목록 enrich에서 이미지 collection N+1 없이 soft-delete 식당까지 조회한다. */ + @EntityGraph(attributePaths = "images") + @Query("select distinct r from Restaurant r where r.id in :restaurantIds") + List findAllByIdWithImages(@Param("restaurantIds") List restaurantIds); + // 랜덤 추천 — 큐레이션 조건 없이 전체 활성 식당에서 뽑는다(#154). rand() 정렬은 식당 수가 적은 규모를 전제로 한다. @Query(value = """ select r.id diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java index 0938e217..7a2fe2f9 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantPortImpl.java @@ -8,12 +8,14 @@ import java.util.Optional; import java.util.function.Function; import java.util.stream.Collectors; +import org.sopt.hashi.media.ImageReference; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.RestaurantDetailInfo; import org.sopt.hashi.restaurant.RestaurantInfo; import org.sopt.hashi.restaurant.RestaurantPort; import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantImage; import org.sopt.hashi.restaurant.domain.RestaurantRepository; import org.sopt.hashi.shared.storage.FileStorage; import org.springframework.stereotype.Component; @@ -44,7 +46,7 @@ public Optional findSummaryById(Long restaurantId) { if (restaurantId == null) { return Optional.empty(); } - return restaurantRepository.findById(restaurantId) + return restaurantRepository.findByIdWithImages(restaurantId) .map(this::toInfo); } @@ -64,7 +66,7 @@ public List findSummaries(Collection restaurantIds) { return List.of(); } - Map summariesById = restaurantRepository.findAllById(ids).stream() + Map summariesById = restaurantRepository.findAllByIdWithImages(ids).stream() .map(this::toInfo) .collect(Collectors.toMap(RestaurantInfo::id, Function.identity())); @@ -79,7 +81,7 @@ public Optional findDetailById(Long restaurantId) { if (restaurantId == null) { return Optional.empty(); } - return restaurantRepository.findById(restaurantId) + return restaurantRepository.findByIdWithImages(restaurantId) .map(this::toDetailInfo); } @@ -122,7 +124,7 @@ private RestaurantInfo toInfo(Restaurant restaurant) { restaurant.getId(), restaurant.getName(), restaurant.getAddress(), - fileStorage.resolveFileUrl(restaurant.getThumbnailFileKey()) + toThumbnailReference(restaurant) ); } @@ -132,7 +134,20 @@ private RestaurantDetailInfo toDetailInfo(Restaurant restaurant) { restaurant.getName(), restaurant.getLocalName(), restaurant.getAddress(), - fileStorage.resolveFileUrl(restaurant.getThumbnailFileKey()) + toThumbnailReference(restaurant) ); } + + private ImageReference toThumbnailReference(Restaurant restaurant) { + return restaurant.getThumbnailImage() + .map(this::toImageReference) + .orElse(null); + } + + private ImageReference toImageReference(RestaurantImage image) { + String legacyUrl = image.getFileKey() == null + ? null + : fileStorage.resolveFileUrl(image.getFileKey()); + return new ImageReference(image.getImageAssetId(), legacyUrl); + } } diff --git a/src/test/java/org/sopt/hashi/admin/service/AdminReservationServiceTest.java b/src/test/java/org/sopt/hashi/admin/service/AdminReservationServiceTest.java new file mode 100644 index 00000000..2523e51d --- /dev/null +++ b/src/test/java/org/sopt/hashi/admin/service/AdminReservationServiceTest.java @@ -0,0 +1,145 @@ +package org.sopt.hashi.admin.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.hashi.admin.dto.AdminReservationListResponse; +import org.sopt.hashi.admin.dto.AdminReservationResponse; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.reservation.AdminReservationInfo; +import org.sopt.hashi.reservation.PaymentStatus; +import org.sopt.hashi.reservation.ReservationPort; +import org.sopt.hashi.reservation.ReservationStatus; +import org.sopt.hashi.reservation.ReservationType; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.PageRequest; + +@ExtendWith(MockitoExtension.class) +class AdminReservationServiceTest { + + @Mock + private ReservationPort reservationPort; + + @Mock + private MediaPort mediaPort; + + @Test + void 어드민_예약_목록은_READY와_상태_이미지를_한번에_projection한다() { + AdminReservationService service = new AdminReservationService(reservationPort, mediaPort); + UUID readyAssetId = UUID.randomUUID(); + UUID processingAssetId = UUID.randomUUID(); + UUID missingAssetId = UUID.randomUUID(); + List infos = List.of( + info(101L, new ImageReference(readyAssetId, "https://legacy/ready.jpg")), + info(102L, new ImageReference(processingAssetId, "https://legacy/processing.jpg")), + info(103L, new ImageReference(missingAssetId, "https://legacy/missing.jpg")) + ); + given(reservationPort.findPageByAdmin(null, 0, 10)) + .willReturn(new PageImpl<>(infos, PageRequest.of(0, 10), infos.size())); + MediaImage ready = readyImage(readyAssetId, "https://cdn/ready.webp"); + MediaImage processing = statusImage(processingAssetId, MediaImageStatus.PROCESSING); + given(mediaPort.findImages(any())).willReturn(Map.of( + request(readyAssetId), ready, + request(processingAssetId), processing + )); + + AdminReservationListResponse response = service.getReservations(null, 0, 10); + + assertThat(response.reservations()).hasSize(3); + assertThat(response.reservations().get(0).restaurantImageUrl()) + .isEqualTo("https://cdn/ready.webp"); + assertThat(response.reservations().get(0).restaurantThumbnailImage()).isEqualTo(ready); + assertThat(response.reservations().get(1).restaurantImageUrl()).isNull(); + assertThat(response.reservations().get(1).restaurantThumbnailImage()).isEqualTo(processing); + assertThat(response.reservations().get(2).restaurantImageUrl()).isNull(); + assertThat(response.reservations().get(2).restaurantThumbnailImage()).isNull(); + verify(mediaPort).findImages(ArgumentMatchers.argThat( + requests -> Set.copyOf(requests).equals(Set.of( + request(readyAssetId), + request(processingAssetId), + request(missingAssetId))))); + } + + @Test + void legacy_only_어드민_예약은_media를_조회하지_않고_기존_URL을_유지한다() { + AdminReservationService service = new AdminReservationService(reservationPort, mediaPort); + AdminReservationInfo info = info( + 101L, ImageReference.legacy("https://legacy/restaurant.jpg")); + given(reservationPort.changeStatusByAdmin(101L, ReservationStatus.CONFIRMED)) + .willReturn(info); + + AdminReservationResponse response = service.changeStatus( + 101L, ReservationStatus.CONFIRMED); + + assertThat(response.restaurantImageUrl()) + .isEqualTo("https://legacy/restaurant.jpg"); + assertThat(response.restaurantThumbnailImage()).isNull(); + verifyNoInteractions(mediaPort); + } + + private AdminReservationInfo info(Long reservationId, ImageReference imageReference) { + return new AdminReservationInfo( + reservationId, + 7L, + ReservationType.STANDARD, + "예약자", + 10L, + "스시야", + imageReference, + "도쿄", + LocalDateTime.of(2026, 9, 1, 18, 0), + 2, + 0, + 0, + null, + ReservationStatus.REQUESTED, + PaymentStatus.PENDING, + 0L, + BigDecimal.valueOf(4_000), + 2L); + } + + private MediaImageRequest request(UUID assetId) { + return new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL); + } + + private MediaImage readyImage(UUID assetId, String url) { + MediaImage.Source source = new MediaImage.Source(url, 192, 192, "image/webp"); + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(url, 192, 192))))); + } + + private MediaImage statusImage(UUID assetId, MediaImageStatus status) { + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + status, + null, + List.of()); + } +} diff --git a/src/test/java/org/sopt/hashi/reservation/RestaurantPortContractTest.java b/src/test/java/org/sopt/hashi/reservation/RestaurantPortContractTest.java index c7220214..de01d0df 100644 --- a/src/test/java/org/sopt/hashi/reservation/RestaurantPortContractTest.java +++ b/src/test/java/org/sopt/hashi/reservation/RestaurantPortContractTest.java @@ -7,6 +7,7 @@ import java.util.Optional; import org.junit.jupiter.api.Test; +import org.sopt.hashi.media.ImageReference; import org.sopt.hashi.restaurant.RestaurantInfo; import org.sopt.hashi.restaurant.RestaurantPort; @@ -30,7 +31,10 @@ class RestaurantPortContractTest { RestaurantPort restaurantPort = mock(RestaurantPort.class); given(restaurantPort.findSummaryById(1L)) .willReturn(Optional.of(new RestaurantInfo( - 1L, "하시식당", "도쿄 신주쿠 1-1", "https://presigned.example/main.jpg"))); + 1L, + "하시식당", + "도쿄 신주쿠 1-1", + ImageReference.legacy("https://presigned.example/main.jpg")))); Optional summary = restaurantPort.findSummaryById(1L); @@ -38,6 +42,8 @@ class RestaurantPortContractTest { assertThat(info.id()).isEqualTo(1L); assertThat(info.name()).isEqualTo("하시식당"); assertThat(info.address()).isEqualTo("도쿄 신주쿠 1-1"); + assertThat(info.thumbnailImageReference().legacyUrl()) + .isEqualTo("https://presigned.example/main.jpg"); }); } } diff --git a/src/test/java/org/sopt/hashi/reservation/service/ReservationServiceTest.java b/src/test/java/org/sopt/hashi/reservation/service/ReservationServiceTest.java new file mode 100644 index 00000000..765bdda5 --- /dev/null +++ b/src/test/java/org/sopt/hashi/reservation/service/ReservationServiceTest.java @@ -0,0 +1,245 @@ +package org.sopt.hashi.reservation.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import java.util.Set; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentMatchers; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.point.PointPort; +import org.sopt.hashi.reservation.AdminReservationInfo; +import org.sopt.hashi.reservation.domain.Reservation; +import org.sopt.hashi.reservation.domain.ReservationRepository; +import org.sopt.hashi.reservation.dto.ReservationDetailResponse; +import org.sopt.hashi.reservation.dto.ReservationListResponse; +import org.sopt.hashi.restaurant.RestaurantDetailInfo; +import org.sopt.hashi.restaurant.RestaurantInfo; +import org.sopt.hashi.restaurant.RestaurantPort; +import org.sopt.hashi.user.UserPort; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +@ExtendWith(MockitoExtension.class) +class ReservationServiceTest { + + @Mock + private ReservationRepository reservationRepository; + + @Mock + private RestaurantPort restaurantPort; + + @Mock + private MediaPort mediaPort; + + @Mock + private PointPort pointPort; + + @Mock + private UserPort userPort; + + @Mock + private CurrentUserProvider currentUserProvider; + + @Test + void 예약_목록은_페이지에_포함된_식당_이미지만_한번에_bulk_조회한다() { + ReservationService service = createService(); + UUID firstAssetId = UUID.randomUUID(); + UUID secondAssetId = UUID.randomUUID(); + UUID lookaheadAssetId = UUID.randomUUID(); + Reservation first = standardReservation(101L, 10L); + Reservation second = standardReservation(100L, 20L); + Reservation lookahead = standardReservation(99L, 30L); + given(currentUserProvider.currentUserId()).willReturn(7L); + given(reservationRepository.findByUserIdOrderByIdDesc(eq(7L), any(Pageable.class))) + .willReturn(List.of(first, second, lookahead)); + given(reservationRepository.countByUserId(7L)).willReturn(3L); + given(restaurantPort.findSummaries(ArgumentMatchers.argThat( + ids -> Set.copyOf(ids).equals(Set.of(10L, 20L))))) + .willReturn(List.of( + restaurantInfo(10L, firstAssetId, "https://legacy/first.jpg"), + restaurantInfo(20L, secondAssetId, "https://legacy/second.jpg") + )); + MediaImage firstImage = readyImage(firstAssetId, "https://cdn/first.webp"); + MediaImage secondImage = readyImage(secondAssetId, "https://cdn/second.webp"); + given(mediaPort.findImages(any())).willReturn(Map.of( + request(firstAssetId), firstImage, + request(secondAssetId), secondImage + )); + + ReservationListResponse response = service.getMyReservations(null, 2, null); + + assertThat(response.reservations()).hasSize(2); + assertThat(response.hasNext()).isTrue(); + assertThat(response.nextCursor()).isEqualTo(100L); + assertThat(response.reservations().getFirst().restaurantImageUrl()) + .isEqualTo("https://cdn/first.webp"); + assertThat(response.reservations().getFirst().restaurantThumbnailImage()) + .isEqualTo(firstImage); + verify(mediaPort).findImages(ArgumentMatchers.argThat( + requests -> Set.copyOf(requests).equals(Set.of( + request(firstAssetId), request(secondAssetId))) + && requests.stream().noneMatch( + request -> request.assetId().equals(lookaheadAssetId)))); + } + + @Test + void 어디든_예약만_있으면_식당과_media를_조회하지_않는다() { + ReservationService service = createService(); + Reservation anywhere = anywhereReservation(101L); + given(currentUserProvider.currentUserId()).willReturn(7L); + given(reservationRepository.findByUserIdOrderByIdDesc(eq(7L), any(Pageable.class))) + .willReturn(List.of(anywhere)); + given(reservationRepository.countByUserId(7L)).willReturn(1L); + + ReservationListResponse response = service.getMyReservations(null, 10, null); + + assertThat(response.reservations()).singleElement() + .satisfies(item -> { + assertThat(item.restaurantName()).isEqualTo("긴자 미등록 식당"); + assertThat(item.restaurantAddress()).isEqualTo("도쿄도 주오구 긴자"); + assertThat(item.restaurantImageUrl()).isNull(); + assertThat(item.restaurantThumbnailImage()).isNull(); + }); + verifyNoInteractions(restaurantPort, mediaPort); + } + + @Test + void 예약_상세의_PROCESSING_asset은_legacy_URL로_우회하지_않는다() { + ReservationService service = createService(); + UUID assetId = UUID.randomUUID(); + Reservation reservation = standardReservation(101L, 10L); + MediaImage processing = statusImage(assetId, MediaImageStatus.PROCESSING); + given(currentUserProvider.currentUserId()).willReturn(7L); + given(reservationRepository.findById(101L)).willReturn(Optional.of(reservation)); + given(restaurantPort.findDetailById(10L)).willReturn(Optional.of( + new RestaurantDetailInfo( + 10L, + "스시야", + "寿司屋", + "도쿄", + new ImageReference(assetId, "https://legacy/original.jpg")))); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), processing)); + + ReservationDetailResponse response = service.getMyReservation(101L); + + assertThat(response.restaurantImageUrl()).isNull(); + assertThat(response.restaurantThumbnailImage()).isEqualTo(processing); + verify(mediaPort).findImages(ArgumentMatchers.argThat( + requests -> List.copyOf(requests).equals(List.of(request(assetId))))); + } + + @Test + void 어드민_예약_조회는_media를_해석하지_않고_ImageReference를_전달한다() { + ReservationService service = createService(); + UUID assetId = UUID.randomUUID(); + Reservation reservation = standardReservation(101L, 10L); + ImageReference reference = new ImageReference(assetId, "https://legacy/original.jpg"); + given(reservationRepository.findAll(any(Pageable.class))) + .willReturn(new PageImpl<>(List.of(reservation))); + given(restaurantPort.findSummaries(Set.of(10L))) + .willReturn(List.of(new RestaurantInfo(10L, "스시야", "도쿄", reference))); + + Page response = service.findPageByAdmin(null, 0, 10); + + assertThat(response.getContent()).singleElement() + .satisfies(info -> assertThat(info.restaurantImageReference()).isEqualTo(reference)); + verifyNoInteractions(mediaPort); + } + + private ReservationService createService() { + return new ReservationService( + reservationRepository, + restaurantPort, + mediaPort, + pointPort, + userPort, + currentUserProvider); + } + + private Reservation standardReservation(Long id, Long restaurantId) { + Reservation reservation = Reservation.standard( + 7L, + "예약자", + restaurantId, + LocalDateTime.of(2026, 9, 1, 18, 0), + 2, + 0, + 0, + null, + 0L, + 4_000L); + ReflectionTestUtils.setField(reservation, "id", id); + return reservation; + } + + private Reservation anywhereReservation(Long id) { + Reservation reservation = Reservation.anywhere( + 7L, + "예약자", + "긴자 미등록 식당", + "도쿄도 주오구 긴자", + LocalDateTime.of(2026, 9, 1, 18, 0), + 2, + 0, + 0, + null, + 0L, + 4_000L); + ReflectionTestUtils.setField(reservation, "id", id); + return reservation; + } + + private RestaurantInfo restaurantInfo(Long id, UUID assetId, String legacyUrl) { + return new RestaurantInfo( + id, + "식당 " + id, + "도쿄", + new ImageReference(assetId, legacyUrl)); + } + + private MediaImageRequest request(UUID assetId) { + return new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL); + } + + private MediaImage readyImage(UUID assetId, String url) { + MediaImage.Source source = new MediaImage.Source(url, 192, 192, "image/webp"); + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(url, 192, 192))))); + } + + private MediaImage statusImage(UUID assetId, MediaImageStatus status) { + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + status, + null, + List.of()); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java index ccf39d02..b015e2d2 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantPortImplTest.java @@ -2,17 +2,20 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import java.math.BigDecimal; import java.util.Arrays; import java.util.List; import java.util.Optional; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; +import org.sopt.hashi.media.ImageReference; import org.sopt.hashi.restaurant.RestaurantDetailInfo; import org.sopt.hashi.restaurant.RestaurantInfo; import org.sopt.hashi.restaurant.domain.PriceCurrency; @@ -59,7 +62,7 @@ void setUp() { @Test void 식당_id로_식당_요약을_조회한다() { Restaurant restaurant = createRestaurant(1L); - given(restaurantRepository.findById(1L)).willReturn(Optional.of(restaurant)); + given(restaurantRepository.findByIdWithImages(1L)).willReturn(Optional.of(restaurant)); given(fileStorage.resolveFileUrl("restaurants/1/thumbnail.jpg")) .willReturn("https://cdn.example.com/restaurants/1/thumbnail.jpg"); @@ -69,7 +72,7 @@ void setUp() { 1L, "히마와리 스시", "도쿄도 신주쿠구", - "https://cdn.example.com/restaurants/1/thumbnail.jpg" + ImageReference.legacy("https://cdn.example.com/restaurants/1/thumbnail.jpg") )); } @@ -77,7 +80,7 @@ void setUp() { void 식당_요약_목록은_요청_순서를_유지하고_없는_식당은_제외한다() { Restaurant first = createRestaurant(1L); Restaurant second = createRestaurant(2L); - given(restaurantRepository.findAllById(List.of(2L, 1L))) + given(restaurantRepository.findAllByIdWithImages(List.of(2L, 1L))) .willReturn(List.of(first, second)); given(fileStorage.resolveFileUrl("restaurants/1/thumbnail.jpg")) .willReturn("https://cdn.example.com/restaurants/1/thumbnail.jpg"); @@ -94,7 +97,7 @@ void setUp() { @Test void 식당_id로_식당_상세를_조회한다() { Restaurant restaurant = createRestaurant(1L); - given(restaurantRepository.findById(1L)).willReturn(Optional.of(restaurant)); + given(restaurantRepository.findByIdWithImages(1L)).willReturn(Optional.of(restaurant)); given(fileStorage.resolveFileUrl("restaurants/1/thumbnail.jpg")) .willReturn("https://cdn.example.com/restaurants/1/thumbnail.jpg"); @@ -105,10 +108,55 @@ void setUp() { "히마와리 스시", "Himawari Sushi", "도쿄도 신주쿠구", - "https://cdn.example.com/restaurants/1/thumbnail.jpg" + ImageReference.legacy("https://cdn.example.com/restaurants/1/thumbnail.jpg") )); } + @Test + void backfill된_대표_이미지는_asset_ID와_legacy_URL을_함께_전달한다() { + UUID assetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L); + restaurant.replaceImages(List.of(RestaurantImage.createBackfilled( + "restaurants/1/thumbnail.jpg", assetId, 1))); + given(restaurantRepository.findByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + given(fileStorage.resolveFileUrl("restaurants/1/thumbnail.jpg")) + .willReturn("https://cdn.example.com/restaurants/1/thumbnail.jpg"); + + RestaurantInfo result = restaurantPort.findSummaryById(1L).orElseThrow(); + + assertThat(result.thumbnailImageReference()).isEqualTo(new ImageReference( + assetId, + "https://cdn.example.com/restaurants/1/thumbnail.jpg")); + } + + @Test + void asset_only_대표_이미지는_legacy_URL을_계산하지_않는다() { + UUID assetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L); + restaurant.replaceImages(List.of(RestaurantImage.createAsset(assetId, 1))); + given(restaurantRepository.findByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + + RestaurantInfo result = restaurantPort.findSummaryById(1L).orElseThrow(); + + assertThat(result.thumbnailImageReference()).isEqualTo(ImageReference.asset(assetId)); + verify(fileStorage, never()).resolveFileUrl(org.mockito.ArgumentMatchers.any()); + } + + @Test + void soft_delete된_식당도_예약과_리뷰용_대표_이미지를_반환한다() { + Restaurant restaurant = createRestaurant(1L); + restaurant.softDelete(); + given(restaurantRepository.findByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + given(fileStorage.resolveFileUrl("restaurants/1/thumbnail.jpg")) + .willReturn("https://cdn.example.com/restaurants/1/thumbnail.jpg"); + + Optional result = restaurantPort.findSummaryById(1L); + + assertThat(result).isPresent(); + assertThat(result.orElseThrow().thumbnailImageReference().legacyUrl()) + .isEqualTo("https://cdn.example.com/restaurants/1/thumbnail.jpg"); + } + private Restaurant createRestaurant(Long id) { Restaurant restaurant = Restaurant.create( "히마와리 스시", diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java index 50471b77..0b439415 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceIntegrationTest.java @@ -1,6 +1,7 @@ package org.sopt.hashi.restaurant.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verifyNoInteractions; import java.math.BigDecimal; @@ -13,6 +14,7 @@ import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.AdminRestaurantInfo.AdminRestaurantMenuInfo; +import org.sopt.hashi.restaurant.RestaurantInfo; import org.sopt.hashi.restaurant.RestaurantPort; import org.sopt.hashi.restaurant.domain.PriceCurrency; import org.sopt.hashi.restaurant.domain.Restaurant; @@ -164,6 +166,27 @@ class RestaurantServiceIntegrationTest { verifyNoInteractions(mediaPort); } + @Test + void RestaurantPort는_soft_delete_식당도_대표_이미지와_함께_조회한다() { + Restaurant restaurant = createRestaurant("삭제된 식당", "초밥"); + restaurant.replaceImages(List.of( + RestaurantImage.createLegacy("restaurants/deleted.jpg", 1) + )); + restaurant.softDelete(); + restaurantRepository.saveAndFlush(restaurant); + given(fileStorage.resolveFileUrl("restaurants/deleted.jpg")) + .willReturn("https://cdn.example.com/restaurants/deleted.jpg"); + + RestaurantInfo response = restaurantPort.findSummaryById(restaurant.getId()).orElseThrow(); + List summaries = restaurantPort.findSummaries(List.of(restaurant.getId())); + + assertThat(response.thumbnailImageReference().assetId()).isNull(); + assertThat(response.thumbnailImageReference().legacyUrl()) + .isEqualTo("https://cdn.example.com/restaurants/deleted.jpg"); + assertThat(summaries).singleElement() + .satisfies(info -> assertThat(info.id()).isEqualTo(restaurant.getId())); + } + private AdminRestaurantCommand updateMenusCommand(List menus) { return new AdminRestaurantCommand( null, From 8ae9e79bc8281cb028608094222c9c8e25d7570b Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 28 Aug 2026 02:00:46 +0900 Subject: [PATCH 06/26] =?UTF-8?q?feat(review):=20=EC=8B=9D=EB=8B=B9=20?= =?UTF-8?q?=EC=8D=B8=EB=84=A4=EC=9D=BC=20media=20=EC=9D=91=EB=8B=B5=20?= =?UTF-8?q?=EC=97=B0=EB=8F=99=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restaurant/RestaurantDetailInfo.java | 24 --- .../sopt/hashi/restaurant/RestaurantInfo.java | 20 --- .../review/dto/MyReviewDetailResponse.java | 2 + .../review/dto/MyReviewListResponse.java | 2 + .../review/dto/ReviewContextResponse.java | 2 + .../dto/VisitedReservationListResponse.java | 2 + .../hashi/review/service/MyReviewService.java | 92 ++++++++++- .../ReviewReservationQueryService.java | 106 ++++++++++++- .../dto/MyReviewDetailResponseTest.java | 1 + .../review/service/MyReviewServiceTest.java | 115 +++++++++++++- .../ReviewReservationQueryServiceTest.java | 150 +++++++++++++++++- 11 files changed, 452 insertions(+), 64 deletions(-) diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java index 78e53a2b..669d2796 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantDetailInfo.java @@ -13,28 +13,4 @@ public record RestaurantDetailInfo( String address, ImageReference thumbnailImageReference ) { - - /** legacy 테스트와 호출부의 점진 전환용 생성자. */ - public RestaurantDetailInfo( - Long id, - String name, - String nameJa, - String address, - String imageUrl - ) { - this( - id, - name, - nameJa, - address, - imageUrl == null ? null : ImageReference.legacy(imageUrl)); - } - - /** 신규 asset을 legacy URL로 우회하지 않는 임시 호환 accessor. */ - @Deprecated(forRemoval = true) - public String imageUrl() { - return thumbnailImageReference == null || thumbnailImageReference.assetId() != null - ? null - : thumbnailImageReference.legacyUrl(); - } } diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java index 266f1f0c..f6fbd1e4 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantInfo.java @@ -13,24 +13,4 @@ public record RestaurantInfo( String address, ImageReference thumbnailImageReference ) { - - /** legacy 테스트와 호출부의 점진 전환용 생성자. */ - public RestaurantInfo(Long id, String name, String address, String imageUrl) { - this( - id, - name, - address, - imageUrl == null ? null : ImageReference.legacy(imageUrl)); - } - - /** - * 신규 asset을 legacy URL로 우회하지 않는 임시 호환 accessor다. - * 최종 HTTP 응답 Service는 {@link #thumbnailImageReference()}와 MediaPort를 사용해야 한다. - */ - @Deprecated(forRemoval = true) - public String imageUrl() { - return thumbnailImageReference == null || thumbnailImageReference.assetId() != null - ? null - : thumbnailImageReference.legacyUrl(); - } } diff --git a/src/main/java/org/sopt/hashi/review/dto/MyReviewDetailResponse.java b/src/main/java/org/sopt/hashi/review/dto/MyReviewDetailResponse.java index 10498220..bc8be049 100644 --- a/src/main/java/org/sopt/hashi/review/dto/MyReviewDetailResponse.java +++ b/src/main/java/org/sopt/hashi/review/dto/MyReviewDetailResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.time.LocalDateTime; import java.util.List; +import org.sopt.hashi.media.MediaImage; @JsonInclude(JsonInclude.Include.NON_NULL) public record MyReviewDetailResponse( @@ -10,6 +11,7 @@ public record MyReviewDetailResponse( Long restaurantId, String restaurantName, String restaurantThumbnailUrl, + MediaImage restaurantThumbnailImage, LocalDateTime visitedAt, Integer adultCount, Integer childCount, diff --git a/src/main/java/org/sopt/hashi/review/dto/MyReviewListResponse.java b/src/main/java/org/sopt/hashi/review/dto/MyReviewListResponse.java index 0ef5f7a5..607255c7 100644 --- a/src/main/java/org/sopt/hashi/review/dto/MyReviewListResponse.java +++ b/src/main/java/org/sopt/hashi/review/dto/MyReviewListResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.time.LocalDateTime; import java.util.List; +import org.sopt.hashi.media.MediaImage; @JsonInclude(JsonInclude.Include.NON_NULL) public record MyReviewListResponse( @@ -17,6 +18,7 @@ public record MyReviewSummaryResponse( Long restaurantId, String restaurantName, String restaurantThumbnailUrl, + MediaImage restaurantThumbnailImage, LocalDateTime visitedAt, Integer adultCount, Integer childCount, diff --git a/src/main/java/org/sopt/hashi/review/dto/ReviewContextResponse.java b/src/main/java/org/sopt/hashi/review/dto/ReviewContextResponse.java index 913f9745..c40c37d3 100644 --- a/src/main/java/org/sopt/hashi/review/dto/ReviewContextResponse.java +++ b/src/main/java/org/sopt/hashi/review/dto/ReviewContextResponse.java @@ -4,6 +4,7 @@ import java.time.LocalDateTime; import java.util.Arrays; import java.util.List; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.review.domain.ReviewKeyword; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -12,6 +13,7 @@ public record ReviewContextResponse( Long restaurantId, String restaurantName, String restaurantThumbnailUrl, + MediaImage restaurantThumbnailImage, LocalDateTime visitedAt, int adultCount, int teenCount, diff --git a/src/main/java/org/sopt/hashi/review/dto/VisitedReservationListResponse.java b/src/main/java/org/sopt/hashi/review/dto/VisitedReservationListResponse.java index 96ac843c..7162afde 100644 --- a/src/main/java/org/sopt/hashi/review/dto/VisitedReservationListResponse.java +++ b/src/main/java/org/sopt/hashi/review/dto/VisitedReservationListResponse.java @@ -3,6 +3,7 @@ import com.fasterxml.jackson.annotation.JsonInclude; import java.time.LocalDateTime; import java.util.List; +import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.review.domain.ReviewLifecycleStatus; @JsonInclude(JsonInclude.Include.NON_NULL) @@ -19,6 +20,7 @@ public record VisitedReservationResponse( Long restaurantId, String restaurantName, String restaurantThumbnailUrl, + MediaImage restaurantThumbnailImage, LocalDateTime visitedAt, int adultCount, int teenCount, diff --git a/src/main/java/org/sopt/hashi/review/service/MyReviewService.java b/src/main/java/org/sopt/hashi/review/service/MyReviewService.java index a54a02c8..61747a28 100644 --- a/src/main/java/org/sopt/hashi/review/service/MyReviewService.java +++ b/src/main/java/org/sopt/hashi/review/service/MyReviewService.java @@ -1,12 +1,20 @@ package org.sopt.hashi.review.service; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Map; +import java.util.Objects; import java.util.function.Function; import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.reservation.ReservationPort; import org.sopt.hashi.reservation.ReservationReviewInfo; import org.sopt.hashi.restaurant.RestaurantInfo; @@ -40,6 +48,7 @@ public class MyReviewService { private final ReviewRepository reviewRepository; private final ReservationPort reservationPort; private final RestaurantPort restaurantPort; + private final MediaPort mediaPort; private final UserPort userPort; private final FileStorage fileStorage; private final CurrentUserProvider currentUserProvider; @@ -48,6 +57,7 @@ public MyReviewService( ReviewRepository reviewRepository, ReservationPort reservationPort, RestaurantPort restaurantPort, + MediaPort mediaPort, UserPort userPort, FileStorage fileStorage, CurrentUserProvider currentUserProvider @@ -55,6 +65,7 @@ public MyReviewService( this.reviewRepository = reviewRepository; this.reservationPort = reservationPort; this.restaurantPort = restaurantPort; + this.mediaPort = mediaPort; this.userPort = userPort; this.fileStorage = fileStorage; this.currentUserProvider = currentUserProvider; @@ -91,12 +102,17 @@ public MyReviewListResponse getMyReviews(Long cursor, Integer size) { .toList()) .stream() .collect(Collectors.toMap(RestaurantInfo::id, Function.identity())); + MediaProjection mediaProjection = loadThumbnailProjection( + restaurantsById.values().stream() + .map(RestaurantInfo::thumbnailImageReference) + .toList()); List content = pageContent.stream() .map(review -> toSummary( review, resolveReservation(reservationsById, review.getReservationId()), - requiredRestaurant(restaurantsById, review.getRestaurantId()))) + requiredRestaurant(restaurantsById, review.getRestaurantId()), + mediaProjection)) .toList(); return new MyReviewListResponse(content, nextCursor, hasNext); } @@ -111,12 +127,17 @@ public MyReviewDetailResponse getMyReview(Long reviewId) { String reviewerNickname = userPort.findById(userId) .map(UserInfo::nickname) .orElse(WITHDRAWN_REVIEWER_NICKNAME); + MediaProjection mediaProjection = loadThumbnailProjection( + referenceList(restaurant.thumbnailImageReference())); + ProjectedImage thumbnail = projectThumbnail( + restaurant.thumbnailImageReference(), mediaProjection); return new MyReviewDetailResponse( review.getId(), restaurant.id(), restaurant.name(), - restaurant.imageUrl(), + thumbnail.url(), + thumbnail.image(), reservation.reservedAt(), reservation.adultCount(), reservation.childCount(), @@ -155,13 +176,17 @@ private Review getOwnedActiveReview(Long reviewId, Long userId) { private MyReviewSummaryResponse toSummary( Review review, ReservationReviewInfo reservation, - RestaurantInfo restaurant + RestaurantInfo restaurant, + MediaProjection mediaProjection ) { + ProjectedImage thumbnail = projectThumbnail( + restaurant.thumbnailImageReference(), mediaProjection); return new MyReviewSummaryResponse( review.getId(), restaurant.id(), restaurant.name(), - restaurant.imageUrl(), + thumbnail.url(), + thumbnail.image(), reservation == null ? null : reservation.reservedAt(), reservation == null ? null : reservation.adultCount(), reservation == null ? null : reservation.childCount(), @@ -218,4 +243,63 @@ private void validateCursor(Long cursor) { throw new BusinessException(CommonErrorCode.INVALID_INPUT); } } + + private MediaProjection loadThumbnailProjection(Collection references) { + List requests = references.stream() + .filter(Objects::nonNull) + .map(ImageReference::assetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest( + assetId, MediaImageRole.RESTAURANT_THUMBNAIL)) + .distinct() + .toList(); + if (requests.isEmpty()) { + return MediaProjection.empty(); + } + return new MediaProjection(mediaPort.findImages(requests)); + } + + private List referenceList(ImageReference reference) { + return reference == null ? List.of() : List.of(reference); + } + + private ProjectedImage projectThumbnail( + ImageReference reference, + MediaProjection mediaProjection + ) { + if (reference == null) { + return ProjectedImage.empty(); + } + if (reference.assetId() == null) { + return new ProjectedImage(reference.legacyUrl(), null); + } + MediaImage mediaImage = mediaProjection.find(reference); + String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY + ? mediaImage.defaultSource().url() + : null; + return new ProjectedImage(url, mediaImage); + } + + private record MediaProjection(Map images) { + + private MediaProjection { + images = Map.copyOf(images); + } + + private static MediaProjection empty() { + return new MediaProjection(Map.of()); + } + + private MediaImage find(ImageReference reference) { + return images.get(new MediaImageRequest( + reference.assetId(), MediaImageRole.RESTAURANT_THUMBNAIL)); + } + } + + private record ProjectedImage(String url, MediaImage image) { + + private static ProjectedImage empty() { + return new ProjectedImage(null, null); + } + } } diff --git a/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java b/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java index 89ad3c99..7620fe0d 100644 --- a/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java +++ b/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java @@ -9,6 +9,12 @@ import java.util.function.Function; import java.util.stream.Collectors; import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.point.PointPort; import org.sopt.hashi.point.PointSourceType; import org.sopt.hashi.reservation.ReservationPort; @@ -41,6 +47,7 @@ public class ReviewReservationQueryService { private final ReviewRepository reviewRepository; private final ReservationPort reservationPort; private final RestaurantPort restaurantPort; + private final MediaPort mediaPort; private final PointPort pointPort; private final CurrentUserProvider currentUserProvider; @@ -48,12 +55,14 @@ public ReviewReservationQueryService( ReviewRepository reviewRepository, ReservationPort reservationPort, RestaurantPort restaurantPort, + MediaPort mediaPort, PointPort pointPort, CurrentUserProvider currentUserProvider ) { this.reviewRepository = reviewRepository; this.reservationPort = reservationPort; this.restaurantPort = restaurantPort; + this.mediaPort = mediaPort; this.pointPort = pointPort; this.currentUserProvider = currentUserProvider; } @@ -64,6 +73,10 @@ public ReviewContextResponse getContext(Long reservationId) { .getReviewInfoByIdAndUserId(reservationId, userId); RestaurantDisplay restaurant = findRestaurantDisplay(reservation); + MediaProjection mediaProjection = loadThumbnailProjection( + referenceList(restaurant.thumbnailImageReference())); + ProjectedImage thumbnail = projectThumbnail( + restaurant.thumbnailImageReference(), mediaProjection); boolean hasReviewHistory = reservation.supportsReview() && reservation.reservationStatus() == ReservationStatus.VISITED && reviewRepository.existsByReservationId(reservation.id()); @@ -73,7 +86,8 @@ public ReviewContextResponse getContext(Long reservationId) { reservation.id(), restaurant.id(), restaurant.name(), - restaurant.thumbnailUrl(), + thumbnail.url(), + thumbnail.image(), reservation.reservedAt(), reservation.adultCount(), reservation.teenCount(), @@ -134,6 +148,10 @@ public VisitedReservationListResponse getVisitedReservations( : null; Map restaurantById = findRestaurants(contentReservations); + MediaProjection mediaProjection = loadThumbnailProjection( + restaurantById.values().stream() + .map(RestaurantInfo::thumbnailImageReference) + .toList()); Map earnedPointByReservationId = findEarnedPoints( contentReservations, reviewByReservationId); @@ -142,7 +160,8 @@ public VisitedReservationListResponse getVisitedReservations( reservation, restaurantById, reviewByReservationId, - earnedPointByReservationId)) + earnedPointByReservationId, + mediaProjection)) .toList(); return new VisitedReservationListResponse(content, totalCount, nextCursor, hasNext); @@ -154,7 +173,10 @@ private RestaurantDisplay findRestaurantDisplay(ReservationReviewInfo reservatio } RestaurantInfo restaurant = restaurantPort.findSummaryById(reservation.restaurantId()) .orElseThrow(() -> new BusinessException(ReviewErrorCode.RESTAURANT_NOT_FOUND)); - return new RestaurantDisplay(restaurant.id(), restaurant.name(), restaurant.imageUrl()); + return new RestaurantDisplay( + restaurant.id(), + restaurant.name(), + restaurant.thumbnailImageReference()); } private ReviewUnavailableReason unavailableReason( @@ -281,9 +303,12 @@ private VisitedReservationResponse toVisitedReservationResponse( ReservationReviewInfo reservation, Map restaurantById, Map reviewByReservationId, - Map earnedPointByReservationId + Map earnedPointByReservationId, + MediaProjection mediaProjection ) { RestaurantDisplay restaurant = toRestaurantDisplay(reservation, restaurantById); + ProjectedImage thumbnail = projectThumbnail( + restaurant.thumbnailImageReference(), mediaProjection); Review review = reviewByReservationId.get(reservation.id()); boolean hasReviewHistory = review != null; boolean activeReview = hasReviewHistory && !review.isDeleted(); @@ -293,7 +318,8 @@ private VisitedReservationResponse toVisitedReservationResponse( reservation.id(), restaurant.id(), restaurant.name(), - restaurant.thumbnailUrl(), + thumbnail.url(), + thumbnail.image(), reservation.reservedAt(), reservation.adultCount(), reservation.teenCount(), @@ -329,13 +355,79 @@ private RestaurantDisplay toRestaurantDisplay( "예약(id=%d)에 연결된 식당(id=%d)을 찾을 수 없습니다." .formatted(reservation.id(), reservation.restaurantId())); } - return new RestaurantDisplay(restaurant.id(), restaurant.name(), restaurant.imageUrl()); + return new RestaurantDisplay( + restaurant.id(), + restaurant.name(), + restaurant.thumbnailImageReference()); } private VisitedReservationListResponse emptyResponse() { return new VisitedReservationListResponse(List.of(), 0L, null, false); } - private record RestaurantDisplay(Long id, String name, String thumbnailUrl) { + private MediaProjection loadThumbnailProjection(Collection references) { + List requests = references.stream() + .filter(Objects::nonNull) + .map(ImageReference::assetId) + .filter(Objects::nonNull) + .map(assetId -> new MediaImageRequest( + assetId, MediaImageRole.RESTAURANT_THUMBNAIL)) + .distinct() + .toList(); + if (requests.isEmpty()) { + return MediaProjection.empty(); + } + return new MediaProjection(mediaPort.findImages(requests)); + } + + private List referenceList(ImageReference reference) { + return reference == null ? List.of() : List.of(reference); + } + + private ProjectedImage projectThumbnail( + ImageReference reference, + MediaProjection mediaProjection + ) { + if (reference == null) { + return ProjectedImage.empty(); + } + if (reference.assetId() == null) { + return new ProjectedImage(reference.legacyUrl(), null); + } + MediaImage mediaImage = mediaProjection.find(reference); + String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY + ? mediaImage.defaultSource().url() + : null; + return new ProjectedImage(url, mediaImage); + } + + private record RestaurantDisplay( + Long id, + String name, + ImageReference thumbnailImageReference + ) { + } + + private record MediaProjection(Map images) { + + private MediaProjection { + images = Map.copyOf(images); + } + + private static MediaProjection empty() { + return new MediaProjection(Map.of()); + } + + private MediaImage find(ImageReference reference) { + return images.get(new MediaImageRequest( + reference.assetId(), MediaImageRole.RESTAURANT_THUMBNAIL)); + } + } + + private record ProjectedImage(String url, MediaImage image) { + + private static ProjectedImage empty() { + return new ProjectedImage(null, null); + } } } diff --git a/src/test/java/org/sopt/hashi/review/dto/MyReviewDetailResponseTest.java b/src/test/java/org/sopt/hashi/review/dto/MyReviewDetailResponseTest.java index f76a88d6..1e95a810 100644 --- a/src/test/java/org/sopt/hashi/review/dto/MyReviewDetailResponseTest.java +++ b/src/test/java/org/sopt/hashi/review/dto/MyReviewDetailResponseTest.java @@ -18,6 +18,7 @@ class MyReviewDetailResponseTest { 1L, "하시 스시", "https://cdn.example.com/restaurants/1/thumbnail.jpg", + null, LocalDateTime.of(2026, 7, 1, 12, 0), 2, 0, diff --git a/src/test/java/org/sopt/hashi/review/service/MyReviewServiceTest.java b/src/test/java/org/sopt/hashi/review/service/MyReviewServiceTest.java index 2fcec275..2e34ec12 100644 --- a/src/test/java/org/sopt/hashi/review/service/MyReviewServiceTest.java +++ b/src/test/java/org/sopt/hashi/review/service/MyReviewServiceTest.java @@ -2,19 +2,30 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.reservation.ReservationPort; import org.sopt.hashi.reservation.ReservationReviewInfo; import org.sopt.hashi.reservation.ReservationStatus; @@ -52,6 +63,9 @@ class MyReviewServiceTest { @Mock private RestaurantPort restaurantPort; + @Mock + private MediaPort mediaPort; + @Mock private UserPort userPort; @@ -69,6 +83,7 @@ void setUp() { reviewRepository, reservationPort, restaurantPort, + mediaPort, userPort, fileStorage, currentUserProvider @@ -79,7 +94,11 @@ void setUp() { void 내가_작성한_리뷰_목록을_예약과_식당_정보로_조합한다() { Review review = review(USER_ID); ReservationReviewInfo reservation = reservation(); - RestaurantInfo restaurant = restaurant(); + UUID assetId = UUID.randomUUID(); + RestaurantInfo restaurant = restaurant( + new ImageReference(assetId, "https://legacy.example.com/restaurants/10.jpg")); + MediaImage readyImage = readyImage( + assetId, "https://cdn.example.com/restaurants/10/192.webp"); given(currentUserProvider.currentUserId()).willReturn(USER_ID); given(reviewRepository.findByUserIdAndDeletedFalseOrderByIdDesc( @@ -89,6 +108,7 @@ void setUp() { .willReturn(List.of(reservation)); given(restaurantPort.findSummaries(List.of(RESTAURANT_ID))) .willReturn(List.of(restaurant)); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), readyImage)); MyReviewListResponse response = myReviewService.getMyReviews(null, null); @@ -97,10 +117,16 @@ void setUp() { assertThat(response.hasNext()).isFalse(); assertThat(response.content().getFirst().reviewId()).isEqualTo(REVIEW_ID); assertThat(response.content().getFirst().restaurantName()).isEqualTo("야키토리 무사시"); + assertThat(response.content().getFirst().restaurantThumbnailUrl()) + .isEqualTo("https://cdn.example.com/restaurants/10/192.webp"); + assertThat(response.content().getFirst().restaurantThumbnailImage()) + .isEqualTo(readyImage); assertThat(response.content().getFirst().visitedAt()).isEqualTo(reservation.reservedAt()); assertThat(response.content().getFirst().keywords()).containsExactly("음식이 맛있어요"); verify(reservationPort).findReviewInfos(List.of(RESERVATION_ID)); verify(restaurantPort).findSummaries(List.of(RESTAURANT_ID)); + verify(mediaPort).findImages(argThat( + requests -> List.copyOf(requests).equals(List.of(request(assetId))))); } @Test @@ -122,12 +148,43 @@ void setUp() { assertThat(response.reviewerNickname()).isEqualTo("탈퇴한 회원"); assertThat(response.imageUrls()) .containsExactly("https://cdn.example.com/reviews/20/1.jpg"); + verifyNoInteractions(mediaPort); + } + + @Test + void 내_리뷰_상세의_PROCESSING_식당_asset은_legacy_URL로_우회하지_않는다() { + Review review = review(USER_ID); + UUID assetId = UUID.randomUUID(); + MediaImage processingImage = statusImage(assetId, MediaImageStatus.PROCESSING); + given(currentUserProvider.currentUserId()).willReturn(USER_ID); + given(reviewRepository.findByIdAndUserIdAndDeletedFalse(REVIEW_ID, USER_ID)) + .willReturn(Optional.of(review)); + given(reservationPort.getReviewInfoByIdAndUserId(RESERVATION_ID, USER_ID)) + .willReturn(reservation()); + given(restaurantPort.findSummaryById(RESTAURANT_ID)).willReturn(Optional.of(restaurant( + new ImageReference( + assetId, + "https://legacy.example.com/restaurants/10/thumbnail.jpg")))); + given(userPort.findById(USER_ID)).willReturn(Optional.empty()); + given(fileStorage.resolveFileUrl("uploads/reviews/20/1.jpg")) + .willReturn("https://cdn.example.com/reviews/20/1.jpg"); + given(mediaPort.findImages(any())) + .willReturn(Map.of(request(assetId), processingImage)); + + MyReviewDetailResponse response = myReviewService.getMyReview(REVIEW_ID); + + assertThat(response.restaurantThumbnailUrl()).isNull(); + assertThat(response.restaurantThumbnailImage()).isEqualTo(processingImage); + verify(mediaPort).findImages(argThat( + requests -> List.copyOf(requests).equals(List.of(request(assetId))))); } @Test void 커서_다음_페이지가_있으면_마지막_응답_리뷰_ID를_다음_커서로_반환한다() { - Review firstReview = review(USER_ID, 30L); - Review extraReview = review(USER_ID, 20L); + UUID assetId = UUID.randomUUID(); + Review firstReview = review(USER_ID, 30L, RESERVATION_ID, RESTAURANT_ID); + Review extraReview = review(USER_ID, 20L, 200L, 20L); + MediaImage readyImage = readyImage(assetId, "https://cdn.example.com/10/192.webp"); given(currentUserProvider.currentUserId()).willReturn(USER_ID); given(reviewRepository.findByUserIdAndDeletedFalseAndIdLessThanOrderByIdDesc( @@ -136,7 +193,8 @@ void setUp() { given(reservationPort.findReviewInfos(List.of(RESERVATION_ID))) .willReturn(List.of(reservation())); given(restaurantPort.findSummaries(List.of(RESTAURANT_ID))) - .willReturn(List.of(restaurant())); + .willReturn(List.of(restaurant(ImageReference.asset(assetId)))); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), readyImage)); MyReviewListResponse response = myReviewService.getMyReviews(40L, 1); @@ -146,6 +204,10 @@ void setUp() { assertThat(response.hasNext()).isTrue(); verify(reviewRepository).findByUserIdAndDeletedFalseAndIdLessThanOrderByIdDesc( USER_ID, 40L, PageRequest.of(0, 2)); + verify(reservationPort).findReviewInfos(List.of(RESERVATION_ID)); + verify(restaurantPort).findSummaries(List.of(RESTAURANT_ID)); + verify(mediaPort).findImages(argThat(requests -> + Set.copyOf(requests).equals(Set.of(request(assetId))))); } @Test @@ -212,9 +274,18 @@ private Review review(Long userId) { } private Review review(Long userId, Long reviewId) { + return review(userId, reviewId, RESERVATION_ID, RESTAURANT_ID); + } + + private Review review( + Long userId, + Long reviewId, + Long reservationId, + Long restaurantId + ) { Review review = Review.create( - RESERVATION_ID, - RESTAURANT_ID, + reservationId, + restaurantId, userId, 5, "직원분들이 친절하고 음식이 맛있었습니다." @@ -227,14 +298,44 @@ private Review review(Long userId, Long reviewId) { } private RestaurantInfo restaurant() { + return restaurant(ImageReference.legacy( + "https://cdn.example.com/restaurants/10/thumbnail.jpg")); + } + + private RestaurantInfo restaurant(ImageReference imageReference) { return new RestaurantInfo( RESTAURANT_ID, "야키토리 무사시", "도쿄도 시부야구", - "https://cdn.example.com/restaurants/10/thumbnail.jpg" + imageReference ); } + private MediaImageRequest request(UUID assetId) { + return new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL); + } + + private MediaImage readyImage(UUID assetId, String url) { + MediaImage.Source source = new MediaImage.Source(url, 192, 192, "image/webp"); + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(url, 192, 192))))); + } + + private MediaImage statusImage(UUID assetId, MediaImageStatus status) { + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + status, + null, + List.of()); + } + private ReservationReviewInfo reservation() { return new ReservationReviewInfo( RESERVATION_ID, diff --git a/src/test/java/org/sopt/hashi/review/service/ReviewReservationQueryServiceTest.java b/src/test/java/org/sopt/hashi/review/service/ReviewReservationQueryServiceTest.java index 86fd1c6f..c20c3b57 100644 --- a/src/test/java/org/sopt/hashi/review/service/ReviewReservationQueryServiceTest.java +++ b/src/test/java/org/sopt/hashi/review/service/ReviewReservationQueryServiceTest.java @@ -2,19 +2,32 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; import static org.mockito.BDDMockito.given; import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; import java.time.LocalDateTime; import java.util.List; import java.util.Map; import java.util.Optional; +import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.hashi.auth.CurrentUserProvider; +import org.sopt.hashi.media.ImageReference; +import org.sopt.hashi.media.MediaImage; +import org.sopt.hashi.media.MediaImageRequest; +import org.sopt.hashi.media.MediaImageRole; +import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.point.PointPort; import org.sopt.hashi.point.PointSourceType; import org.sopt.hashi.reservation.ReservationPort; @@ -45,6 +58,9 @@ class ReviewReservationQueryServiceTest { @Mock private RestaurantPort restaurantPort; + @Mock + private MediaPort mediaPort; + @Mock private PointPort pointPort; @@ -59,6 +75,7 @@ void setUp() { reviewRepository, reservationPort, restaurantPort, + mediaPort, pointPort, currentUserProvider ); @@ -67,10 +84,16 @@ void setUp() { @Test void 방문_완료된_미작성_예약의_리뷰_작성_정보를_조회한다() { ReservationReviewInfo reservation = reservation(100L, 10L, 22, ReservationStatus.VISITED); + UUID assetId = UUID.randomUUID(); + MediaImage readyImage = readyImage(assetId, "https://cdn.example.com/10/192.webp"); given(currentUserProvider.currentUserId()).willReturn(USER_ID); given(reservationPort.getReviewInfoByIdAndUserId(100L, USER_ID)).willReturn(reservation); given(restaurantPort.findSummaryById(10L)) - .willReturn(Optional.of(restaurant(10L, "아키토리 무사시"))); + .willReturn(Optional.of(restaurant( + 10L, + "아키토리 무사시", + new ImageReference(assetId, "https://legacy.example.com/10.jpg")))); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), readyImage)); given(reviewRepository.existsByReservationId(100L)).willReturn(false); ReviewContextResponse response = reviewReservationQueryService.getContext(100L); @@ -78,12 +101,59 @@ void setUp() { assertThat(response.reservationId()).isEqualTo(100L); assertThat(response.restaurantId()).isEqualTo(10L); assertThat(response.restaurantName()).isEqualTo("아키토리 무사시"); + assertThat(response.restaurantThumbnailUrl()) + .isEqualTo("https://cdn.example.com/10/192.webp"); + assertThat(response.restaurantThumbnailImage()).isEqualTo(readyImage); assertThat(response.teenCount()).isZero(); assertThat(response.reviewable()).isTrue(); assertThat(response.reviewUnavailableReason()).isNull(); assertThat(response.reviewKeywordOptions()) .extracting(ReviewContextResponse.ReviewKeywordOption::code) .contains("FOOD_IS_DELICIOUS", "STAFF_IS_KIND", "GOOD_VALUE"); + verify(mediaPort).findImages(argThat( + requests -> List.copyOf(requests).equals(List.of(request(assetId))))); + } + + @ParameterizedTest + @EnumSource(value = MediaImageStatus.class, names = {"PROCESSING", "FAILED"}) + void 처리되지_않은_식당_asset은_legacy_URL로_우회하지_않는다(MediaImageStatus status) { + ReservationReviewInfo reservation = reservation(100L, 10L, 22, ReservationStatus.VISITED); + UUID assetId = UUID.randomUUID(); + MediaImage statusImage = statusImage(assetId, status); + given(currentUserProvider.currentUserId()).willReturn(USER_ID); + given(reservationPort.getReviewInfoByIdAndUserId(100L, USER_ID)).willReturn(reservation); + given(restaurantPort.findSummaryById(10L)) + .willReturn(Optional.of(restaurant( + 10L, + "아키토리 무사시", + new ImageReference(assetId, "https://legacy.example.com/10.jpg")))); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), statusImage)); + given(reviewRepository.existsByReservationId(100L)).willReturn(false); + + ReviewContextResponse response = reviewReservationQueryService.getContext(100L); + + assertThat(response.restaurantThumbnailUrl()).isNull(); + assertThat(response.restaurantThumbnailImage()).isEqualTo(statusImage); + } + + @Test + void 식당_asset_조회가_누락되어도_legacy_URL로_우회하지_않는다() { + ReservationReviewInfo reservation = reservation(100L, 10L, 22, ReservationStatus.VISITED); + UUID assetId = UUID.randomUUID(); + given(currentUserProvider.currentUserId()).willReturn(USER_ID); + given(reservationPort.getReviewInfoByIdAndUserId(100L, USER_ID)).willReturn(reservation); + given(restaurantPort.findSummaryById(10L)) + .willReturn(Optional.of(restaurant( + 10L, + "아키토리 무사시", + new ImageReference(assetId, "https://legacy.example.com/10.jpg")))); + given(mediaPort.findImages(any())).willReturn(Map.of()); + given(reviewRepository.existsByReservationId(100L)).willReturn(false); + + ReviewContextResponse response = reviewReservationQueryService.getContext(100L); + + assertThat(response.restaurantThumbnailUrl()).isNull(); + assertThat(response.restaurantThumbnailImage()).isNull(); } @Test @@ -129,6 +199,42 @@ void setUp() { assertThat(response.content().get(1).reviewId()).isEqualTo(52L); assertThat(response.content().get(1).rating()).isEqualTo(4); assertThat(response.content().get(1).earnedPoint()).isEqualTo(500L); + verifyNoInteractions(mediaPort); + } + + @Test + void 방문_예약_목록은_응답_페이지의_식당_asset만_중복없이_bulk_조회한다() { + UUID assetId = UUID.randomUUID(); + List reservations = List.of( + reservation(103L, 10L, 23, ReservationStatus.VISITED), + reservation(102L, 10L, 22, ReservationStatus.VISITED), + reservation(101L, 20L, 21, ReservationStatus.VISITED) + ); + MediaImage readyImage = readyImage(assetId, "https://cdn.example.com/10/192.webp"); + given(currentUserProvider.currentUserId()).willReturn(USER_ID); + given(reservationPort.findVisitedReviewInfos(USER_ID)).willReturn(reservations); + given(reviewRepository.findByReservationIdIn(List.of(103L, 102L, 101L))) + .willReturn(List.of()); + given(restaurantPort.findSummaries(List.of(10L))) + .willReturn(List.of(restaurant( + 10L, + "아키토리 무사시", + new ImageReference(assetId, "https://legacy.example.com/10.jpg")))); + given(mediaPort.findImages(any())).willReturn(Map.of(request(assetId), readyImage)); + + VisitedReservationListResponse response = reviewReservationQueryService + .getVisitedReservations("all", null, "latest", null, 2); + + assertThat(response.content()).hasSize(2); + assertThat(response.content()) + .allSatisfy(item -> { + assertThat(item.restaurantThumbnailUrl()) + .isEqualTo("https://cdn.example.com/10/192.webp"); + assertThat(item.restaurantThumbnailImage()).isEqualTo(readyImage); + }); + verify(restaurantPort).findSummaries(List.of(10L)); + verify(mediaPort).findImages(argThat(requests -> + Set.copyOf(requests).equals(Set.of(request(assetId))))); } @Test @@ -158,6 +264,7 @@ void setUp() { assertThat(response.nextCursor()).isEqualTo(100L); assertThat(response.hasNext()).isTrue(); verify(restaurantPort).existsById(10L); + verifyNoInteractions(mediaPort); } @Test @@ -183,6 +290,7 @@ void setUp() { assertThat(allResponse.content().getFirst().reviewUnavailableReason()) .isEqualTo(ReviewUnavailableReason.UNSUPPORTED_RESERVATION_TYPE); assertThat(unreviewedResponse.content()).isEmpty(); + verifyNoInteractions(restaurantPort, mediaPort); } @Test @@ -306,14 +414,52 @@ private ReservationReviewInfo anywhereReservation(Long reservationId, int day) { } private RestaurantInfo restaurant(Long restaurantId, String name) { + return restaurant( + restaurantId, + name, + ImageReference.legacy( + "https://cdn.example.com/restaurants/%d/thumbnail.jpg" + .formatted(restaurantId))); + } + + private RestaurantInfo restaurant( + Long restaurantId, + String name, + ImageReference imageReference + ) { return new RestaurantInfo( restaurantId, name, "도쿄도", - "https://cdn.example.com/restaurants/%d/thumbnail.jpg".formatted(restaurantId) + imageReference ); } + private MediaImageRequest request(UUID assetId) { + return new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL); + } + + private MediaImage readyImage(UUID assetId, String url) { + MediaImage.Source source = new MediaImage.Source(url, 192, 192, "image/webp"); + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + MediaImageStatus.READY, + source, + List.of(new MediaImage.SourceSet( + "image/webp", + List.of(new MediaImage.Candidate(url, 192, 192))))); + } + + private MediaImage statusImage(UUID assetId, MediaImageStatus status) { + return new MediaImage( + assetId, + MediaImageRole.RESTAURANT_THUMBNAIL, + status, + null, + List.of()); + } + private Review review( Long reviewId, Long reservationId, From c77792e898f3db4e497c4e19301b211514be26b6 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 30 Aug 2026 16:40:37 +0900 Subject: [PATCH 07/26] =?UTF-8?q?fix(restaurant):=20=EA=B4=80=EB=A6=AC?= =?UTF-8?q?=EC=9E=90=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EA=B3=84=EC=95=BD?= =?UTF-8?q?=EA=B3=BC=20=EC=9E=A0=EA=B8=88=20=EB=B3=B4=EC=99=84=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restaurant/AdminRestaurantCommand.java | 4 +- .../restaurant/service/RestaurantService.java | 15 +- .../RestaurantMediaSchemaValidationTest.java | 48 ++++ .../service/RestaurantServiceTest.java | 38 +++ ...aurantWriteConcurrencyIntegrationTest.java | 223 ++++++++++++++++++ 5 files changed, 321 insertions(+), 7 deletions(-) create mode 100644 src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java diff --git a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java index 59975186..b997eea6 100644 --- a/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java +++ b/src/main/java/org/sopt/hashi/restaurant/AdminRestaurantCommand.java @@ -13,7 +13,9 @@ * restaurant가 해석한다(지원하지 않는 값이면 RESTAURANT-001/005). * 컬렉션은 전체 교체 의미다. 수정에서 null이면 유지하며, imageKeys·hashtags는 최소 1개를 유지해야 한다. * businessHours는 제공 시 7개 요일을 중복 없이 모두 포함해야 한다(위반 시 RESTAURANT-006). - * 이미지 키는 업로드 완료된 S3 object key다. + * 이미지 키는 업로드 완료된 S3 object key다. 신규 media 식당 이미지는 등록에서 + * {@code imageAssetIds}, 수정에서 stable association을 포함한 {@code images}만 사용하며, + * 반대 동작의 필드가 전달되면 묵시적으로 무시하지 않고 잘못된 입력으로 거부한다. */ public record AdminRestaurantCommand( String name, diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index 9bf10163..f511818d 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -368,6 +368,7 @@ public AdminRestaurantInfo createByAdmin(AdminRestaurantCommand command) { /** 어드민 식당 수정 — 부분 수정(PATCH). null 필드는 유지하고, 컬렉션은 전체 교체한다. */ @Transactional public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantCommand command) { + validateImageFieldsForUpdate(command); validateNonBlankIfPresent(command.localName()); // 자유 텍스트 전환(#145) 후에도 값 비우기 불가 정책 유지 — enum 시절엔 빈 값이 변환 단계에서 거부됐다 validateNonBlankIfPresent(command.foodCategory()); @@ -435,7 +436,7 @@ public AdminRestaurantInfo updateByAdmin(Long restaurantId, AdminRestaurantComma /** 어드민 식당 삭제 — soft delete(deleted=true). 예약·리뷰가 참조하는 데이터는 보존한다. */ @Transactional public void deleteByAdmin(Long restaurantId) { - Restaurant restaurant = findRestaurantForAdmin(restaurantId); + Restaurant restaurant = findRestaurantForAdminUpdate(restaurantId); restaurant.softDelete(); // 노출 종료 전이 — 예약·리뷰가 계속 참조하므로 언제 내려갔는지 기록이 필요하다 (adminId는 MDC) log.info("어드민 식당 삭제(soft). restaurantId={}", restaurantId); @@ -744,11 +745,6 @@ private BusinessException menuNotFoundException(Long restaurantId) { return new BusinessException(RestaurantErrorCode.MENU_NOT_FOUND); } - private Restaurant findRestaurantForAdmin(Long restaurantId) { - return restaurantRepository.findById(restaurantId) - .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); - } - private Restaurant findRestaurantForAdminUpdate(Long restaurantId) { return restaurantRepository.findByIdForUpdate(restaurantId) .orElseThrow(() -> new BusinessException(RestaurantErrorCode.NOT_FOUND)); @@ -761,6 +757,7 @@ private void validateRequiredForCreate(AdminRestaurantCommand command) { || command.area() == null || command.genre() == null || command.foodCategory() == null || command.foodCategory().isBlank() || command.priceCurrency() == null || command.minPrice() == null || command.maxPrice() == null + || command.images() != null || !hasExactlyOneCreateImageSource(command.imageKeys(), command.imageAssetIds()) || command.hashtags() == null || command.hashtags().isEmpty(); if (missingRequired) { @@ -791,6 +788,12 @@ private void validatePriceRange(BigDecimal minPrice, BigDecimal maxPrice) { } } + private void validateImageFieldsForUpdate(AdminRestaurantCommand command) { + if (command.imageAssetIds() != null) { + throw new BusinessException(CommonErrorCode.INVALID_INPUT); + } + } + private boolean hasExactlyOneCreateImageSource( List imageKeys, List imageAssetIds diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java index d860a2e4..9fd8d106 100644 --- a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantMediaSchemaValidationTest.java @@ -5,8 +5,12 @@ import java.math.BigDecimal; import java.util.Map; +import java.util.List; import java.util.UUID; import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; +import org.sopt.hashi.restaurant.service.RestaurantService; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; @@ -38,6 +42,9 @@ class RestaurantMediaSchemaValidationTest { @Autowired private RestaurantRepository restaurantRepository; + @Autowired + private RestaurantService restaurantService; + @Autowired private JdbcTemplate jdbcTemplate; @@ -82,6 +89,40 @@ AND table_name IN ('restaurant_image', 'restaurant_menu') .isInstanceOf(DataAccessException.class); } + @Test + void 식당_이미지_재정렬은_MySQL_unique_제약에서도_ID와_1_based_순서를_유지한다() { + Restaurant restaurant = createRestaurant(); + restaurant.replaceImages(List.of( + RestaurantImage.createLegacy("restaurants/A.jpg", 1), + RestaurantImage.createLegacy("restaurants/B.jpg", 2), + RestaurantImage.createLegacy("restaurants/C.jpg", 3) + )); + restaurantRepository.saveAndFlush(restaurant); + Long restaurantId = restaurant.getId(); + Long firstId = restaurant.getImages().get(0).getId(); + Long secondId = restaurant.getImages().get(1).getId(); + Long thirdId = restaurant.getImages().get(2).getId(); + + restaurantService.updateByAdmin( + restaurantId, + updateImagesCommand(List.of( + new ImageCommand(thirdId, null), + new ImageCommand(firstId, null), + new ImageCommand(secondId, null) + )) + ); + + Restaurant reloaded = restaurantRepository + .findActiveByIdWithImages(restaurantId) + .orElseThrow(); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getId) + .containsExactly(thirdId, firstId, secondId); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getDisplayOrder) + .containsExactly(1, 2, 3); + } + private void assertAssetColumn(String tableName, String indexName) { Map column = jdbcTemplate.queryForMap(""" SELECT character_maximum_length, @@ -149,4 +190,11 @@ private Restaurant createRestaurant() { BigDecimal.valueOf(3_000) ); } + + private AdminRestaurantCommand updateImagesCommand(List images) { + return new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, null, images, null, null, null, null + ); + } } diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index 41af9d73..9d7f4e05 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -219,6 +219,44 @@ private RestaurantService createRestaurantService() { verifyNoInteractions(restaurantRepository); } + @Test + void 어드민_식당_등록과_수정은_각_동작에서_지원하지_않는_이미지_필드를_거부한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID assetId = UUID.randomUUID(); + AdminRestaurantCommand createCommand = new AdminRestaurantCommand( + "히마와리 스시", "Himawari Sushi", "식당 소개", "매장 상세 설명", + "도쿄도 신주쿠구", "도쿄", "sushi", "sushi", "JPY", + BigDecimal.valueOf(1_000), BigDecimal.valueOf(3_000), + null, List.of(assetId), List.of(new ImageCommand(null, assetId)), + null, List.of("스시"), null, null + ); + AdminRestaurantCommand updateCommand = new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, List.of(assetId), null, null, null, null, null + ); + + assertThatThrownBy(() -> restaurantService.createByAdmin(createCommand)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + assertThatThrownBy(() -> restaurantService.updateByAdmin(1L, updateCommand)) + .isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(CommonErrorCode.INVALID_INPUT)); + verifyNoInteractions(restaurantRepository, mediaPort); + } + + @Test + void 어드민_식당_삭제는_수정과_같은_write_lock으로_Aggregate를_조회한다() { + RestaurantService restaurantService = createRestaurantService(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + + restaurantService.deleteByAdmin(1L); + + assertThat(restaurant.isDeleted()).isTrue(); + verify(restaurantRepository).findByIdForUpdate(1L); + verify(restaurantRepository, never()).findById(1L); + } + @Test void 어드민_메뉴_수정은_기존_ID를_유지하고_신규와_삭제를_동기화한다() { RestaurantService restaurantService = createRestaurantService(); diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java new file mode 100644 index 00000000..c49e43a9 --- /dev/null +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java @@ -0,0 +1,223 @@ +package org.sopt.hashi.restaurant.service; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.math.BigDecimal; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.restaurant.AdminRestaurantCommand; +import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; +import org.sopt.hashi.restaurant.domain.PriceCurrency; +import org.sopt.hashi.restaurant.domain.Restaurant; +import org.sopt.hashi.restaurant.domain.RestaurantGenre; +import org.sopt.hashi.restaurant.domain.RestaurantImage; +import org.sopt.hashi.restaurant.domain.RestaurantRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.boot.testcontainers.service.connection.ServiceConnection; +import org.springframework.test.annotation.DirtiesContext; +import org.springframework.transaction.support.TransactionTemplate; +import org.testcontainers.containers.MySQLContainer; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; + +@Testcontainers(disabledWithoutDocker = true) +@SpringBootTest(properties = { + "spring.jpa.hibernate.ddl-auto=validate", + "jwt.secret=test-secret-key-must-be-at-least-32-bytes-long", + "kakao.client-id=test-client-id", + "kakao.redirect-uri=https://app.hashi.test/callback", + "hashi.storage.cloudfront-domain=https://cdn.hashi.test" +}) +@DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) +class RestaurantWriteConcurrencyIntegrationTest { + + @Container + @ServiceConnection + private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") + .withDatabaseName("hashi") + .withUsername("hashi") + .withPassword("hashi"); + + @Autowired + private RestaurantService restaurantService; + + @Autowired + private RestaurantRepository restaurantRepository; + + @Autowired + private TransactionTemplate transactionTemplate; + + private ExecutorService executor; + + @BeforeEach + void setUp() { + executor = Executors.newFixedThreadPool(2); + } + + @AfterEach + void tearDown() throws InterruptedException { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); + } + + @Test + void 수정과_삭제가_경쟁해도_먼저_commit된_수정값을_삭제가_되돌리지_않는다() throws Exception { + Restaurant restaurant = restaurantRepository.saveAndFlush(createRestaurant("잠금 전 식당")); + Long restaurantId = restaurant.getId(); + CountDownLatch updateFlushed = new CountDownLatch(1); + CountDownLatch allowUpdateCommit = new CountDownLatch(1); + + Future update = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + restaurantService.updateByAdmin( + restaurantId, + updateNameCommand("잠금 후 식당") + ); + updateFlushed.countDown(); + await(allowUpdateCommit, "수정 transaction commit"); + })); + assertThat(updateFlushed.await(10, TimeUnit.SECONDS)).isTrue(); + + Future delete = executor.submit(() -> restaurantService.deleteByAdmin(restaurantId)); + try { + awaitMySqlRowLockCompetition(); + } finally { + allowUpdateCommit.countDown(); + } + + update.get(20, TimeUnit.SECONDS); + delete.get(20, TimeUnit.SECONDS); + + Restaurant reloaded = restaurantRepository.findById(restaurantId).orElseThrow(); + assertThat(reloaded.getName()).isEqualTo("잠금 후 식당"); + assertThat(reloaded.isDeleted()).isTrue(); + } + + @Test + void 같은_식당의_동시_재정렬은_완전한_요청_하나로_직렬화된다() throws Exception { + Restaurant restaurant = createRestaurant("동시 재정렬 식당"); + restaurant.replaceImages(List.of( + RestaurantImage.createLegacy("restaurants/A.jpg", 1), + RestaurantImage.createLegacy("restaurants/B.jpg", 2), + RestaurantImage.createLegacy("restaurants/C.jpg", 3) + )); + restaurantRepository.saveAndFlush(restaurant); + Long restaurantId = restaurant.getId(); + Long firstId = restaurant.getImages().get(0).getId(); + Long secondId = restaurant.getImages().get(1).getId(); + Long thirdId = restaurant.getImages().get(2).getId(); + List firstOrder = List.of(thirdId, firstId, secondId); + List secondOrder = List.of(secondId, thirdId, firstId); + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + + Future first = executor.submit(() -> reorderAfterBarrier( + restaurantId, firstOrder, ready, start)); + Future second = executor.submit(() -> reorderAfterBarrier( + restaurantId, secondOrder, ready, start)); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + + first.get(20, TimeUnit.SECONDS); + second.get(20, TimeUnit.SECONDS); + + Restaurant reloaded = restaurantRepository + .findActiveByIdWithImages(restaurantId) + .orElseThrow(); + List finalOrder = reloaded.getImages().stream() + .map(RestaurantImage::getId) + .toList(); + assertThat(List.of(firstOrder, secondOrder)).contains(finalOrder); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getDisplayOrder) + .containsExactly(1, 2, 3); + } + + private void reorderAfterBarrier( + Long restaurantId, + List imageIds, + CountDownLatch ready, + CountDownLatch start + ) { + ready.countDown(); + await(start, "동시 재정렬 시작"); + restaurantService.updateByAdmin( + restaurantId, + updateImagesCommand(imageIds.stream() + .map(id -> new ImageCommand(id, null)) + .toList()) + ); + } + + private void awaitMySqlRowLockCompetition() throws InterruptedException, SQLException { + try (Connection connection = DriverManager.getConnection( + MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); + Statement statement = connection.createStatement()) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM performance_schema.data_lock_waits")) { + resultSet.next(); + if (resultSet.getInt(1) > 0) { + return; + } + } + Thread.sleep(50); + } + } + throw new AssertionError("MySQL row lock 경쟁이 제한 시간 안에 관찰되지 않았습니다"); + } + + private void await(CountDownLatch latch, String description) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException(description + " 대기 시간이 초과되었습니다"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(description + " 대기가 중단되었습니다", exception); + } + } + + private AdminRestaurantCommand updateNameCommand(String name) { + return new AdminRestaurantCommand( + name, null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null + ); + } + + private AdminRestaurantCommand updateImagesCommand(List images) { + return new AdminRestaurantCommand( + null, null, null, null, null, null, null, null, null, null, null, + null, null, images, null, null, null, null + ); + } + + private Restaurant createRestaurant(String name) { + return Restaurant.create( + name, + "Concurrency Restaurant", + "식당 소개", + "식당 상세 설명", + "도쿄도 신주쿠구", + "도쿄", + RestaurantGenre.SUSHI, + "초밥", + PriceCurrency.JPY, + BigDecimal.valueOf(1_000), + BigDecimal.valueOf(3_000) + ); + } +} From a8f1ff7c38a5a9d9b60150bd4623b69dac0aea80 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 30 Aug 2026 16:44:58 +0900 Subject: [PATCH 08/26] =?UTF-8?q?test(media):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20binding=20=EB=8F=99=EC=8B=9C=EC=84=B1=20=EA=B2=80?= =?UTF-8?q?=EC=A6=9D=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MediaPortIntegrationTest.java | 107 ++++++++++++++++++ 1 file changed, 107 insertions(+) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java index 76166b54..e681d032 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -10,8 +10,14 @@ import java.util.List; import java.util.Map; import java.util.UUID; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.Future; +import java.util.concurrent.TimeUnit; import org.hibernate.SessionFactory; import org.hibernate.stat.Statistics; +import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.sopt.hashi.auth.ActorType; @@ -23,6 +29,7 @@ import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.media.code.MediaErrorCode; import org.sopt.hashi.media.domain.ImageAsset; import org.sopt.hashi.media.domain.ImageAssetRepository; import org.sopt.hashi.media.domain.ImageBindingStatus; @@ -30,6 +37,7 @@ import org.sopt.hashi.media.domain.ImageRole; import org.sopt.hashi.media.domain.MediaOwnerType; import org.sopt.hashi.media.domain.MediaPurpose; +import org.sopt.hashi.shared.error.BusinessException; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; @@ -82,12 +90,21 @@ class MediaPortIntegrationTest { @MockitoBean private CurrentActorProvider currentActorProvider; + private ExecutorService executor; + @BeforeEach void setUp() { jdbcTemplate.update("DELETE FROM image_rendition"); jdbcTemplate.update("DELETE FROM image_asset"); when(currentActorProvider.currentActor()) .thenReturn(new CurrentActor(ActorType.ADMIN, 1L)); + executor = Executors.newFixedThreadPool(2); + } + + @AfterEach + void tearDown() throws InterruptedException { + executor.shutdownNow(); + executor.awaitTermination(5, TimeUnit.SECONDS); } @Test @@ -128,6 +145,49 @@ void setUp() { assertThat(manyAssetQueries).isEqualTo(2L); } + @Test + void 동일_asset을_동시에_claim하면_하나만_BOUND로_전이한다() throws Exception { + ImageAsset asset = readyAsset(MediaPurpose.RESTAURANT, false); + transactionTemplate.executeWithoutResult(status -> imageAssetRepository.save(asset)); + List claims = List.of(new MediaAssetUse( + asset.getPublicId(), MediaAssetPurpose.RESTAURANT)); + + List results = claimConcurrently(claims, claims); + + assertThat(results).containsExactlyInAnyOrder( + ClaimResult.success(), + ClaimResult.failure(MediaErrorCode.ALREADY_BOUND) + ); + assertThat(imageAssetRepository.findByPublicId(asset.getPublicId()).orElseThrow() + .getBindingStatus()).isEqualTo(ImageBindingStatus.BOUND); + } + + @Test + void 반대_입력_순서의_다중_asset_claim도_내부_ID_순서로_잠가_deadlock을_피한다() throws Exception { + ImageAsset first = readyAsset(MediaPurpose.RESTAURANT, false); + ImageAsset second = readyAsset(MediaPurpose.RESTAURANT, false); + transactionTemplate.executeWithoutResult(status -> + imageAssetRepository.saveAll(List.of(first, second))); + MediaAssetUse firstUse = new MediaAssetUse( + first.getPublicId(), MediaAssetPurpose.RESTAURANT); + MediaAssetUse secondUse = new MediaAssetUse( + second.getPublicId(), MediaAssetPurpose.RESTAURANT); + + List results = claimConcurrently( + List.of(firstUse, secondUse), + List.of(secondUse, firstUse) + ); + + assertThat(results).containsExactlyInAnyOrder( + ClaimResult.success(), + ClaimResult.failure(MediaErrorCode.ALREADY_BOUND) + ); + assertThat(imageAssetRepository.findAllByPublicIdIn( + List.of(first.getPublicId(), second.getPublicId()))) + .extracting(ImageAsset::getBindingStatus) + .containsOnly(ImageBindingStatus.BOUND); + } + private long projectionQueryCount(int count) { List assets = new ArrayList<>(); for (int index = 0; index < count; index++) { @@ -149,6 +209,42 @@ private long projectionQueryCount(int count) { return statistics.getPrepareStatementCount(); } + private List claimConcurrently( + List firstClaims, + List secondClaims + ) throws Exception { + CountDownLatch ready = new CountDownLatch(2); + CountDownLatch start = new CountDownLatch(1); + Future first = executor.submit(() -> + claimAfterBarrier(firstClaims, ready, start)); + Future second = executor.submit(() -> + claimAfterBarrier(secondClaims, ready, start)); + assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); + start.countDown(); + return List.of( + first.get(20, TimeUnit.SECONDS), + second.get(20, TimeUnit.SECONDS) + ); + } + + private ClaimResult claimAfterBarrier( + List claims, + CountDownLatch ready, + CountDownLatch start + ) throws InterruptedException { + ready.countDown(); + if (!start.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException("concurrent binding start timed out"); + } + try { + transactionTemplate.executeWithoutResult(status -> + mediaPort.reconcileBindings(claims, List.of())); + return ClaimResult.success(); + } catch (BusinessException exception) { + return ClaimResult.failure((MediaErrorCode) exception.getErrorCode()); + } + } + private ImageAsset readyAsset(MediaPurpose purpose, boolean bound) { UUID assetId = UUID.randomUUID(); ImageAsset asset = ImageAsset.createDirectUpload( @@ -194,4 +290,15 @@ private void addRendition(ImageAsset asset, UUID jobId, ImageRole role, int widt asset.getPublicId(), role.name().toLowerCase(), width) ); } + + private record ClaimResult(boolean succeeded, MediaErrorCode errorCode) { + + private static ClaimResult success() { + return new ClaimResult(true, null); + } + + private static ClaimResult failure(MediaErrorCode errorCode) { + return new ClaimResult(false, errorCode); + } + } } From 4827079918d815862156325fd780b6efe85d6094 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 30 Aug 2026 16:47:04 +0900 Subject: [PATCH 09/26] =?UTF-8?q?fix(admin):=20=EC=8B=9D=EB=8B=B9=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=98=A4=EB=A5=98=20=EA=B3=84?= =?UTF-8?q?=EC=95=BD=20=EB=85=B8=EC=B6=9C=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/web/AdminRestaurantController.java | 5 ++ ...AdminRestaurantControllerContractTest.java | 49 +++++++++++++++++++ 2 files changed, 54 insertions(+) create mode 100644 src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerContractTest.java diff --git a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java index c53eb10b..f80506f7 100644 --- a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java +++ b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java @@ -8,6 +8,7 @@ import org.sopt.hashi.admin.dto.CreateRestaurantRequest; import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; import org.sopt.hashi.admin.service.AdminRestaurantService; +import org.sopt.hashi.media.code.MediaErrorCode; import org.sopt.hashi.shared.error.CommonErrorCode; import org.sopt.hashi.shared.response.SuccessResponse; import org.sopt.hashi.shared.swagger.ApiErrorResponse; @@ -76,6 +77,8 @@ public AdminRestaurantController(AdminRestaurantService adminRestaurantService) } """))) @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiException(value = MediaErrorCode.class, + codes = {"ASSET_NOT_FOUND", "INVALID_STATE", "ALREADY_BOUND", "DUPLICATE_ASSET"}) @ApiSuccess(value = AdminSuccessCode.class, codes = {"RESTAURANT_CREATED"}) @ResponseStatus(HttpStatus.CREATED) @PostMapping @@ -116,6 +119,8 @@ public SuccessResponse create( } """))) @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) + @ApiException(value = MediaErrorCode.class, + codes = {"ASSET_NOT_FOUND", "INVALID_STATE", "ALREADY_BOUND", "DUPLICATE_ASSET"}) @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-009", diff --git a/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerContractTest.java b/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerContractTest.java new file mode 100644 index 00000000..d9016e3a --- /dev/null +++ b/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerContractTest.java @@ -0,0 +1,49 @@ +package org.sopt.hashi.admin.web; + +import static org.assertj.core.api.Assertions.assertThat; + +import io.swagger.v3.oas.models.Operation; +import io.swagger.v3.oas.models.examples.Example; +import io.swagger.v3.oas.models.responses.ApiResponses; +import java.lang.reflect.Method; +import java.util.Map; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.admin.dto.CreateRestaurantRequest; +import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; +import org.sopt.hashi.config.ApiExceptionsOperationCustomizer; +import org.springframework.web.method.HandlerMethod; + +class AdminRestaurantControllerContractTest { + + private final ApiExceptionsOperationCustomizer customizer = + new ApiExceptionsOperationCustomizer(); + private final AdminRestaurantController controller = + new AdminRestaurantController(null); + + @Test + void 식당_등록과_수정은_media_binding_오류를_OpenAPI에_노출한다() throws Exception { + assertMediaBindingErrors("create", CreateRestaurantRequest.class); + assertMediaBindingErrors("update", Long.class, UpdateRestaurantRequest.class); + } + + private void assertMediaBindingErrors(String methodName, Class... parameterTypes) + throws Exception { + Method method = AdminRestaurantController.class + .getDeclaredMethod(methodName, parameterTypes); + Operation operation = new Operation().responses(new ApiResponses()); + + customizer.customize(operation, new HandlerMethod(controller, method)); + + assertThat(examples(operation, "400")).containsKey("MEDIA-008"); + assertThat(examples(operation, "404")).containsKey("MEDIA-001"); + assertThat(examples(operation, "409")).containsKeys("MEDIA-006", "MEDIA-007"); + } + + private Map examples(Operation operation, String status) { + return operation.getResponses() + .get(status) + .getContent() + .get("application/json") + .getExamples(); + } +} From 39b6e3a8997b137291e1b41aba8beb988220dcf7 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 30 Aug 2026 16:51:50 +0900 Subject: [PATCH 10/26] =?UTF-8?q?fix(admin):=20media=20=EC=98=A4=EB=A5=98?= =?UTF-8?q?=20=EB=AC=B8=EC=84=9C=EC=9D=98=20=EB=AA=A8=EB=93=88=20=EA=B2=BD?= =?UTF-8?q?=EA=B3=84=20=EC=9C=A0=EC=A7=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/web/AdminRestaurantController.java | 21 ++++++++++++++----- 1 file changed, 16 insertions(+), 5 deletions(-) diff --git a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java index f80506f7..0aa1eb04 100644 --- a/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java +++ b/src/main/java/org/sopt/hashi/admin/web/AdminRestaurantController.java @@ -8,7 +8,6 @@ import org.sopt.hashi.admin.dto.CreateRestaurantRequest; import org.sopt.hashi.admin.dto.UpdateRestaurantRequest; import org.sopt.hashi.admin.service.AdminRestaurantService; -import org.sopt.hashi.media.code.MediaErrorCode; import org.sopt.hashi.shared.error.CommonErrorCode; import org.sopt.hashi.shared.response.SuccessResponse; import org.sopt.hashi.shared.swagger.ApiErrorResponse; @@ -77,8 +76,14 @@ public AdminRestaurantController(AdminRestaurantService adminRestaurantService) } """))) @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) - @ApiException(value = MediaErrorCode.class, - codes = {"ASSET_NOT_FOUND", "INVALID_STATE", "ALREADY_BOUND", "DUPLICATE_ASSET"}) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "MEDIA-001", + message = "이미지 자산을 찾을 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "MEDIA-006", + message = "현재 이미지 상태에서는 요청을 처리할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "MEDIA-007", + message = "이미 사용 중이거나 사용이 끝난 이미지입니다") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, code = "MEDIA-008", + message = "같은 이미지 자산을 중복해서 요청할 수 없습니다") @ApiSuccess(value = AdminSuccessCode.class, codes = {"RESTAURANT_CREATED"}) @ResponseStatus(HttpStatus.CREATED) @PostMapping @@ -119,8 +124,14 @@ public SuccessResponse create( } """))) @ApiException(value = CommonErrorCode.class, codes = {"INVALID_INPUT", "UNAUTHORIZED", "FORBIDDEN"}) - @ApiException(value = MediaErrorCode.class, - codes = {"ASSET_NOT_FOUND", "INVALID_STATE", "ALREADY_BOUND", "DUPLICATE_ASSET"}) + @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "MEDIA-001", + message = "이미지 자산을 찾을 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "MEDIA-006", + message = "현재 이미지 상태에서는 요청을 처리할 수 없습니다") + @ApiErrorResponse(status = HttpStatus.CONFLICT, code = "MEDIA-007", + message = "이미 사용 중이거나 사용이 끝난 이미지입니다") + @ApiErrorResponse(status = HttpStatus.BAD_REQUEST, code = "MEDIA-008", + message = "같은 이미지 자산을 중복해서 요청할 수 없습니다") @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-004", message = "식당을 찾을 수 없습니다.") @ApiErrorResponse(status = HttpStatus.NOT_FOUND, code = "RESTAURANT-009", From 3a4bf06405d6472dd73ce79bcb8bcb9e4a7b1556 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sun, 30 Aug 2026 23:47:56 +0900 Subject: [PATCH 11/26] =?UTF-8?q?fix(admin):=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=9E=85=EB=A0=A5=20=EA=B3=84=EC=95=BD=EA=B3=BC=20media=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EC=9D=BC=EC=B9=98=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/dto/CreateRestaurantRequest.java | 37 +++++++ .../admin/dto/UpdateRestaurantRequest.java | 39 +++++++- .../restaurant/service/RestaurantService.java | 4 - .../web/AdminRestaurantControllerTest.java | 99 +++++++++++++++++++ .../service/RestaurantServiceTest.java | 26 +++++ 5 files changed, 200 insertions(+), 5 deletions(-) create mode 100644 src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerTest.java diff --git a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java index bbae69d4..52cc6653 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/CreateRestaurantRequest.java @@ -1,6 +1,8 @@ package org.sopt.hashi.admin.dto; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; @@ -60,6 +62,9 @@ public record CreateRestaurantRequest( @Schema(description = "식당 이미지 asset ID 목록") @Size(min = 1, message = "식당 이미지 asset은 최소 1개 이상 필요합니다") List<@NotNull(message = "이미지 asset ID는 null일 수 없습니다") UUID> imageAssetIds, + @JsonProperty("images") + @Schema(hidden = true) + JsonNode unsupportedImages, List<@NotNull(message = "메뉴 항목은 null일 수 없습니다") @Valid MenuRequest> menus, @Schema(description = "해시태그 목록", example = "[\"현지인맛집\"]") @NotNull(message = "해시태그는 필수입니다") @@ -97,12 +102,44 @@ public CreateRestaurantRequest( curationTypes, businessHours); } + /** 신규 asset 생성 호출부가 사용하던 canonical 인자 순서를 유지한다. */ + public CreateRestaurantRequest( + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + List imageKeys, + List imageAssetIds, + List menus, + List hashtags, + List curationTypes, + List businessHours + ) { + this( + name, localName, summary, description, address, area, genre, foodCategory, + priceCurrency, minPrice, maxPrice, imageKeys, imageAssetIds, null, menus, + hashtags, curationTypes, businessHours); + } + @AssertTrue(message = "식당 이미지는 imageKeys 또는 imageAssetIds 중 하나만 필요합니다") @JsonIgnore public boolean isImageSourceValid() { return (imageKeys == null) != (imageAssetIds == null); } + @AssertTrue(message = "식당 등록에서는 images를 사용할 수 없습니다") + @JsonIgnore + public boolean isCreateImageContractValid() { + return unsupportedImages == null; + } + /** 메뉴 항목 — 목록 전체가 함께 저장되므로 각 항목은 완전한 값으로 받는다. */ public record MenuRequest( @Schema(description = "메뉴명", example = "특선 모둠 야키니쿠") diff --git a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java index 859795c0..68988c4f 100644 --- a/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java +++ b/src/main/java/org/sopt/hashi/admin/dto/UpdateRestaurantRequest.java @@ -1,6 +1,8 @@ package org.sopt.hashi.admin.dto; import com.fasterxml.jackson.annotation.JsonIgnore; +import com.fasterxml.jackson.annotation.JsonProperty; +import com.fasterxml.jackson.databind.JsonNode; import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.Valid; import jakarta.validation.constraints.AssertTrue; @@ -54,6 +56,9 @@ public record UpdateRestaurantRequest( @Schema(description = "식당 이미지 ordered wrapper(선택) — 보내면 전체 교체") @Size(min = 1, message = "식당 이미지는 최소 1개 이상 필요합니다") List<@NotNull(message = "식당 이미지 항목은 null일 수 없습니다") @Valid ImageRequest> images, + @JsonProperty("imageAssetIds") + @Schema(hidden = true) + JsonNode unsupportedImageAssetIds, List<@NotNull(message = "메뉴 항목은 null일 수 없습니다") @Valid MenuRequest> menus, @Schema(description = "해시태그 목록(선택) — 보내면 전체 교체, 최소 1개", example = "[\"오마카세\"]") @Size(min = 1, message = "해시태그는 최소 1개 이상 필요합니다") @@ -85,7 +90,33 @@ public UpdateRestaurantRequest( ) { this( name, localName, summary, description, address, area, genre, foodCategory, - priceCurrency, minPrice, maxPrice, imageKeys, null, menus, hashtags, + priceCurrency, minPrice, maxPrice, imageKeys, null, null, menus, hashtags, + curationTypes, businessHours); + } + + /** ordered wrapper 호출부가 사용하던 canonical 인자 순서를 유지한다. */ + public UpdateRestaurantRequest( + String name, + String localName, + String summary, + String description, + String address, + String area, + String genre, + String foodCategory, + String priceCurrency, + BigDecimal minPrice, + BigDecimal maxPrice, + List imageKeys, + List images, + List menus, + List hashtags, + List curationTypes, + List businessHours + ) { + this( + name, localName, summary, description, address, area, genre, foodCategory, + priceCurrency, minPrice, maxPrice, imageKeys, images, null, menus, hashtags, curationTypes, businessHours); } @@ -95,6 +126,12 @@ public boolean isImageCollectionSourceValid() { return imageKeys == null || images == null; } + @AssertTrue(message = "식당 수정에서는 imageAssetIds를 사용할 수 없습니다") + @JsonIgnore + public boolean isUpdateImageContractValid() { + return unsupportedImageAssetIds == null; + } + public record ImageRequest( @Positive(message = "식당 이미지 ID는 1 이상이어야 합니다") Long restaurantImageId, UUID imageAssetId diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index f511818d..d4a2d4db 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -954,7 +954,6 @@ private RestaurantImageUpdatePlan planAssetImageUpdate( Map existingById = existingImages.stream() .collect(Collectors.toMap(RestaurantImage::getId, Function.identity())); Set retainedIds = new HashSet<>(); - Set newAssetIds = new HashSet<>(); List finalImages = new ArrayList<>(); List claims = new ArrayList<>(); @@ -975,9 +974,6 @@ private RestaurantImageUpdatePlan planAssetImageUpdate( continue; } UUID assetId = requireAssetId(image.imageAssetId()); - if (!newAssetIds.add(assetId)) { - throw new BusinessException(CommonErrorCode.INVALID_INPUT); - } finalImages.add(RestaurantImage.createAsset(assetId, 1)); claims.add(new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)); } diff --git a/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerTest.java b/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerTest.java new file mode 100644 index 00000000..23cec465 --- /dev/null +++ b/src/test/java/org/sopt/hashi/admin/web/AdminRestaurantControllerTest.java @@ -0,0 +1,99 @@ +package org.sopt.hashi.admin.web; + +import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.patch; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.sopt.hashi.admin.service.AdminRestaurantService; +import org.sopt.hashi.auth.internal.onboarding.OnboardingJwtIssuer; +import org.sopt.hashi.auth.internal.security.JwtAuthenticationFilter; +import org.sopt.hashi.shared.exception.GlobalExceptionHandler; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.context.annotation.ComponentScan.Filter; +import org.springframework.context.annotation.FilterType; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +@WebMvcTest( + controllers = AdminRestaurantController.class, + excludeFilters = @Filter( + type = FilterType.ASSIGNABLE_TYPE, + classes = {OnboardingJwtIssuer.class, JwtAuthenticationFilter.class})) +@AutoConfigureMockMvc(addFilters = false) +@Import(GlobalExceptionHandler.class) +class AdminRestaurantControllerTest { + + @Autowired + private MockMvc mockMvc; + + @MockitoBean + private AdminRestaurantService adminRestaurantService; + + @Test + void 등록은_수정용_images_필드를_무시하지_않고_400으로_거부한다() throws Exception { + UUID assetId = UUID.randomUUID(); + + mockMvc.perform(post("/api/v1/admin/restaurants") + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "name": "하시 스시", + "localName": "ハシ寿司", + "summary": "한 줄 소개", + "description": "상세 설명", + "address": "도쿄도 시부야구", + "area": "도쿄", + "genre": "sushi", + "foodCategory": "스시", + "priceCurrency": "JPY", + "minPrice": 1000, + "maxPrice": 3000, + "imageAssetIds": ["%s"], + "images": [{"imageAssetId": "%s"}], + "menus": [], + "hashtags": ["스시"], + "curationTypes": [], + "businessHours": [ + {"dayOfWeek": "MONDAY", "closed": true}, + {"dayOfWeek": "TUESDAY", "closed": true}, + {"dayOfWeek": "WEDNESDAY", "closed": true}, + {"dayOfWeek": "THURSDAY", "closed": true}, + {"dayOfWeek": "FRIDAY", "closed": true}, + {"dayOfWeek": "SATURDAY", "closed": true}, + {"dayOfWeek": "SUNDAY", "closed": true} + ] + } + """.formatted(assetId, assetId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-400")); + + verifyNoInteractions(adminRestaurantService); + } + + @Test + void 수정은_등록용_imageAssetIds_필드를_무시하지_않고_400으로_거부한다() throws Exception { + UUID retainedAssetId = UUID.randomUUID(); + UUID unsupportedAssetId = UUID.randomUUID(); + + mockMvc.perform(patch("/api/v1/admin/restaurants/{restaurantId}", 1L) + .contentType(MediaType.APPLICATION_JSON) + .content(""" + { + "images": [{"imageAssetId": "%s"}], + "imageAssetIds": ["%s"] + } + """.formatted(retainedAssetId, unsupportedAssetId))) + .andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.code").value("COMMON-400")); + + verifyNoInteractions(adminRestaurantService); + } +} diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index 9d7f4e05..bebdb489 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -37,6 +37,7 @@ import org.sopt.hashi.media.MediaImageRole; import org.sopt.hashi.media.MediaImageStatus; import org.sopt.hashi.media.MediaPort; +import org.sopt.hashi.media.code.MediaErrorCode; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.MenuCommand; @@ -445,6 +446,31 @@ private RestaurantService createRestaurantService() { ); } + @Test + void ordered_wrapper의_중복_asset은_media_중복_오류를_그대로_반환한다() { + RestaurantService restaurantService = createRestaurantService(); + UUID duplicateAssetId = UUID.randomUUID(); + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + restaurant.replaceImages(List.of(RestaurantImage.createLegacy("restaurants/current.jpg", 1))); + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + doThrow(new BusinessException(MediaErrorCode.DUPLICATE_ASSET)) + .when(mediaPort).reconcileBindings(any(), any()); + + assertThatThrownBy(() -> restaurantService.updateByAdmin( + 1L, + updateImagesCommand(null, List.of( + new ImageCommand(null, duplicateAssetId), + new ImageCommand(null, duplicateAssetId) + )) + )).isInstanceOfSatisfying(BusinessException.class, exception -> + assertThat(exception.getErrorCode()).isEqualTo(MediaErrorCode.DUPLICATE_ASSET)); + + assertThat(restaurant.getImages()) + .extracting(RestaurantImage::getFileKey) + .containsExactly("restaurants/current.jpg"); + verify(restaurantRepository, never()).flush(); + } + @Test void legacy_중복_key_재정렬은_기존_ID와_backfill_asset을_보존한다() { RestaurantService restaurantService = createRestaurantService(); From 37c7e13c64b228fc8e678561474acf5182cb2d33 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Mon, 31 Aug 2026 00:10:52 +0900 Subject: [PATCH 12/26] =?UTF-8?q?test(media):=20=EC=8B=A4=EC=A0=9C=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20binding=20=EC=9E=A0=EA=B8=88=20?= =?UTF-8?q?=EA=B2=BD=ED=95=A9=20=EA=B2=80=EC=A6=9D=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/MediaPortIntegrationTest.java | 77 +++++-- ...aurantWriteConcurrencyIntegrationTest.java | 205 ++++++++++++++---- 2 files changed, 227 insertions(+), 55 deletions(-) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java index e681d032..0537758f 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -5,6 +5,11 @@ import static org.mockito.Mockito.when; import jakarta.persistence.EntityManagerFactory; +import java.sql.Connection; +import java.sql.DriverManager; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; import java.time.LocalDateTime; import java.util.ArrayList; import java.util.List; @@ -213,29 +218,43 @@ private List claimConcurrently( List firstClaims, List secondClaims ) throws Exception { - CountDownLatch ready = new CountDownLatch(2); - CountDownLatch start = new CountDownLatch(1); + CountDownLatch firstClaimFlushed = new CountDownLatch(1); + CountDownLatch allowFirstCommit = new CountDownLatch(1); Future first = executor.submit(() -> - claimAfterBarrier(firstClaims, ready, start)); - Future second = executor.submit(() -> - claimAfterBarrier(secondClaims, ready, start)); - assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); - start.countDown(); + claimAndHold(firstClaims, firstClaimFlushed, allowFirstCommit)); + assertThat(firstClaimFlushed.await(10, TimeUnit.SECONDS)).isTrue(); + + Future second = executor.submit(() -> claimOnce(secondClaims)); + try { + awaitMySqlRowLockCompetition(); + } finally { + allowFirstCommit.countDown(); + } return List.of( first.get(20, TimeUnit.SECONDS), second.get(20, TimeUnit.SECONDS) ); } - private ClaimResult claimAfterBarrier( + private ClaimResult claimAndHold( List claims, - CountDownLatch ready, - CountDownLatch start - ) throws InterruptedException { - ready.countDown(); - if (!start.await(10, TimeUnit.SECONDS)) { - throw new IllegalStateException("concurrent binding start timed out"); + CountDownLatch firstClaimFlushed, + CountDownLatch allowFirstCommit + ) { + try { + transactionTemplate.executeWithoutResult(status -> { + mediaPort.reconcileBindings(claims, List.of()); + imageAssetRepository.flush(); + firstClaimFlushed.countDown(); + await(allowFirstCommit, "첫 claim transaction commit"); + }); + return ClaimResult.success(); + } catch (BusinessException exception) { + return ClaimResult.failure((MediaErrorCode) exception.getErrorCode()); } + } + + private ClaimResult claimOnce(List claims) { try { transactionTemplate.executeWithoutResult(status -> mediaPort.reconcileBindings(claims, List.of())); @@ -245,6 +264,36 @@ private ClaimResult claimAfterBarrier( } } + private void awaitMySqlRowLockCompetition() throws InterruptedException, SQLException { + try (Connection connection = DriverManager.getConnection( + MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); + Statement statement = connection.createStatement()) { + long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); + while (System.nanoTime() < deadline) { + try (ResultSet resultSet = statement.executeQuery( + "SELECT COUNT(*) FROM performance_schema.data_lock_waits")) { + resultSet.next(); + if (resultSet.getInt(1) > 0) { + return; + } + } + Thread.sleep(50); + } + } + throw new AssertionError("MySQL asset row lock 경쟁이 제한 시간 안에 관찰되지 않았습니다"); + } + + private void await(CountDownLatch latch, String description) { + try { + if (!latch.await(10, TimeUnit.SECONDS)) { + throw new IllegalStateException(description + " 대기 시간이 초과되었습니다"); + } + } catch (InterruptedException exception) { + Thread.currentThread().interrupt(); + throw new IllegalStateException(description + " 대기가 중단되었습니다", exception); + } + } + private ImageAsset readyAsset(MediaPurpose purpose, boolean bound) { UUID assetId = UUID.randomUUID(); ImageAsset asset = ImageAsset.createDirectUpload( diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java index c49e43a9..6cc17e4c 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java @@ -1,6 +1,7 @@ package org.sopt.hashi.restaurant.service; import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.when; import java.math.BigDecimal; import java.sql.Connection; @@ -8,17 +9,31 @@ import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; +import java.time.LocalDateTime; import java.util.List; +import java.util.UUID; import java.util.concurrent.CountDownLatch; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.Future; import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; import org.junit.jupiter.api.AfterEach; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; +import org.sopt.hashi.auth.ActorType; +import org.sopt.hashi.auth.CurrentActor; +import org.sopt.hashi.auth.CurrentActorProvider; +import org.sopt.hashi.media.domain.ImageAsset; +import org.sopt.hashi.media.domain.ImageAssetRepository; +import org.sopt.hashi.media.domain.ImageBindingStatus; +import org.sopt.hashi.media.domain.ImageFormat; +import org.sopt.hashi.media.domain.ImageRole; +import org.sopt.hashi.media.domain.MediaOwnerType; +import org.sopt.hashi.media.domain.MediaPurpose; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.ImageCommand; +import org.sopt.hashi.restaurant.AdminRestaurantInfo; import org.sopt.hashi.restaurant.domain.PriceCurrency; import org.sopt.hashi.restaurant.domain.Restaurant; import org.sopt.hashi.restaurant.domain.RestaurantGenre; @@ -28,6 +43,7 @@ import org.springframework.boot.test.context.SpringBootTest; import org.springframework.boot.testcontainers.service.connection.ServiceConnection; import org.springframework.test.annotation.DirtiesContext; +import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.transaction.support.TransactionTemplate; import org.testcontainers.containers.MySQLContainer; import org.testcontainers.junit.jupiter.Container; @@ -44,6 +60,11 @@ @DirtiesContext(classMode = DirtiesContext.ClassMode.AFTER_CLASS) class RestaurantWriteConcurrencyIntegrationTest { + private static final String SPEC_DIGEST = + "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + private static final String SOURCE_CHECKSUM = + "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; + @Container @ServiceConnection private static final MySQLContainer MYSQL = new MySQLContainer<>("mysql:8.4") @@ -57,13 +78,21 @@ class RestaurantWriteConcurrencyIntegrationTest { @Autowired private RestaurantRepository restaurantRepository; + @Autowired + private ImageAssetRepository imageAssetRepository; + @Autowired private TransactionTemplate transactionTemplate; + @MockitoBean + private CurrentActorProvider currentActorProvider; + private ExecutorService executor; @BeforeEach void setUp() { + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 1L)); executor = Executors.newFixedThreadPool(2); } @@ -106,59 +135,84 @@ void tearDown() throws InterruptedException { } @Test - void 같은_식당의_동시_재정렬은_완전한_요청_하나로_직렬화된다() throws Exception { - Restaurant restaurant = createRestaurant("동시 재정렬 식당"); + void 같은_식당의_동시_collection_수정은_binding과_순서를_완전한_요청_단위로_직렬화한다() + throws Exception { + ImageAsset retiredAsset = readyRestaurantAsset(true); + ImageAsset firstNewAsset = readyRestaurantAsset(false); + ImageAsset secondNewAsset = readyRestaurantAsset(false); + transactionTemplate.executeWithoutResult(status -> imageAssetRepository.saveAll( + List.of(retiredAsset, firstNewAsset, secondNewAsset))); + + Restaurant restaurant = createRestaurant("동시 이미지 수정 식당"); restaurant.replaceImages(List.of( - RestaurantImage.createLegacy("restaurants/A.jpg", 1), - RestaurantImage.createLegacy("restaurants/B.jpg", 2), - RestaurantImage.createLegacy("restaurants/C.jpg", 3) + RestaurantImage.createLegacy("restaurants/retained-legacy.jpg", 1), + RestaurantImage.createAsset(retiredAsset.getPublicId(), 2) )); restaurantRepository.saveAndFlush(restaurant); Long restaurantId = restaurant.getId(); - Long firstId = restaurant.getImages().get(0).getId(); - Long secondId = restaurant.getImages().get(1).getId(); - Long thirdId = restaurant.getImages().get(2).getId(); - List firstOrder = List.of(thirdId, firstId, secondId); - List secondOrder = List.of(secondId, thirdId, firstId); - CountDownLatch ready = new CountDownLatch(2); - CountDownLatch start = new CountDownLatch(1); - - Future first = executor.submit(() -> reorderAfterBarrier( - restaurantId, firstOrder, ready, start)); - Future second = executor.submit(() -> reorderAfterBarrier( - restaurantId, secondOrder, ready, start)); - assertThat(ready.await(10, TimeUnit.SECONDS)).isTrue(); - start.countDown(); + Long retainedLegacyImageId = restaurant.getImages().getFirst().getId(); + Long retiredRestaurantImageId = restaurant.getImages().get(1).getId(); + CountDownLatch firstUpdateFlushed = new CountDownLatch(1); + CountDownLatch allowFirstCommit = new CountDownLatch(1); + AtomicReference firstNewAssociationId = new AtomicReference<>(); + + Future first = executor.submit(() -> transactionTemplate.executeWithoutResult(status -> { + AdminRestaurantInfo response = restaurantService.updateByAdmin( + restaurantId, + updateImagesCommand(List.of( + new ImageCommand(retainedLegacyImageId, null), + new ImageCommand(null, firstNewAsset.getPublicId()) + )) + ); + firstNewAssociationId.set(findAssociationId(response, firstNewAsset.getPublicId())); + firstUpdateFlushed.countDown(); + await(allowFirstCommit, "첫 collection 수정 transaction commit"); + })); + assertThat(firstUpdateFlushed.await(10, TimeUnit.SECONDS)).isTrue(); + + Future second = executor.submit(() -> restaurantService.updateByAdmin( + restaurantId, + updateImagesCommand(List.of( + new ImageCommand(firstNewAssociationId.get(), null), + new ImageCommand(null, secondNewAsset.getPublicId()) + )) + )); + try { + awaitMySqlRowLockCompetition(); + } finally { + allowFirstCommit.countDown(); + } first.get(20, TimeUnit.SECONDS); - second.get(20, TimeUnit.SECONDS); + AdminRestaurantInfo secondResponse = second.get(20, TimeUnit.SECONDS); + Long secondNewAssociationId = findAssociationId( + secondResponse, secondNewAsset.getPublicId()); Restaurant reloaded = restaurantRepository .findActiveByIdWithImages(restaurantId) .orElseThrow(); - List finalOrder = reloaded.getImages().stream() - .map(RestaurantImage::getId) - .toList(); - assertThat(List.of(firstOrder, secondOrder)).contains(finalOrder); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getId) + .containsExactly(firstNewAssociationId.get(), secondNewAssociationId); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getImageAssetId) + .containsExactly(firstNewAsset.getPublicId(), secondNewAsset.getPublicId()); assertThat(reloaded.getImages()) .extracting(RestaurantImage::getDisplayOrder) - .containsExactly(1, 2, 3); - } - - private void reorderAfterBarrier( - Long restaurantId, - List imageIds, - CountDownLatch ready, - CountDownLatch start - ) { - ready.countDown(); - await(start, "동시 재정렬 시작"); - restaurantService.updateByAdmin( - restaurantId, - updateImagesCommand(imageIds.stream() - .map(id -> new ImageCommand(id, null)) - .toList()) - ); + .containsExactly(1, 2); + assertThat(reloaded.getImages()) + .extracting(RestaurantImage::getId) + .doesNotContain(retainedLegacyImageId, retiredRestaurantImageId); + assertThat(imageAssetRepository.findAllByPublicIdIn(List.of( + retiredAsset.getPublicId(), + firstNewAsset.getPublicId(), + secondNewAsset.getPublicId() + ))).extracting(ImageAsset::getBindingStatus) + .containsExactlyInAnyOrder( + ImageBindingStatus.RETIRED, + ImageBindingStatus.BOUND, + ImageBindingStatus.BOUND + ); } private void awaitMySqlRowLockCompetition() throws InterruptedException, SQLException { @@ -205,6 +259,75 @@ private AdminRestaurantCommand updateImagesCommand(List images) { ); } + private Long findAssociationId(AdminRestaurantInfo response, UUID assetId) { + return response.heroImages().stream() + .filter(image -> image.image() != null && assetId.equals(image.image().assetId())) + .map(image -> image.restaurantImageId()) + .findFirst() + .orElseThrow(() -> new AssertionError("응답에서 asset association을 찾을 수 없습니다")); + } + + private ImageAsset readyRestaurantAsset(boolean bound) { + UUID assetId = UUID.randomUUID(); + ImageAsset asset = ImageAsset.createDirectUpload( + assetId, + MediaPurpose.RESTAURANT, + MediaOwnerType.ADMIN, + 1L, + "media/originals/%s/original".formatted(assetId), + "image/jpeg", + 1024L, + LocalDateTime.now().plusMinutes(5) + ); + UUID jobId = UUID.randomUUID(); + asset.beginInitialProcessing( + "version-1", "\"etag-1\"", 1, SPEC_DIGEST, jobId, LocalDateTime.now()); + addSquareRenditions(asset, jobId, ImageRole.RESTAURANT_THUMBNAIL, 96, 192, 288); + addSquareRenditions(asset, jobId, ImageRole.RESTAURANT_CARD, 135, 270, 405); + addRendition(asset, jobId, ImageRole.RESTAURANT_HERO, 430, 256); + addRendition(asset, jobId, ImageRole.RESTAURANT_HERO, 860, 512); + addRendition(asset, jobId, ImageRole.RESTAURANT_HERO, 1290, 769); + asset.completeCurrentProcessing( + jobId, 1, SPEC_DIGEST, "image/jpeg", 1024L, 3024, 4032, + SOURCE_CHECKSUM); + if (bound) { + asset.bind(); + } + return asset; + } + + private void addSquareRenditions( + ImageAsset asset, + UUID jobId, + ImageRole role, + int... widths + ) { + for (int width : widths) { + addRendition(asset, jobId, role, width, width); + } + } + + private void addRendition( + ImageAsset asset, + UUID jobId, + ImageRole role, + int width, + int height + ) { + asset.addRendition( + jobId, + 1, + SPEC_DIGEST, + role, + ImageFormat.WEBP, + width, + height, + 100L, + "media/renditions/%s/v1/%s/%d.webp".formatted( + asset.getPublicId(), role.name().toLowerCase(), width) + ); + } + private Restaurant createRestaurant(String name) { return Restaurant.create( name, From fb74e9a4686504e9f3a8f1b6e62fd400341fb6e9 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 3 Sep 2026 20:21:09 +0900 Subject: [PATCH 13/26] =?UTF-8?q?test(media):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EA=B7=9C=EA=B2=A9=20fixture=20=EC=B5=9C=EC=8B=A0?= =?UTF-8?q?=ED=99=94=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/sopt/hashi/media/service/MediaPortImplTest.java | 2 +- .../org/sopt/hashi/media/service/MediaPortIntegrationTest.java | 2 +- .../service/RestaurantMediaTransactionIntegrationTest.java | 2 +- .../service/RestaurantWriteConcurrencyIntegrationTest.java | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java index c04f4c69..eb53853c 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -49,7 +49,7 @@ class MediaPortImplTest { private static final String SPEC_DIGEST = - "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + "1b5759a9285732133699114e21101b3b9b43b5cd8e208bf1246d059f4293634f"; @Mock private ImageAssetRepository imageAssetRepository; diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java index 0537758f..e9303e9f 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -66,7 +66,7 @@ class MediaPortIntegrationTest { private static final String SPEC_DIGEST = - "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + "1b5759a9285732133699114e21101b3b9b43b5cd8e208bf1246d059f4293634f"; private static final String SOURCE_CHECKSUM = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; diff --git a/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java index aa0d7191..431c0f1e 100644 --- a/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/RestaurantMediaTransactionIntegrationTest.java @@ -52,7 +52,7 @@ class RestaurantMediaTransactionIntegrationTest { private static final String SPEC_DIGEST = - "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + "1b5759a9285732133699114e21101b3b9b43b5cd8e208bf1246d059f4293634f"; private static final String SOURCE_CHECKSUM = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java index 6cc17e4c..30fe1a16 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantWriteConcurrencyIntegrationTest.java @@ -61,7 +61,7 @@ class RestaurantWriteConcurrencyIntegrationTest { private static final String SPEC_DIGEST = - "91ac56d691c5af9e43061b0a2cc43763a4d1825244135d120057c3305a1bbe32"; + "1b5759a9285732133699114e21101b3b9b43b5cd8e208bf1246d059f4293634f"; private static final String SOURCE_CHECKSUM = "47DEQpj8HBSa+/TImW+5JCeuQeRkm5NMpJWZG3hSuFU="; From 8de6b5fd9280cf7f86f8dfe0908ffe7de84ec318 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 3 Sep 2026 20:24:56 +0900 Subject: [PATCH 14/26] =?UTF-8?q?fix(media):=20=EC=8B=A4=ED=8C=A8=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=83=81=ED=83=9C=20=ED=91=9C?= =?UTF-8?q?=EC=8B=9C=20=EC=9C=A0=EC=A7=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hashi/media/service/MediaPortImpl.java | 11 +++-- .../media/service/MediaPortImplTest.java | 48 +++++++++++++++++++ 2 files changed, 56 insertions(+), 3 deletions(-) diff --git a/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java index 5e9db779..38217a14 100644 --- a/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java +++ b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java @@ -155,12 +155,17 @@ private java.util.Optional project( List renditionProjections, MediaImageRequest request ) { - if (asset == null - || asset.getBindingStatus() != ImageBindingStatus.BOUND - || asset.getCleanupStatus() == MediaCleanupStatus.PURGING) { + if (asset == null || asset.getBindingStatus() != ImageBindingStatus.BOUND) { return java.util.Optional.empty(); } ImageProcessingStatus processingStatus = asset.getProcessingStatus(); + boolean visibleFailureTombstone = processingStatus == ImageProcessingStatus.FAILED + && (asset.getCleanupStatus() == MediaCleanupStatus.PURGING + || asset.getCleanupStatus() == MediaCleanupStatus.PURGED); + if (asset.getCleanupStatus() != MediaCleanupStatus.ACTIVE + && !visibleFailureTombstone) { + return java.util.Optional.empty(); + } if (processingStatus != ImageProcessingStatus.PROCESSING && processingStatus != ImageProcessingStatus.READY && processingStatus != ImageProcessingStatus.FAILED) { diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java index eb53853c..e8f0df56 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -256,6 +256,36 @@ void setUp() { verify(fileStorage, never()).resolveFileUrl(anyString()); } + @Test + void BOUND_FAILED는_object_정리_중과_완료_후에도_source_없는_FAILED로_반환한다() { + UUID activeId = UUID.randomUUID(); + UUID purgingId = UUID.randomUUID(); + UUID purgedId = UUID.randomUUID(); + List requests = List.of( + new MediaImageRequest(activeId, MediaImageRole.REVIEW_DETAIL), + new MediaImageRequest(purgingId, MediaImageRole.REVIEW_DETAIL), + new MediaImageRequest(purgedId, MediaImageRole.REVIEW_DETAIL) + ); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of( + failedProjection(activeId, MediaCleanupStatus.ACTIVE), + failedProjection(purgingId, MediaCleanupStatus.PURGING), + failedProjection(purgedId, MediaCleanupStatus.PURGED) + )); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())).thenReturn(List.of()); + + Map result = mediaPort.findImages(requests); + + assertThat(result).hasSize(3); + requests.forEach(request -> assertThat(result.get(request)).satisfies(image -> { + assertThat(image.status()).isEqualTo(MediaImageStatus.FAILED); + assertThat(image.defaultSource()).isNull(); + assertThat(image.sourceSets()).isEmpty(); + })); + verify(fileStorage, never()).resolveFileUrl(anyString()); + } + @Test void spec_digest가_다르거나_asset이_UNBOUND이면_결과에서_제외한다() { UUID mismatchId = UUID.randomUUID(); @@ -336,6 +366,24 @@ private AssetImageProjection projection( targetSpecVersion, targetSpecDigest, lastFailureSpecVersion); } + private AssetImageProjection failedProjection( + UUID publicId, + MediaCleanupStatus cleanupStatus + ) { + return projection( + publicId, + MediaPurpose.REVIEW, + ImageProcessingStatus.FAILED, + ImageBindingStatus.BOUND, + cleanupStatus, + null, + null, + null, + null, + 1 + ); + } + private RenditionImageProjection rendition( UUID assetId, ImageRole role, From 8062e76c68e2e10a68703ba236eaa37807e33349 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 3 Sep 2026 20:30:36 +0900 Subject: [PATCH 15/26] =?UTF-8?q?fix(restaurant):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=B0=EA=B2=B0=20migration=20=EB=B3=B5=EA=B5=AC?= =?UTF-8?q?=20=EA=B2=BD=EA=B3=84=20=EB=B6=84=EB=A6=AC=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- ...link_restaurant_images_to_media_assets.sql | 8 --- ...__link_restaurant_menu_to_media_assets.sql | 7 ++ .../hashi/migration/FlywayMigrationTest.java | 68 +++++++++++++++++-- 3 files changed, 69 insertions(+), 14 deletions(-) create mode 100644 src/main/resources/db/migration/V19__link_restaurant_menu_to_media_assets.sql diff --git a/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql b/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql index ffa38c8d..8f2b4f0d 100644 --- a/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql +++ b/src/main/resources/db/migration/V18__link_restaurant_images_to_media_assets.sql @@ -26,11 +26,3 @@ ALTER TABLE restaurant_image OR image_asset_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' ), ADD UNIQUE INDEX uq_restaurant_image_asset_id (image_asset_id); - -ALTER TABLE restaurant_menu - ADD COLUMN image_asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER image_key, - ADD CONSTRAINT ck_restaurant_menu_asset_id CHECK ( - image_asset_id IS NULL - OR image_asset_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' - ), - ADD UNIQUE INDEX uq_restaurant_menu_asset_id (image_asset_id); diff --git a/src/main/resources/db/migration/V19__link_restaurant_menu_to_media_assets.sql b/src/main/resources/db/migration/V19__link_restaurant_menu_to_media_assets.sql new file mode 100644 index 00000000..c01e6bc9 --- /dev/null +++ b/src/main/resources/db/migration/V19__link_restaurant_menu_to_media_assets.sql @@ -0,0 +1,7 @@ +ALTER TABLE restaurant_menu + ADD COLUMN image_asset_id CHAR(36) CHARACTER SET ascii COLLATE ascii_bin NULL AFTER image_key, + ADD CONSTRAINT ck_restaurant_menu_asset_id CHECK ( + image_asset_id IS NULL + OR image_asset_id REGEXP '^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$' + ), + ADD UNIQUE INDEX uq_restaurant_menu_asset_id (image_asset_id); diff --git a/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java b/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java index 2fdf9b31..de514d54 100644 --- a/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java +++ b/src/test/java/org/sopt/hashi/migration/FlywayMigrationTest.java @@ -54,9 +54,41 @@ class FlywayMigrationTest { assertThatThrownBy(() -> flyway().migrate()) .isInstanceOf(FlywayException.class); - assertThat(columnExists("target_processing_started_at")).isFalse(); - assertThat(columnExists("last_recovery_requested_at")).isFalse(); - assertThat(columnExists("processing_recovery_attempts")).isFalse(); + assertThat(columnExists("image_asset", "target_processing_started_at")).isFalse(); + assertThat(columnExists("image_asset", "last_recovery_requested_at")).isFalse(); + assertThat(columnExists("image_asset", "processing_recovery_attempts")).isFalse(); + } + + @Test + void V18과_V19는_테이블별_복구_경계를_분리한다() throws SQLException { + Flyway throughV18 = Flyway.configure() + .dataSource(MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()) + .locations("classpath:db/migration") + .target(MigrationVersion.fromVersion("18")) + .validateOnMigrate(true) + .baselineOnMigrate(false) + .cleanDisabled(false) + .load(); + throughV18.clean(); + throughV18.migrate(); + + assertThat(columnExists("restaurant_image", "image_asset_id")).isTrue(); + assertThat(columnExists("restaurant_menu", "image_asset_id")).isFalse(); + assertThat(migrationSucceeded("18")).isTrue(); + + execute("ALTER TABLE restaurant_menu ADD COLUMN image_asset_id VARCHAR(10) NULL"); + Flyway remaining = flyway(); + assertThatThrownBy(remaining::migrate).isInstanceOf(FlywayException.class); + + assertThat(columnExists("restaurant_image", "image_asset_id")).isTrue(); + assertThat(migrationSucceeded("18")).isTrue(); + + execute("ALTER TABLE restaurant_menu DROP COLUMN image_asset_id"); + remaining.repair(); + remaining.migrate(); + + assertThat(columnExists("restaurant_menu", "image_asset_id")).isTrue(); + assertThat(migrationSucceeded("19")).isTrue(); } private Flyway flyway() { @@ -104,16 +136,17 @@ INSERT INTO image_asset ( } } - private boolean columnExists(String columnName) throws SQLException { + private boolean columnExists(String tableName, String columnName) throws SQLException { try (Connection connection = connection(); var statement = connection.prepareStatement(""" SELECT COUNT(*) FROM information_schema.columns WHERE table_schema = DATABASE() - AND table_name = 'image_asset' + AND table_name = ? AND column_name = ? """)) { - statement.setString(1, columnName); + statement.setString(1, tableName); + statement.setString(2, columnName); try (ResultSet result = statement.executeQuery()) { result.next(); return result.getInt(1) > 0; @@ -121,6 +154,29 @@ SELECT COUNT(*) } } + private boolean migrationSucceeded(String version) throws SQLException { + try (Connection connection = connection(); + var statement = connection.prepareStatement(""" + SELECT COUNT(*) + FROM flyway_schema_history + WHERE version = ? + AND success = TRUE + """)) { + statement.setString(1, version); + try (ResultSet result = statement.executeQuery()) { + result.next(); + return result.getInt(1) == 1; + } + } + } + + private void execute(String sql) throws SQLException { + try (Connection connection = connection(); + var statement = connection.createStatement()) { + statement.execute(sql); + } + } + private Connection connection() throws SQLException { return DriverManager.getConnection( MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); From 49a417d1f46e70e3cb0f96e3f27ba391c4057a16 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Thu, 3 Sep 2026 20:38:26 +0900 Subject: [PATCH 16/26] =?UTF-8?q?test(media):=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=83=81=ED=83=9C=EB=B3=84=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20?= =?UTF-8?q?=EB=85=B8=EC=B6=9C=20=EA=B3=84=EC=95=BD=20=EA=B2=80=EC=A6=9D=20?= =?UTF-8?q?(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/service/MediaPortImplTest.java | 63 +++++++++++++++++++ 1 file changed, 63 insertions(+) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java index e8f0df56..c7003123 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -286,6 +286,33 @@ void setUp() { verify(fileStorage, never()).resolveFileUrl(anyString()); } + @Test + void READY와_PROCESSING은_object_정리_중이거나_완료되면_반환하지_않는다() { + UUID readyPurgingId = UUID.randomUUID(); + UUID readyPurgedId = UUID.randomUUID(); + UUID processingPurgingId = UUID.randomUUID(); + UUID processingPurgedId = UUID.randomUUID(); + List requests = List.of( + new MediaImageRequest(readyPurgingId, MediaImageRole.REVIEW_DETAIL), + new MediaImageRequest(readyPurgedId, MediaImageRole.REVIEW_DETAIL), + new MediaImageRequest(processingPurgingId, MediaImageRole.REVIEW_DETAIL), + new MediaImageRequest(processingPurgedId, MediaImageRole.REVIEW_DETAIL) + ); + when(imageAssetRepository.findImageProjectionsByPublicIdIn(anyCollection())) + .thenReturn(List.of( + readyProjection(readyPurgingId, MediaCleanupStatus.PURGING), + readyProjection(readyPurgedId, MediaCleanupStatus.PURGED), + processingProjection(processingPurgingId, MediaCleanupStatus.PURGING), + processingProjection(processingPurgedId, MediaCleanupStatus.PURGED) + )); + when(imageRenditionRepository.findActiveImageProjections( + anyCollection(), anyCollection())).thenReturn(List.of()); + + assertThat(mediaPort.findImages(requests)).isEmpty(); + + verify(fileStorage, never()).resolveFileUrl(anyString()); + } + @Test void spec_digest가_다르거나_asset이_UNBOUND이면_결과에서_제외한다() { UUID mismatchId = UUID.randomUUID(); @@ -384,6 +411,42 @@ private AssetImageProjection failedProjection( ); } + private AssetImageProjection readyProjection( + UUID publicId, + MediaCleanupStatus cleanupStatus + ) { + return projection( + publicId, + MediaPurpose.REVIEW, + ImageProcessingStatus.READY, + ImageBindingStatus.BOUND, + cleanupStatus, + 1, + SPEC_DIGEST, + null, + null, + null + ); + } + + private AssetImageProjection processingProjection( + UUID publicId, + MediaCleanupStatus cleanupStatus + ) { + return projection( + publicId, + MediaPurpose.REVIEW, + ImageProcessingStatus.PROCESSING, + ImageBindingStatus.BOUND, + cleanupStatus, + null, + null, + 1, + SPEC_DIGEST, + null + ); + } + private RenditionImageProjection rendition( UUID assetId, ImageRole role, From e1285f1118d9463d7d66510ecae704dde2b74679 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 01:51:59 +0900 Subject: [PATCH 17/26] =?UTF-8?q?test(media):=20=EC=A0=95=EB=A6=AC=20?= =?UTF-8?q?=EC=A4=91=20source=20=EB=85=B8=EC=B6=9C=20=ED=9A=8C=EA=B7=80=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hashi/media/service/MediaPortImplTest.java | 15 ++++++++++++++- 1 file changed, 14 insertions(+), 1 deletion(-) diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java index c7003123..6d583099 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -306,7 +306,20 @@ void setUp() { processingProjection(processingPurgedId, MediaCleanupStatus.PURGED) )); when(imageRenditionRepository.findActiveImageProjections( - anyCollection(), anyCollection())).thenReturn(List.of()); + anyCollection(), anyCollection())).thenReturn(List.of( + rendition( + readyPurgingId, + ImageRole.REVIEW_DETAIL, + 860, + 860 + ), + rendition( + readyPurgedId, + ImageRole.REVIEW_DETAIL, + 860, + 860 + ) + )); assertThat(mediaPort.findImages(requests)).isEmpty(); From e7a3cffd586ce2996002e619ccfb40efb989ebaf Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 23:41:11 +0900 Subject: [PATCH 18/26] =?UTF-8?q?ci(media):=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EB=B8=8C=EB=9E=9C=EC=B9=98=20=EB=8C=80=EC=83=81=20PR=20?= =?UTF-8?q?=EA=B2=80=EC=A6=9D=20=EC=B6=94=EA=B0=80=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 31 ++++++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 7667daf0..291dcb1a 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -2,9 +2,6 @@ on: pull_request: - branches: - - develop - - main permissions: contents: read @@ -64,6 +61,7 @@ jobs: name: Build and Test runs-on: ubuntu-latest needs: validate-convention + timeout-minutes: 30 steps: - name: Checkout source code @@ -129,5 +127,32 @@ jobs: - name: Grant execute permission for Gradle run: chmod +x ./gradlew + - name: Require Docker for MySQL tests + run: docker info + - name: Build with Gradle run: ./gradlew clean build --no-daemon + + - name: Verify test execution + run: | + python3 - <<'PY' + from pathlib import Path + import xml.etree.ElementTree as ET + + report_root = Path("build/test-results/test") + reports = sorted(report_root.glob("TEST-*.xml")) + assert reports, "Test reports are missing" + assert (report_root / "TEST-org.sopt.hashi.ModularityTests.xml").is_file(), \ + "Module verification did not produce a test report" + + total = 0 + for report in reports: + suite = ET.parse(report).getroot() + tests = int(suite.attrib["tests"]) + assert tests > 0, f"{report.name}: no tests executed" + for outcome in ("failures", "errors", "skipped"): + assert int(suite.attrib[outcome]) == 0, f"{report.name}: {outcome} must be zero" + total += tests + + print(f"Test suites: {len(reports)}, tests: {total}, failures/errors/skips: 0") + PY From 8d1885406d0bdec4b8b9c0b4e76caee2dda83bda Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 4 Sep 2026 23:47:25 +0900 Subject: [PATCH 19/26] =?UTF-8?q?fix(media):=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=84=20=EC=97=B0=EA=B2=B0=20=EC=86=8C=EC=9C=A0?= =?UTF-8?q?=EA=B6=8C=20=EC=9A=B0=EC=84=A0=20=EA=B2=80=EC=A6=9D=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hashi/media/service/MediaPortImpl.java | 12 +++-- .../media/service/MediaPortImplTest.java | 45 +++++++++++++++++++ 2 files changed, 53 insertions(+), 4 deletions(-) diff --git a/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java index 38217a14..41dfc07d 100644 --- a/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java +++ b/src/main/java/org/sopt/hashi/media/service/MediaPortImpl.java @@ -105,8 +105,10 @@ public void reconcileBindings(Collection claims, Collection validateClaim( - assetsById.get(use.assetId()), use.purpose(), ownerType, actor.subjectId())); + normalizedClaims.forEach(use -> requireClaimOwner( + assetsById.get(use.assetId()), ownerType, actor.subjectId())); + normalizedClaims.forEach(use -> validateClaimState( + assetsById.get(use.assetId()), use.purpose())); normalizedRetires.forEach(use -> validateRetire(assetsById.get(use.assetId()), use.purpose())); normalizedClaims.forEach(use -> assetsById.get(use.assetId()).bind()); @@ -218,11 +220,13 @@ private java.util.Optional project( asset.getPublicId(), request.role(), status, defaultSource, sourceSets)); } - private void validateClaim(ImageAsset asset, MediaAssetPurpose expectedPurpose, - MediaOwnerType actorType, Long actorSubjectId) { + private void requireClaimOwner(ImageAsset asset, MediaOwnerType actorType, Long actorSubjectId) { if (!asset.isOwnedBy(actorType, actorSubjectId)) { throw new BusinessException(MediaErrorCode.ASSET_NOT_FOUND); } + } + + private void validateClaimState(ImageAsset asset, MediaAssetPurpose expectedPurpose) { if (asset.getPurpose() != MediaPurpose.valueOf(expectedPurpose.name()) || asset.getCleanupStatus() != MediaCleanupStatus.ACTIVE) { throw new BusinessException(MediaErrorCode.INVALID_STATE); diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java index 6d583099..ea42774f 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortImplTest.java @@ -17,6 +17,8 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.sopt.hashi.auth.ActorType; @@ -162,6 +164,49 @@ void setUp() { verify(invalid, never()).bind(); } + @ParameterizedTest + @EnumSource(value = ImageBindingStatus.class, names = {"UNBOUND", "BOUND"}) + void 전체_claim의_소유권을_앞선_asset_상태보다_먼저_확인한다(ImageBindingStatus bindingStatus) { + UUID bannerId = UUID.randomUUID(); + UUID foreignId = UUID.randomUUID(); + UUID missingId = UUID.randomUUID(); + ImageAsset banner = asset( + bannerId, MediaPurpose.MAGAZINE_BANNER, MediaOwnerType.ADMIN, 3L, + bindingStatus == ImageBindingStatus.UNBOUND + ? ImageProcessingStatus.PROCESSING : ImageProcessingStatus.READY, + bindingStatus, MediaCleanupStatus.ACTIVE); + ImageAsset foreign = asset( + foreignId, MediaPurpose.MAGAZINE_THUMBNAIL, MediaOwnerType.ADMIN, 7L, + ImageProcessingStatus.READY, ImageBindingStatus.UNBOUND, + MediaCleanupStatus.ACTIVE); + when(currentActorProvider.currentActor()) + .thenReturn(new CurrentActor(ActorType.ADMIN, 3L)); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.MAGAZINE_BANNER)) + .thenReturn(true); + when(purposeAccessPolicy.isAllowed(ActorType.ADMIN, MediaPurpose.MAGAZINE_THUMBNAIL)) + .thenReturn(true); + when(imageAssetRepository.findIdentitiesByPublicIdIn(anyCollection())) + .thenReturn(List.of(identity(1L, bannerId), identity(2L, foreignId))) + .thenReturn(List.of(identity(1L, bannerId))); + when(imageAssetRepository.findAllByIdInForUpdate(List.of(1L, 2L))) + .thenReturn(List.of(banner, foreign)); + + for (UUID deniedId : List.of(foreignId, missingId)) { + assertThatThrownBy(() -> mediaPort.reconcileBindings( + List.of( + new MediaAssetUse(bannerId, MediaAssetPurpose.MAGAZINE_BANNER), + new MediaAssetUse(deniedId, MediaAssetPurpose.MAGAZINE_THUMBNAIL) + ), List.of())) + .isInstanceOf(BusinessException.class) + .hasFieldOrPropertyWithValue("errorCode", MediaErrorCode.ASSET_NOT_FOUND); + } + + verify(banner, never()).bind(); + verify(foreign, never()).bind(); + verify(banner, never()).retire(); + verify(foreign, never()).retire(); + } + @Test void READY_projection은_defaultWidth와_모든_candidate를_반환한다() { UUID assetId = UUID.randomUUID(); From 1ae42f312e2f91dd745302191ca546e1c32434fa Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Sat, 5 Sep 2026 00:18:45 +0900 Subject: [PATCH 20/26] =?UTF-8?q?fix(media):=20=EC=9D=B4=EB=AF=B8=EC=A7=80?= =?UTF-8?q?=20=EC=9D=BC=EA=B4=84=20=EC=9E=91=EC=97=85=20=EC=9E=A0=EA=B8=88?= =?UTF-8?q?=20=EC=88=9C=EC=84=9C=20=ED=86=B5=EC=9D=BC=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../media/domain/ImageAssetRepository.java | 9 --- .../service/MediaAssetTransactionService.java | 6 +- .../service/MediaPortIntegrationTest.java | 74 ++++++++++++++++++- 3 files changed, 75 insertions(+), 14 deletions(-) diff --git a/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java b/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java index 33aa83e8..28925209 100644 --- a/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java +++ b/src/main/java/org/sopt/hashi/media/domain/ImageAssetRepository.java @@ -53,15 +53,6 @@ List findImageProjectionsByPublicIdIn( @Param("publicIds") Collection publicIds ); - @Lock(LockModeType.PESSIMISTIC_WRITE) - @Query(""" - select asset - from ImageAsset asset - where asset.publicId in :publicIds - order by asset.id asc - """) - List findAllByPublicIdInForUpdate(@Param("publicIds") Collection publicIds); - @Lock(LockModeType.PESSIMISTIC_WRITE) @Query(""" select asset diff --git a/src/main/java/org/sopt/hashi/media/service/MediaAssetTransactionService.java b/src/main/java/org/sopt/hashi/media/service/MediaAssetTransactionService.java index 11e94c30..0a1bb204 100644 --- a/src/main/java/org/sopt/hashi/media/service/MediaAssetTransactionService.java +++ b/src/main/java/org/sopt/hashi/media/service/MediaAssetTransactionService.java @@ -95,7 +95,11 @@ public List completeAssets( Map metadataByAssetId ) { Optional config = lockPipelineConfig(); - List lockedAssets = imageAssetRepository.findAllByPublicIdInForUpdate(assetIds); + List internalIds = imageAssetRepository.findIdentitiesByPublicIdIn(assetIds).stream() + .map(ImageAssetRepository.AssetIdentity::getId) + .sorted() + .toList(); + List lockedAssets = imageAssetRepository.findAllByIdInForUpdate(internalIds); Map assetsByPublicId = requireOwnedAssets(actor, assetIds, lockedAssets); LocalDateTime now = LocalDateTime.now(clock); diff --git a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java index e9303e9f..a4367eb2 100644 --- a/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java +++ b/src/test/java/org/sopt/hashi/media/service/MediaPortIntegrationTest.java @@ -7,6 +7,7 @@ import jakarta.persistence.EntityManagerFactory; import java.sql.Connection; import java.sql.DriverManager; +import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; import java.sql.Statement; @@ -80,6 +81,9 @@ class MediaPortIntegrationTest { @Autowired private MediaPort mediaPort; + @Autowired + private MediaAssetTransactionService transactionService; + @Autowired private ImageAssetRepository imageAssetRepository; @@ -193,6 +197,55 @@ void tearDown() throws InterruptedException { .containsOnly(ImageBindingStatus.BOUND); } + @Test + void 완료와_claim이_경쟁해도_UUID와_반대인_내부_ID_순서로_잠근다() throws Exception { + List assets = new ArrayList<>(); + for (int index = 0; index < 1_000; index++) { + UUID assetId = new UUID(0L, 1_000L - index); + assets.add(index < 10 + ? readyAsset(MediaPurpose.RESTAURANT, false, assetId) + : pendingAsset(MediaPurpose.RESTAURANT, assetId)); + } + transactionTemplate.executeWithoutResult(status -> imageAssetRepository.saveAll(assets)); + jdbcTemplate.execute("ANALYZE TABLE image_asset"); + List requestedAssets = assets.subList(0, 10); + List requestedIds = requestedAssets.stream().map(ImageAsset::getPublicId).toList(); + List claims = requestedIds.stream() + .map(assetId -> new MediaAssetUse(assetId, MediaAssetPurpose.RESTAURANT)) + .toList(); + + try (Connection blocker = DriverManager.getConnection( + MYSQL.getJdbcUrl(), MYSQL.getUsername(), MYSQL.getPassword()); + PreparedStatement statement = blocker.prepareStatement( + "SELECT id FROM image_asset WHERE id = ? FOR UPDATE")) { + blocker.setAutoCommit(false); + statement.setLong(1, requestedAssets.get(4).getId()); + try (ResultSet rows = statement.executeQuery()) { + assertThat(rows.next()).isTrue(); + } + + Future> completed = executor.submit(() -> + transactionService.completeAssets( + new CurrentActor(ActorType.ADMIN, 1L), requestedIds, Map.of())); + Future claimed; + try { + awaitMySqlRowLockCompetition(1); + claimed = executor.submit(() -> claimOnce(claims)); + awaitMySqlRowLockCompetition(2); + } finally { + blocker.commit(); + } + + assertThat(completed.get(20, TimeUnit.SECONDS)).hasSize(10); + assertThat(claimed.get(20, TimeUnit.SECONDS)).isEqualTo(ClaimResult.success()); + } + + assertThat(imageAssetRepository.findAllByPublicIdIn(requestedIds)) + .hasSize(10) + .extracting(ImageAsset::getBindingStatus) + .containsOnly(ImageBindingStatus.BOUND); + } + private long projectionQueryCount(int count) { List assets = new ArrayList<>(); for (int index = 0; index < count; index++) { @@ -265,15 +318,21 @@ private ClaimResult claimOnce(List claims) { } private void awaitMySqlRowLockCompetition() throws InterruptedException, SQLException { + awaitMySqlRowLockCompetition(1); + } + + private void awaitMySqlRowLockCompetition(int minimumWaitingTransactions) + throws InterruptedException, SQLException { try (Connection connection = DriverManager.getConnection( MYSQL.getJdbcUrl(), "root", MYSQL.getPassword()); Statement statement = connection.createStatement()) { long deadline = System.nanoTime() + TimeUnit.SECONDS.toNanos(10); while (System.nanoTime() < deadline) { try (ResultSet resultSet = statement.executeQuery( - "SELECT COUNT(*) FROM performance_schema.data_lock_waits")) { + "SELECT COUNT(DISTINCT REQUESTING_ENGINE_TRANSACTION_ID) " + + "FROM performance_schema.data_lock_waits")) { resultSet.next(); - if (resultSet.getInt(1) > 0) { + if (resultSet.getInt(1) >= minimumWaitingTransactions) { return; } } @@ -295,8 +354,11 @@ private void await(CountDownLatch latch, String description) { } private ImageAsset readyAsset(MediaPurpose purpose, boolean bound) { - UUID assetId = UUID.randomUUID(); - ImageAsset asset = ImageAsset.createDirectUpload( + return readyAsset(purpose, bound, UUID.randomUUID()); + } + + private ImageAsset pendingAsset(MediaPurpose purpose, UUID assetId) { + return ImageAsset.createDirectUpload( assetId, purpose, MediaOwnerType.ADMIN, @@ -306,6 +368,10 @@ private ImageAsset readyAsset(MediaPurpose purpose, boolean bound) { 1024L, LocalDateTime.now().plusMinutes(5) ); + } + + private ImageAsset readyAsset(MediaPurpose purpose, boolean bound, UUID assetId) { + ImageAsset asset = pendingAsset(purpose, assetId); UUID jobId = UUID.randomUUID(); asset.beginInitialProcessing( "version-1", "\"etag-1\"", 1, SPEC_DIGEST, jobId, LocalDateTime.now()); From d6f5e50c695b652bcda463f3b09a4c72d8671f15 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:02 +0900 Subject: [PATCH 21/26] =?UTF-8?q?refactor(media):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20URL=20=EC=84=A0=ED=83=9D=20=EA=B7=9C=EC=B9=99=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/conventions/architecture.md | 3 + .../sopt/hashi/media/MediaImageSelection.java | 19 ++++++ .../hashi/media/MediaImageSelectionTest.java | 61 +++++++++++++++++++ 3 files changed, 83 insertions(+) create mode 100644 src/main/java/org/sopt/hashi/media/MediaImageSelection.java create mode 100644 src/test/java/org/sopt/hashi/media/MediaImageSelectionTest.java diff --git a/docs/conventions/architecture.md b/docs/conventions/architecture.md index 157246b2..e258c031 100644 --- a/docs/conventions/architecture.md +++ b/docs/conventions/architecture.md @@ -190,6 +190,9 @@ - **MUST**: 신규 최적화 이미지는 상태를 가진 지원 도메인 `media`가 관리한다. `ImageAsset`은 Aggregate Root, `ImageRendition`은 그 자식이다. - **MUST**: 콘텐츠 도메인은 asset 식별자 값만 보관하고 일반 요청 경로에서는 공개 `MediaPort`로 검증, claim, bulk 조회한다. §2-2 통합 Port 원칙의 명시적 migration-only 예외로 association을 소유한 도메인의 backfill runner만 `MediaBackfillPort`를 사용할 수 있으며 Controller와 일반 Service에서는 사용하지 않는다. media 테이블과 JPA 관계, 모듈 간 FK, DB join을 만들지 않는다. - **MUST**: 이미지의 콘텐츠 소속과 표시 순서는 기존 콘텐츠 Aggregate가 계속 소유한다. media는 콘텐츠 도메인을 되참조하지 않는다. +- **MAY**: 최종 응답 Service는 공개 값 타입 `MediaImageSelection.from(ImageReference, MediaImage)`으로 + 기존 URL 허용 여부와 READY URL 선택을 공유한다. 이 타입은 DB·외부 호출이 없는 순수 변환이며 + 추가 Service facade가 아니다. role·정렬·개수와 `MediaPort` bulk 조회는 호출 Service가 소유한다. - **MUST**: 원본 확인, 변환과 삭제 같은 S3 작업은 DB 트랜잭션 안에서 실행하지 않는다. 외부 변환 요청은 commit 이후 재시도 가능한 event publication 또는 outbox로 전달한다. - 상세 결정과 외부 계약은 [`ADR 0001`](../adr/0001-media-module-and-image-pipeline.md)과 [`Image Delivery Contract v1`](../media/image-delivery-contract-v1.md)을 따른다. diff --git a/src/main/java/org/sopt/hashi/media/MediaImageSelection.java b/src/main/java/org/sopt/hashi/media/MediaImageSelection.java new file mode 100644 index 00000000..c5126d77 --- /dev/null +++ b/src/main/java/org/sopt/hashi/media/MediaImageSelection.java @@ -0,0 +1,19 @@ +package org.sopt.hashi.media; + +/** 조회된 이미지와 전환기 참조 값에서 화면에 전달할 값을 선택한다. DB 조회는 하지 않는다. */ +public record MediaImageSelection(String url, MediaImage image, String legacyUrl) { + + public static MediaImageSelection from(ImageReference reference, MediaImage image) { + if (reference == null) { + return new MediaImageSelection(null, null, null); + } + if (reference.assetId() == null) { + return new MediaImageSelection(reference.legacyUrl(), null, reference.legacyUrl()); + } + if (image == null || !reference.assetId().equals(image.assetId())) { + return new MediaImageSelection(null, null, null); + } + String url = image.status() == MediaImageStatus.READY ? image.defaultSource().url() : null; + return new MediaImageSelection(url, image, null); + } +} diff --git a/src/test/java/org/sopt/hashi/media/MediaImageSelectionTest.java b/src/test/java/org/sopt/hashi/media/MediaImageSelectionTest.java new file mode 100644 index 00000000..47d9d425 --- /dev/null +++ b/src/test/java/org/sopt/hashi/media/MediaImageSelectionTest.java @@ -0,0 +1,61 @@ +package org.sopt.hashi.media; + +import static org.assertj.core.api.Assertions.assertThat; + +import java.util.List; +import java.util.UUID; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.EnumSource; + +class MediaImageSelectionTest { + + @Test + void 참조가_없으면_이미지를_노출하지_않는다() { + assertThat(MediaImageSelection.from(null, readyImage(UUID.randomUUID()))) + .isEqualTo(new MediaImageSelection(null, null, null)); + } + + @Test + void asset이_없는_기존_사진만_기존_URL을_사용한다() { + assertThat(MediaImageSelection.from(ImageReference.legacy("https://cdn/old.jpg"), + readyImage(UUID.randomUUID()))) + .isEqualTo(new MediaImageSelection("https://cdn/old.jpg", null, "https://cdn/old.jpg")); + } + + @Test + void READY는_새_URL을_사용하고_기존_URL을_노출하지_않는다() { + UUID id = UUID.randomUUID(); + MediaImage image = readyImage(id); + assertThat(MediaImageSelection.from(new ImageReference(id, "https://cdn/old.jpg"), image)) + .isEqualTo(new MediaImageSelection("https://cdn/new.webp", image, null)); + } + + @ParameterizedTest + @EnumSource(value = MediaImageStatus.class, names = {"PROCESSING", "FAILED"}) + void 처리중이나_실패한_asset은_기존_URL로_우회하지_않는다(MediaImageStatus status) { + UUID id = UUID.randomUUID(); + MediaImage image = new MediaImage(id, MediaImageRole.RESTAURANT_CARD, status, null, List.of()); + assertThat(MediaImageSelection.from(new ImageReference(id, "https://cdn/old.jpg"), image)) + .isEqualTo(new MediaImageSelection(null, image, null)); + } + + @Test + void 조회_결과가_없어도_기존_URL로_우회하지_않는다() { + assertThat(MediaImageSelection.from(new ImageReference(UUID.randomUUID(), "https://cdn/old.jpg"), null)) + .isEqualTo(new MediaImageSelection(null, null, null)); + } + + @Test + void 다른_asset의_이미지를_노출하지_않는다() { + assertThat(MediaImageSelection.from(ImageReference.asset(UUID.randomUUID()), readyImage(UUID.randomUUID()))) + .isEqualTo(new MediaImageSelection(null, null, null)); + } + + private MediaImage readyImage(UUID id) { + return new MediaImage(id, MediaImageRole.RESTAURANT_CARD, MediaImageStatus.READY, + new MediaImage.Source("https://cdn/new.webp", 270, 270, "image/webp"), + List.of(new MediaImage.SourceSet("image/webp", List.of( + new MediaImage.Candidate("https://cdn/new.webp", 270, 270))))); + } +} From dd53a3a6ec6379aa9eacf2389ef23c77dc9cf699 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:03 +0900 Subject: [PATCH 22/26] =?UTF-8?q?fix(restaurant):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EC=97=B0=EA=B2=B0=EC=9D=84=20=EB=B9=84=EC=9A=B0?= =?UTF-8?q?=EB=8A=94=20=EC=98=9B=20=EC=88=98=EC=A0=95=20=EB=A9=94=EC=84=9C?= =?UTF-8?q?=EB=93=9C=20=EC=A0=9C=EA=B1=B0=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../restaurant/domain/RestaurantMenu.java | 5 -- .../domain/RestaurantRepositoryTest.java | 51 ++++++++++++++++++- 2 files changed, 49 insertions(+), 7 deletions(-) diff --git a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java index dc69ebe6..4faa8820 100644 --- a/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java +++ b/src/main/java/org/sopt/hashi/restaurant/domain/RestaurantMenu.java @@ -89,11 +89,6 @@ public static RestaurantMenu createWithAsset(String name, String description, UU priceCurrency, priceAmount, main); } - public void update(String name, String description, String imageKey, - PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { - update(name, description, imageKey, null, priceCurrency, priceAmount, main); - } - public void update(String name, String description, String imageKey, UUID imageAssetId, PriceCurrency priceCurrency, BigDecimal priceAmount, boolean main) { this.name = name; diff --git a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantRepositoryTest.java b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantRepositoryTest.java index 0135be5e..52a42676 100644 --- a/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantRepositoryTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/domain/RestaurantRepositoryTest.java @@ -7,6 +7,7 @@ import java.time.LocalTime; import java.util.List; import java.util.Set; +import java.util.UUID; import org.junit.jupiter.api.Test; import org.springframework.data.domain.PageRequest; import org.springframework.beans.factory.annotation.Autowired; @@ -173,7 +174,10 @@ class RestaurantRepositoryTest { @Test void 메뉴_동기화는_기존_ID를_유지하고_누락된_메뉴를_삭제한다() { Restaurant restaurant = createRestaurant("메뉴 수정 식당"); - restaurant.addMenu(createMenu("유지 메뉴")); + UUID retainedAssetId = UUID.randomUUID(); + restaurant.addMenu(RestaurantMenu.createWithAsset( + "유지 메뉴", "메뉴 설명", retainedAssetId, PriceCurrency.JPY, + BigDecimal.valueOf(1_000), false)); restaurant.addMenu(createMenu("삭제 메뉴")); restaurantRepository.saveAndFlush(restaurant); Long restaurantId = restaurant.getId(); @@ -186,7 +190,7 @@ class RestaurantRepositoryTest { .filter(menu -> menu.getId().equals(retainedMenuId)) .findFirst() .orElseThrow(); - retainedMenu.update("수정 메뉴", "수정 설명", null, PriceCurrency.JPY, + retainedMenu.update("수정 메뉴", "수정 설명", null, retainedMenu.getImageAssetId(), PriceCurrency.JPY, BigDecimal.valueOf(1_500), true); managedRestaurant.removeMenusNotIn(Set.of(retainedMenuId)); managedRestaurant.addMenu(createMenu("신규 메뉴")); @@ -206,12 +210,55 @@ class RestaurantRepositoryTest { .satisfies(menu -> { assertThat(menu.getName()).isEqualTo("수정 메뉴"); assertThat(menu.getPriceAmount()).isEqualByComparingTo("1500"); + assertThat(menu.getImageAssetId()).isEqualTo(retainedAssetId); + assertThat(menu.getImageKey()).isNull(); }); assertThat(menus) .filteredOn(menu -> "신규 메뉴".equals(menu.getName())) .hasSize(1); } + @Test + void 메뉴_이미지를_교체하면_새_asset_ID를_저장하고_기존_key를_제거한다() { + Restaurant restaurant = createRestaurant("메뉴 이미지 교체 식당"); + RestaurantMenu menu = createMenu("기존 메뉴"); + restaurant.addMenu(menu); + restaurantRepository.saveAndFlush(restaurant); + UUID replacementAssetId = UUID.randomUUID(); + + menu.update("수정 메뉴", "수정 설명", null, replacementAssetId, PriceCurrency.JPY, + BigDecimal.valueOf(1_500), true); + restaurantRepository.flush(); + entityManager.clear(); + + RestaurantMenu result = restaurantRepository.findMenuByRestaurantIdAndMenuId( + restaurant.getId(), menu.getId()).orElseThrow(); + + assertThat(result.getImageAssetId()).isEqualTo(replacementAssetId); + assertThat(result.getImageKey()).isNull(); + } + + @Test + void 메뉴_이미지_ID를_명시적으로_비우면_연결을_제거한다() { + Restaurant restaurant = createRestaurant("메뉴 이미지 제거 식당"); + RestaurantMenu menu = RestaurantMenu.createWithAsset( + "기존 메뉴", "메뉴 설명", UUID.randomUUID(), PriceCurrency.JPY, + BigDecimal.valueOf(1_000), false); + restaurant.addMenu(menu); + restaurantRepository.saveAndFlush(restaurant); + + menu.update("수정 메뉴", "수정 설명", null, null, PriceCurrency.JPY, + BigDecimal.valueOf(1_500), true); + restaurantRepository.flush(); + entityManager.clear(); + + RestaurantMenu result = restaurantRepository.findMenuByRestaurantIdAndMenuId( + restaurant.getId(), menu.getId()).orElseThrow(); + + assertThat(result.getImageAssetId()).isNull(); + assertThat(result.getImageKey()).isNull(); + } + private Restaurant saveRestaurant(String name) { Restaurant restaurant = createRestaurant(name); entityManager.persistAndFlush(restaurant); From 06f821d25e2a81c6cb360b7bd78605e7c5d510ac Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:03 +0900 Subject: [PATCH 23/26] =?UTF-8?q?fix(restaurant):=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=ED=95=AD=EB=AA=A9=EC=97=90=20=EA=B8=B0=EC=A1=B4=20?= =?UTF-8?q?=EC=A3=BC=EC=86=8C=20=ED=95=A8=EA=BB=98=20=EC=A0=9C=EA=B3=B5=20?= =?UTF-8?q?(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- docs/media/image-delivery-contract-v1.md | 21 +++++- .../hashi/restaurant/RestaurantImageInfo.java | 3 +- .../restaurant/service/RestaurantService.java | 28 ++++--- .../service/RestaurantServiceTest.java | 73 ++++++++++++++++++- .../web/RestaurantControllerTest.java | 4 +- 5 files changed, 112 insertions(+), 17 deletions(-) diff --git a/docs/media/image-delivery-contract-v1.md b/docs/media/image-delivery-contract-v1.md index 2f213046..683870da 100644 --- a/docs/media/image-delivery-contract-v1.md +++ b/docs/media/image-delivery-contract-v1.md @@ -712,6 +712,25 @@ wrapper 안에 둔다. 식당 이미지는 다음 형태를 사용한다. 식당 association wrapper다. 리뷰의 `previewImages`와 상세 `images` 원소는 stable `reviewImageId`를 가진 리뷰 association wrapper다. +식당 wrapper는 `restaurantImageId`, `displayOrder`, `image`, `legacyUrl`을 제공한다. +`legacyUrl`은 asset ID가 없는 기존 사진에만 제공한다. asset이 있으면 READY 여부나 +조회 결과 누락과 관계없이 `legacyUrl`은 `null`이다. 신규 원본 주소는 제공하지 않는다. + +```json +[ + {"restaurantImageId": 101, "displayOrder": 1, "image": null, "legacyUrl": "https://cdn.example.com/legacy/a.jpg"}, + {"restaurantImageId": 102, "displayOrder": 2, "image": {"assetId": "550e8400-e29b-41d4-a716-446655440000", "role": "RESTAURANT_HERO", "status": "PROCESSING", "defaultSource": null, "sourceSets": []}, "legacyUrl": null} +] +``` + +- 새 클라이언트는 wrapper 목록의 순서를 그대로 사용하며, 다른 응답과 사진을 연결할 때는 + `restaurantImageId`를 사용한다. `imageUrls`의 같은 인덱스와 맞추지 않는다. +- `image.status=READY`이면 새 이미지, `image=null`이고 `legacyUrl`이 있으면 기존 주소를 사용한다. + PROCESSING은 placeholder, FAILED 또는 둘 다 없으면 DefaultImage를 표시한다. +- 기존 `imageUrls`는 표시 가능한 기존 주소와 READY 주소만 담으므로 새 목록과 길이가 다를 수 있다. +- 데이터 전환이 완료돼도 필드는 즉시 제거하지 않는다. 클라이언트의 기존 주소 사용 종료까지 + 확인한 뒤 별도 호환성 변경으로 제거한다. 원본 파일 보관·접근 차단과는 별개다. + 교차 모듈 Port의 전환기 이미지 값은 `ImageReference(assetId, legacyUrl)` 형태로 전달한다. 기존 key를 소유한 모듈이 현재 방식으로 계산한 `legacyUrl`을 제공하며, object key 자체는 다른 모듈에 공개하지 않는다. @@ -732,7 +751,7 @@ wrapper 안에 둔다. 식당 이미지는 다음 형태를 사용한다. | 저장 상태 | 기존 URL 필드 | 신규 이미지 필드 | | --- | --- | --- | -| legacy key만 있음 | 기존 CloudFront 원본 URL | 단일 이미지는 `null`, association collection은 stable ID와 순서를 가진 wrapper의 `image: null` | +| legacy key만 있음 | 기존 CloudFront 원본 URL | 단일 이미지는 `null`, 식당 wrapper는 ID·순서와 `image: null`, `legacyUrl` 제공 | | media asset READY | 해당 endpoint role의 `defaultSource.url` | READY 이미지 객체 | | 신규 media asset PROCESSING 또는 FAILED | scalar는 `null`, 배열은 READY 항목만 오름차순으로 포함 | 슬롯과 상태를 유지한 이미지 객체 | | 이미지가 없음 | `null` 또는 빈 배열 | `null` 또는 빈 배열 | diff --git a/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java b/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java index 4cecbe63..1a674ff7 100644 --- a/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java +++ b/src/main/java/org/sopt/hashi/restaurant/RestaurantImageInfo.java @@ -6,6 +6,7 @@ public record RestaurantImageInfo( Long restaurantImageId, int displayOrder, - MediaImage image + MediaImage image, + String legacyUrl ) { } diff --git a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java index d4a2d4db..acbfbbd9 100644 --- a/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java +++ b/src/main/java/org/sopt/hashi/restaurant/service/RestaurantService.java @@ -21,12 +21,13 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; import lombok.extern.slf4j.Slf4j; +import org.sopt.hashi.media.ImageReference; import org.sopt.hashi.media.MediaAssetPurpose; import org.sopt.hashi.media.MediaAssetUse; import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; -import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.restaurant.AdminRestaurantCommand; import org.sopt.hashi.restaurant.AdminRestaurantCommand.BusinessHourCommand; @@ -672,7 +673,8 @@ private RestaurantImageInfo toImageInfo(RestaurantImage image, ProjectedImage pr return null; } return new RestaurantImageInfo( - image.getId(), image.getDisplayOrder(), projectedImage.image()); + image.getId(), image.getDisplayOrder(), projectedImage.image(), + image.getImageAssetId() == null ? projectedImage.url() : null); } private ProjectedImage projectImage( @@ -681,10 +683,14 @@ private ProjectedImage projectImage( MediaProjection mediaProjection ) { if (image.getImageAssetId() == null) { - return new ProjectedImage(resolveLegacyUrl(image.getFileKey()), null); + MediaImageSelection selection = MediaImageSelection.from( + ImageReference.legacy(resolveLegacyUrl(image.getFileKey())), null); + return new ProjectedImage(selection.url(), selection.image()); } MediaImage mediaImage = mediaProjection.find(image.getImageAssetId(), role); - return new ProjectedImage(readyUrl(mediaImage), mediaImage); + MediaImageSelection selection = MediaImageSelection.from( + ImageReference.asset(image.getImageAssetId()), mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private ProjectedImage projectMenuImage( @@ -693,22 +699,20 @@ private ProjectedImage projectMenuImage( MediaProjection mediaProjection ) { if (menu.getImageAssetId() == null) { - return new ProjectedImage(resolveLegacyUrl(menu.getImageKey()), null); + MediaImageSelection selection = MediaImageSelection.from( + ImageReference.legacy(resolveLegacyUrl(menu.getImageKey())), null); + return new ProjectedImage(selection.url(), selection.image()); } MediaImage mediaImage = mediaProjection.find(menu.getImageAssetId(), role); - return new ProjectedImage(readyUrl(mediaImage), mediaImage); + MediaImageSelection selection = MediaImageSelection.from( + ImageReference.asset(menu.getImageAssetId()), mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private String resolveLegacyUrl(String fileKey) { return fileKey == null ? null : fileStorage.resolveFileUrl(fileKey); } - private String readyUrl(MediaImage mediaImage) { - return mediaImage != null && mediaImage.status() == MediaImageStatus.READY - ? mediaImage.defaultSource().url() - : null; - } - private BusinessHourResponse toBusinessHourResponse(RestaurantBusinessHour businessHour) { return new BusinessHourResponse( businessHour.getDayOfWeek().name(), diff --git a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java index bebdb489..70ec627f 100644 --- a/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/service/RestaurantServiceTest.java @@ -26,6 +26,8 @@ import java.util.stream.IntStream; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.junit.jupiter.params.ParameterizedTest; +import org.junit.jupiter.params.provider.ValueSource; import org.mockito.ArgumentCaptor; import org.mockito.ArgumentMatchers; import org.mockito.Mock; @@ -901,6 +903,7 @@ void getRestaurantSummary_returns_main_information() { ); assertThat(response.thumbnailImage().restaurantImageId()).isEqualTo(101L); assertThat(response.thumbnailImage().image()).isNull(); + assertThat(response.thumbnailImage().legacyUrl()).isEqualTo(response.thumbnailUrl()); assertThat(response.heroImages()) .extracting(image -> image.restaurantImageId(), image -> image.displayOrder()) .containsExactly( @@ -908,6 +911,8 @@ void getRestaurantSummary_returns_main_information() { org.assertj.core.groups.Tuple.tuple(102L, 2)); assertThat(response.heroImages()) .allSatisfy(image -> assertThat(image.image()).isNull()); + assertThat(response.heroImages()).extracting(image -> image.legacyUrl()) + .containsExactlyElementsOf(response.imageUrls()); assertThat(response.reservationFee()).isEqualTo(4_000L); verifyNoInteractions(mediaPort); } @@ -939,9 +944,13 @@ void getRestaurantSummary_returns_main_information() { assertThat(response.thumbnailUrl()).isEqualTo(thumbnail.defaultSource().url()); assertThat(response.thumbnailImage().restaurantImageId()).isEqualTo(101L); assertThat(response.thumbnailImage().image()).isEqualTo(thumbnail); + assertThat(response.thumbnailImage().legacyUrl()).isNull(); assertThat(response.imageUrls()).containsExactly(hero.defaultSource().url()); assertThat(response.heroImages()).singleElement() - .satisfies(image -> assertThat(image.image()).isEqualTo(hero)); + .satisfies(image -> { + assertThat(image.image()).isEqualTo(hero); + assertThat(image.legacyUrl()).isNull(); + }); verify(mediaPort).findImages(argThat(requests -> Set.copyOf(requests).equals(Set.of( new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_THUMBNAIL), new MediaImageRequest(assetId, MediaImageRole.RESTAURANT_HERO) @@ -984,6 +993,8 @@ void getRestaurantSummary_returns_main_information() { assertThat(response.thumbnailImage().image().status()) .isEqualTo(MediaImageStatus.PROCESSING); assertThat(response.imageUrls()).isEmpty(); + assertThat(response.thumbnailImage().legacyUrl()).isNull(); + assertThat(response.heroImages()).allSatisfy(image -> assertThat(image.legacyUrl()).isNull()); assertThat(response.heroImages()) .extracting(image -> image.restaurantImageId(), image -> image.image().status()) .containsExactly( @@ -1012,6 +1023,7 @@ void getRestaurantSummary_returns_main_information() { .satisfies(image -> { assertThat(image.restaurantImageId()).isEqualTo(101L); assertThat(image.image()).isNull(); + assertThat(image.legacyUrl()).isNull(); }); verify(fileStorage, never()).resolveFileUrl(any()); } @@ -1056,6 +1068,65 @@ void getRestaurantSummary_returns_main_information() { )))); } + @ParameterizedTest + @ValueSource(strings = {"summary", "list", "admin"}) + void 기존_사진과_처리중_사진이_섞여도_새_목록만으로_주소와_순서를_알_수_있다(String endpoint) { + Restaurant restaurant = createRestaurant(1L, 4.8, 100L); + UUID processingId = UUID.randomUUID(); + UUID readyId = UUID.randomUUID(); + restaurant.replaceImages(List.of( + image(103L, "restaurants/old-ready.jpg", readyId, 3), + image(101L, "restaurants/legacy.jpg", null, 1), + image(102L, "restaurants/old-processing.jpg", processingId, 2))); + MediaImageRole role = endpoint.equals("list") + ? MediaImageRole.RESTAURANT_CARD : MediaImageRole.RESTAURANT_HERO; + MediaImage processing = statusImage(processingId, role, MediaImageStatus.PROCESSING); + MediaImage ready = readyImage(readyId, role, "https://cdn/new.webp"); + given(fileStorage.resolveFileUrl("restaurants/legacy.jpg")).willReturn("https://cdn/legacy.jpg"); + given(mediaPort.findImages(any())).willReturn(Map.of( + new MediaImageRequest(processingId, role), processing, + new MediaImageRequest(readyId, role), ready)); + List urls; + List images; + if (endpoint.equals("list")) { + givenRestaurants(List.of(restaurant)); + var response = createRestaurantService().getRestaurants(null, null, null, null, null, 10) + .content().getFirst(); + urls = response.imageUrls(); + images = response.cardImages(); + assertThat(response.thumbnailImage().legacyUrl()).isEqualTo("https://cdn/legacy.jpg"); + } else if (endpoint.equals("admin")) { + given(restaurantRepository.findByIdForUpdate(1L)).willReturn(Optional.of(restaurant)); + var response = createRestaurantService().updateByAdmin(1L, updateMenuCommand(null)); + urls = response.imageUrls(); + images = response.heroImages(); + assertThat(response.thumbnailImage().legacyUrl()).isEqualTo("https://cdn/legacy.jpg"); + } else { + given(restaurantRepository.findActiveByIdWithImages(1L)).willReturn(Optional.of(restaurant)); + var response = createRestaurantService().getRestaurantSummary(1L); + urls = response.imageUrls(); + images = response.heroImages(); + assertThat(response.thumbnailImage().legacyUrl()).isEqualTo("https://cdn/legacy.jpg"); + } + assertThat(urls).containsExactly("https://cdn/legacy.jpg", "https://cdn/new.webp"); + assertThat(images).extracting(image -> image.restaurantImageId()).containsExactly(101L, 102L, 103L); + assertThat(images).extracting(image -> image.displayOrder()).containsExactly(1, 2, 3); + assertThat(images.get(0).image()).isNull(); + assertThat(images.get(0).legacyUrl()).isEqualTo("https://cdn/legacy.jpg"); + assertThat(images.get(1).image()).isEqualTo(processing); + assertThat(images.get(1).legacyUrl()).isNull(); + assertThat(images.get(2).image()).isEqualTo(ready); + assertThat(images.get(2).legacyUrl()).isNull(); + var json = new com.fasterxml.jackson.databind.ObjectMapper().valueToTree(images); + assertThat(json.get(0).get("legacyUrl").asText()).isEqualTo("https://cdn/legacy.jpg"); + assertThat(json.get(1).get("image").get("status").asText()).isEqualTo("PROCESSING"); + assertThat(json.get(2).get("image").get("defaultSource").get("url").asText()) + .isEqualTo("https://cdn/new.webp"); + verify(mediaPort).findImages(any()); + verify(fileStorage, never()).resolveFileUrl("restaurants/old-processing.jpg"); + verify(fileStorage, never()).resolveFileUrl("restaurants/old-ready.jpg"); + } + @Test void 랜덤_추천은_현재_식당을_제외하고_메인_정보를_반환한다() { RestaurantService restaurantService = createRestaurantService(); diff --git a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java index 7000f338..83707311 100644 --- a/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java +++ b/src/test/java/org/sopt/hashi/restaurant/web/RestaurantControllerTest.java @@ -97,8 +97,8 @@ class RestaurantControllerTest { List.of(new MediaImage.Candidate(thumbnailUrl, 192, 192)) )) ); - RestaurantImageInfo thumbnailInfo = new RestaurantImageInfo(101L, 1, thumbnailImage); - RestaurantImageInfo heroInfo = new RestaurantImageInfo(101L, 1, heroImage); + RestaurantImageInfo thumbnailInfo = new RestaurantImageInfo(101L, 1, thumbnailImage, null); + RestaurantImageInfo heroInfo = new RestaurantImageInfo(101L, 1, heroImage, null); RestaurantMainResponse response = new RestaurantMainResponse( 1L, "히마와리 스시", From d51b829b7e57232c54bfb206a926c35886b27ab5 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:04 +0900 Subject: [PATCH 24/26] =?UTF-8?q?refactor(reservation):=20=EA=B3=B5?= =?UTF-8?q?=ED=86=B5=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=20?= =?UTF-8?q?=EA=B7=9C=EC=B9=99=20=EC=A0=81=EC=9A=A9=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../reservation/service/ReservationService.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java b/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java index a68d00cf..62809e7c 100644 --- a/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java +++ b/src/main/java/org/sopt/hashi/reservation/service/ReservationService.java @@ -13,7 +13,7 @@ import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; -import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.point.PointPort; import org.sopt.hashi.point.PointSourceType; @@ -433,17 +433,10 @@ private ProjectedImage projectThumbnail( ImageReference reference, MediaProjection mediaProjection ) { - if (reference == null) { - return ProjectedImage.empty(); - } - if (reference.assetId() == null) { - return new ProjectedImage(reference.legacyUrl(), null); - } - MediaImage mediaImage = mediaProjection.find(reference); - String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY - ? mediaImage.defaultSource().url() - : null; - return new ProjectedImage(url, mediaImage); + MediaImage mediaImage = reference == null || reference.assetId() == null + ? null : mediaProjection.find(reference); + MediaImageSelection selection = MediaImageSelection.from(reference, mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private record MediaProjection(Map images) { From e0f82be884372af384b1b0ec2e93004b23f81f82 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:05 +0900 Subject: [PATCH 25/26] =?UTF-8?q?refactor(review):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=A0=81=EC=9A=A9=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../hashi/review/service/MyReviewService.java | 17 +++++------------ .../service/ReviewReservationQueryService.java | 17 +++++------------ 2 files changed, 10 insertions(+), 24 deletions(-) diff --git a/src/main/java/org/sopt/hashi/review/service/MyReviewService.java b/src/main/java/org/sopt/hashi/review/service/MyReviewService.java index 61747a28..a53dfaea 100644 --- a/src/main/java/org/sopt/hashi/review/service/MyReviewService.java +++ b/src/main/java/org/sopt/hashi/review/service/MyReviewService.java @@ -13,7 +13,7 @@ import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; -import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.reservation.ReservationPort; import org.sopt.hashi.reservation.ReservationReviewInfo; @@ -267,17 +267,10 @@ private ProjectedImage projectThumbnail( ImageReference reference, MediaProjection mediaProjection ) { - if (reference == null) { - return ProjectedImage.empty(); - } - if (reference.assetId() == null) { - return new ProjectedImage(reference.legacyUrl(), null); - } - MediaImage mediaImage = mediaProjection.find(reference); - String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY - ? mediaImage.defaultSource().url() - : null; - return new ProjectedImage(url, mediaImage); + MediaImage mediaImage = reference == null || reference.assetId() == null + ? null : mediaProjection.find(reference); + MediaImageSelection selection = MediaImageSelection.from(reference, mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private record MediaProjection(Map images) { diff --git a/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java b/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java index 7620fe0d..4db366e6 100644 --- a/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java +++ b/src/main/java/org/sopt/hashi/review/service/ReviewReservationQueryService.java @@ -13,7 +13,7 @@ import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; -import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.point.PointPort; import org.sopt.hashi.point.PointSourceType; @@ -388,17 +388,10 @@ private ProjectedImage projectThumbnail( ImageReference reference, MediaProjection mediaProjection ) { - if (reference == null) { - return ProjectedImage.empty(); - } - if (reference.assetId() == null) { - return new ProjectedImage(reference.legacyUrl(), null); - } - MediaImage mediaImage = mediaProjection.find(reference); - String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY - ? mediaImage.defaultSource().url() - : null; - return new ProjectedImage(url, mediaImage); + MediaImage mediaImage = reference == null || reference.assetId() == null + ? null : mediaProjection.find(reference); + MediaImageSelection selection = MediaImageSelection.from(reference, mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private record RestaurantDisplay( From 17f516990a93d49e185b37841203dfbf74ec2d07 Mon Sep 17 00:00:00 2001 From: hwi-hwi-hwi Date: Fri, 18 Sep 2026 00:54:05 +0900 Subject: [PATCH 26/26] =?UTF-8?q?refactor(admin):=20=EA=B3=B5=ED=86=B5=20?= =?UTF-8?q?=EC=9D=B4=EB=AF=B8=EC=A7=80=20=EC=84=A0=ED=83=9D=20=EA=B7=9C?= =?UTF-8?q?=EC=B9=99=20=EC=A0=81=EC=9A=A9=20(#190)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminReservationService.java | 17 +++++------------ 1 file changed, 5 insertions(+), 12 deletions(-) diff --git a/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java b/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java index bd0c8b73..5100b616 100644 --- a/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java +++ b/src/main/java/org/sopt/hashi/admin/service/AdminReservationService.java @@ -11,7 +11,7 @@ import org.sopt.hashi.media.MediaImage; import org.sopt.hashi.media.MediaImageRequest; import org.sopt.hashi.media.MediaImageRole; -import org.sopt.hashi.media.MediaImageStatus; +import org.sopt.hashi.media.MediaImageSelection; import org.sopt.hashi.media.MediaPort; import org.sopt.hashi.reservation.AdminReservationInfo; import org.sopt.hashi.reservation.ReservationPort; @@ -92,17 +92,10 @@ private ProjectedImage projectThumbnail( ImageReference reference, MediaProjection mediaProjection ) { - if (reference == null) { - return ProjectedImage.empty(); - } - if (reference.assetId() == null) { - return new ProjectedImage(reference.legacyUrl(), null); - } - MediaImage mediaImage = mediaProjection.find(reference); - String url = mediaImage != null && mediaImage.status() == MediaImageStatus.READY - ? mediaImage.defaultSource().url() - : null; - return new ProjectedImage(url, mediaImage); + MediaImage mediaImage = reference == null || reference.assetId() == null + ? null : mediaProjection.find(reference); + MediaImageSelection selection = MediaImageSelection.from(reference, mediaImage); + return new ProjectedImage(selection.url(), selection.image()); } private record MediaProjection(Map images) {