From d98d88af6b4a580cea1b85f4dff1f143f32237bb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=9A=A9=EC=A4=80?= <141994188+youngJun99@users.noreply.github.com> Date: Sun, 1 Feb 2026 20:48:28 +0900 Subject: [PATCH 01/28] =?UTF-8?q?feat:=20OCI=EB=A1=9C=20=EB=A7=88=EC=9D=B4?= =?UTF-8?q?=EA=B7=B8=EB=A0=88=EC=9D=B4=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../cloth/service/ClothAiServiceImpl.java | 12 +- .../cloth/service/ClothServiceImpl.java | 6 +- .../history/service/HistoryServiceImpl.java | 6 +- .../image/event/ImageEventListener.java | 8 +- .../src/main/resources/application-dev.yml | 17 +- .../src/main/resources/application-local.yml | 17 +- .../src/main/resources/application-prod.yml | 17 +- .../cloth/service/ClothServiceTest.java | 8 +- .../service/HistoryServiceImplTest.java | 8 +- clokey-infrastructure/build.gradle | 2 +- .../main/java/org/clokey/config/S3Config.java | 39 ++- .../org/clokey/properties/AwsProperties.java | 6 - .../org/clokey/properties/OciProperties.java | 12 + .../clokey/properties/PropertiesConfig.java | 4 +- .../org/clokey/properties/S3Properties.java | 6 - .../clokey/properties/StorageProperties.java | 6 + .../src/main/java/org/clokey/util/S3Util.java | 173 ------------ .../java/org/clokey/util/StorageUtil.java | 262 ++++++++++++++++++ 18 files changed, 356 insertions(+), 253 deletions(-) delete mode 100644 clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java create mode 100644 clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java delete mode 100644 clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java create mode 100644 clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java delete mode 100644 clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java create mode 100644 clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java index 4b3b799b..dd98ee4e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -34,7 +34,7 @@ import org.clokey.global.util.MemberUtil; import org.clokey.member.entity.Member; import org.clokey.properties.WebClientProperties; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.clokey.util.WebClientUtil; import org.springframework.stereotype.Service; @@ -47,7 +47,7 @@ public class ClothAiServiceImpl implements ClothAiService { private final MemberUtil memberUtil; private final CategoryRepository categoryRepository; - private final S3Util s3Util; + private final StorageUtil storageUtil; private final WebClientUtil webClientUtil; private final WebClientProperties webClientProperties; @@ -61,7 +61,7 @@ public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( request.payloads().stream() .map( req -> - s3Util.createPresignedUrl( + storageUtil.createPresignedUrl( ImageType.CLOTH_IMAGE, currentMember.getId(), req.fileExtension(), @@ -262,13 +262,13 @@ public ClothDetectResponse detectClothes(ClothDetectRequest request) { } private void validateImageUrls(List imageUrls) { - if (!s3Util.doAllFilesExistByUrls(imageUrls)) { + if (!storageUtil.doAllFilesExistByUrls(imageUrls)) { throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); } } private void validateImageUrl(String imageUrl) { - if (!s3Util.doesFileExistByUrl(imageUrl)) { + if (!storageUtil.doesFileExistByUrl(imageUrl)) { throw new BaseCustomException(HistoryErrorCode.HISTORY_IMAGE_NOT_FOUND); } } @@ -295,7 +295,7 @@ private List createPresignedUrls(Long memberId, int count) { return java.util.stream.IntStream.range(0, count) .mapToObj( i -> - s3Util.createPresignedUrlWithoutMd5( + storageUtil.createPresignedUrlWithoutMd5( ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) .toList(); } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java index 903ac5e2..383cc9b8 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java @@ -26,7 +26,7 @@ import org.clokey.global.util.MemberUtil; import org.clokey.member.entity.Member; import org.clokey.response.SliceResponse; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.stereotype.Service; @@ -44,7 +44,7 @@ public class ClothServiceImpl implements ClothService { private final ApplicationEventPublisher eventPublisher; private final CoordinateClothRepository coordinateClothRepository; - private final S3Util s3Util; + private final StorageUtil storageUtil; @Override @Transactional @@ -80,7 +80,7 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { List clothImageUrls = request.content().stream().map(ClothCreateRequest::clothImageUrl).toList(); // 모든 선택된 url들을 확정하는 로직. - s3Util.updateTagsToCompleteByUrls(clothImageUrls); + storageUtil.updateTagsToCompleteByUrls(clothImageUrls); return ClothCreateResponse.from(clothes); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index e1816919..fff0f752 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -38,7 +38,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.Visibility; import org.clokey.report.enums.TargetType; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -65,7 +65,7 @@ public class HistoryServiceImpl implements HistoryService { private final ReportRepository reportRepository; private final MemberRepository memberRepository; private final ApplicationEventPublisher eventPublisher; - private final S3Util s3Util; + private final StorageUtil storageUtil; @Override @Transactional @@ -384,7 +384,7 @@ public HistoryImagesPresignedUrlResponse getHistoryUploadPresignedUrls( request.payloads().stream() .map( payload -> - s3Util.createPresignedUrl( + storageUtil.createPresignedUrl( ImageType.HISTORY_IMAGE, currentMember.getId(), payload.fileExtension(), diff --git a/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java b/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java index ad83e781..d91f03b1 100644 --- a/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java +++ b/clokey-api/src/main/java/org/clokey/domain/image/event/ImageEventListener.java @@ -1,7 +1,7 @@ package org.clokey.domain.image.event; import lombok.RequiredArgsConstructor; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Component; import org.springframework.transaction.event.TransactionPhase; @@ -11,17 +11,17 @@ @RequiredArgsConstructor public class ImageEventListener { - private final S3Util s3Util; + private final StorageUtil storageUtil; @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleImagesDeleteEvent(ImagesDeleteEvent event) { - s3Util.deleteAllByUrls(event.imageUrls()); + storageUtil.deleteAllByUrls(event.imageUrls()); } @Async @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) public void handleImageDeleteEvent(ImageDeleteEvent event) { - s3Util.deleteByUrl(event.imageUrl()); + storageUtil.deleteByUrl(event.imageUrl()); } } diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 27ff94b4..26e0e733 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -71,13 +71,16 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${DEV_AWS_ACCESS_KEY_ID} - secret-access-key: ${DEV_AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${DEV_S3_BUCKET} - endpoint: ${DEV_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_OBJECTSTORAGE_BUCKET} external: api: diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index d3a25664..c3465277 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -71,13 +71,16 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${AWS_ACCESS_KEY_ID} - secret-access-key: ${AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${S3_BUCKET} - endpoint: ${S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_OBJECTSTORAGE_BUCKET} external: api: diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index e6a4b270..ca95030c 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -71,13 +71,16 @@ jwt: refresh-token-expiration-time: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} issuer: ${JWT_ISSUER} -aws: - access-key-id: ${PROD_AWS_ACCESS_KEY_ID} - secret-access-key: ${PROD_AWS_SECRET_ACCESS_KEY} - region: ${AWS_REGION} - s3: - bucket: ${PROD_S3_BUCKET} - endpoint: ${PROD_S3_ENDPOINT:https://s3.ap-northeast-2.amazonaws.com} +oci: + tenancy-id: ${OCI_TENANCY_ID} + user-id: ${OCI_USER_ID} + fingerprint: ${OCI_FINGERPRINT} + private-key: ${OCI_PRIVATE_KEY} + region: ${OCI_REGION} + passphrase: ${OCI_PASSPHRASE:} + objectstorage: + namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} + bucket: ${OCI_OBJECTSTORAGE_BUCKET} firebase: credentials-path: ${FIREBASE_CREDENTIALS_PATH} diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java index 8f54c553..4ce4df22 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java @@ -48,7 +48,7 @@ import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; import org.clokey.response.SliceResponse; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Nested; @@ -80,7 +80,7 @@ class ClothServiceTest extends IntegrationTest { @Autowired private SituationRepository situationRepository; @MockitoBean private MemberUtil memberUtil; - @MockitoBean private S3Util s3Util; + @MockitoBean private StorageUtil storageUtil; @Autowired private ApplicationEvents applicationEvents; @Nested @@ -109,10 +109,10 @@ void setUp() { FileExtension.JPEG, "testMd5Hash1"), new ClothImagesUploadRequest.Payload( FileExtension.JPEG, "testMd5Hash2"))); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) + given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) .willReturn("testUrl1"); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) + given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) .willReturn("testUrl2"); // when diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index d77f89c6..7e01d525 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -48,7 +48,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; -import org.clokey.util.S3Util; +import org.clokey.util.StorageUtil; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; import org.springframework.test.context.bean.override.mockito.MockitoBean; @@ -77,7 +77,7 @@ class HistoryServiceImplTest extends IntegrationTest { @Autowired private BlockRepository blockRepository; @MockitoBean private MemberUtil memberUtil; - @MockitoBean private S3Util s3Util; + @MockitoBean private StorageUtil storageUtil; @Autowired private ApplicationEvents applicationEvents; @Nested @@ -105,9 +105,9 @@ void setUp() { new HistoryImagesUploadRequest.Payload( FileExtension.PNG, "testMd5Hash2"))); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) + given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) .willReturn("testUrl1"); - given(s3Util.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) + given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) .willReturn("testUrl2"); // when diff --git a/clokey-infrastructure/build.gradle b/clokey-infrastructure/build.gradle index 0b072959..311bd530 100644 --- a/clokey-infrastructure/build.gradle +++ b/clokey-infrastructure/build.gradle @@ -14,7 +14,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'io.awspring.cloud:spring-cloud-starter-aws:2.4.4' + implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0' api 'com.google.firebase:firebase-admin:9.7.0' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'io.vanslog:spring-data-meilisearch:0.7.3' diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java b/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java index 6ec82256..f9c4dfec 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java @@ -1,13 +1,10 @@ package org.clokey.config; -import com.amazonaws.auth.AWSStaticCredentialsProvider; -import com.amazonaws.auth.BasicAWSCredentials; -import com.amazonaws.client.builder.AwsClientBuilder; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.AmazonS3ClientBuilder; +import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import java.io.ByteArrayInputStream; import lombok.RequiredArgsConstructor; -import org.clokey.properties.AwsProperties; -import org.clokey.properties.S3Properties; +import org.clokey.properties.OciProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -15,21 +12,23 @@ @RequiredArgsConstructor public class S3Config { - private final AwsProperties awsProperties; - private final S3Properties s3Properties; + private final OciProperties ociProperties; @Bean - public AmazonS3 s3Client() { - BasicAWSCredentials credentials = - new BasicAWSCredentials( - awsProperties.accessKeyId(), awsProperties.secretAccessKey()); - AwsClientBuilder.EndpointConfiguration endpointConfiguration = - new AwsClientBuilder.EndpointConfiguration( - s3Properties.endpoint(), awsProperties.region()); + public ObjectStorageClient objectStorageClient() { + String privateKey = ociProperties.privateKey(); + String passphrase = ociProperties.passphrase(); - return AmazonS3ClientBuilder.standard() - .withEndpointConfiguration(endpointConfiguration) - .withCredentials(new AWSStaticCredentialsProvider(credentials)) - .build(); + SimpleAuthenticationDetailsProvider provider = + SimpleAuthenticationDetailsProvider.builder() + .tenantId(ociProperties.tenancyId()) + .userId(ociProperties.userId()) + .fingerprint(ociProperties.fingerprint()) + .privateKeySupplier(() -> new ByteArrayInputStream(privateKey.getBytes())) + .passPhrase(passphrase != null && !passphrase.isEmpty() ? passphrase : null) + .region(com.oracle.bmc.Region.fromRegionId(ociProperties.region())) + .build(); + + return new ObjectStorageClient(provider); } } diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java deleted file mode 100644 index d96917bd..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/AwsProperties.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.clokey.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties("aws") -public record AwsProperties(String accessKeyId, String secretAccessKey, String region) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java new file mode 100644 index 00000000..bc7fe247 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/OciProperties.java @@ -0,0 +1,12 @@ +package org.clokey.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("oci") +public record OciProperties( + String tenancyId, + String userId, + String fingerprint, + String privateKey, + String region, + String passphrase) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java index 52ef9a03..fe30c904 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/PropertiesConfig.java @@ -7,8 +7,8 @@ @EnableConfigurationProperties({ RedisProperties.class, JwtProperties.class, - S3Properties.class, - AwsProperties.class, + StorageProperties.class, + OciProperties.class, WebClientProperties.class, FirebaseProperties.class, MeilisearchProperties.class diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java deleted file mode 100644 index ff021da2..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/properties/S3Properties.java +++ /dev/null @@ -1,6 +0,0 @@ -package org.clokey.properties; - -import org.springframework.boot.context.properties.ConfigurationProperties; - -@ConfigurationProperties("aws.s3") -public record S3Properties(String bucket, String endpoint) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java b/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java new file mode 100644 index 00000000..62b83554 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/properties/StorageProperties.java @@ -0,0 +1,6 @@ +package org.clokey.properties; + +import org.springframework.boot.context.properties.ConfigurationProperties; + +@ConfigurationProperties("oci.objectstorage") +public record StorageProperties(String namespace, String bucket) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java b/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java deleted file mode 100644 index 76146573..00000000 --- a/clokey-infrastructure/src/main/java/org/clokey/util/S3Util.java +++ /dev/null @@ -1,173 +0,0 @@ -package org.clokey.util; - -import com.amazonaws.HttpMethod; -import com.amazonaws.SdkClientException; -import com.amazonaws.services.s3.AmazonS3; -import com.amazonaws.services.s3.model.*; -import java.util.Date; -import java.util.List; -import java.util.UUID; -import java.util.concurrent.TimeUnit; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.clokey.enums.FileExtension; -import org.clokey.enums.ImageType; -import org.clokey.properties.S3Properties; -import org.springframework.stereotype.Component; - -@Component -@RequiredArgsConstructor -@Slf4j -public class S3Util { - - private final AmazonS3 amazonS3; - private final S3Properties s3Properties; - - public String createPresignedUrl( - ImageType imageType, Long memberId, FileExtension fileExtension, String md5Hash) { - String imageKey = UUID.randomUUID().toString(); - String fileName = createFileName(imageType, memberId, imageKey, fileExtension); - String bucket = s3Properties.bucket(); - - GeneratePresignedUrlRequest generatePresignedUrlRequest = - generatePresignedUrlRequest( - bucket, fileName, fileExtension.getExtension(), md5Hash); - - return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); - } - - public String createPresignedUrlWithoutMd5( - ImageType imageType, Long memberId, FileExtension fileExtension) { - String imageKey = UUID.randomUUID().toString(); - String fileName = createFileName(imageType, memberId, imageKey, fileExtension); - String bucket = s3Properties.bucket(); - - GeneratePresignedUrlRequest generatePresignedUrlRequest = - generatePresignedUrlRequestWithoutMd5( - bucket, fileName, fileExtension.getExtension()); - - return amazonS3.generatePresignedUrl(generatePresignedUrlRequest).toString(); - } - - private String createFileName( - ImageType imageType, Long memberId, String imageKey, FileExtension fileExtension) { - return memberId - + "/" - + imageType.getType() - + "/" - + imageKey - + "." - + fileExtension.getExtension(); - } - - private GeneratePresignedUrlRequest generatePresignedUrlRequest( - String bucket, String fileName, String imageFileExtension, String md5Hash) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) - .withKey(fileName) - .withContentType("image/" + imageFileExtension) - .withExpiration(getPresignedUrlExpiration()); - - generatePresignedUrlRequest.addRequestParameter("x-amz-tagging", "status=pending"); - - generatePresignedUrlRequest.setContentMd5(md5Hash); - - return generatePresignedUrlRequest; - } - - private GeneratePresignedUrlRequest generatePresignedUrlRequestWithoutMd5( - String bucket, String fileName, String imageFileExtension) { - GeneratePresignedUrlRequest generatePresignedUrlRequest = - new GeneratePresignedUrlRequest(bucket, fileName, HttpMethod.PUT) - .withKey(fileName) - .withContentType("image/" + imageFileExtension) - .withExpiration(getPresignedUrlExpiration()); - - generatePresignedUrlRequest.addRequestParameter("x-amz-tagging", "status=pending"); - - return generatePresignedUrlRequest; - } - - public void deleteAllByUrls(List urls) { - if (urls == null || urls.isEmpty()) { - log.info("deleteAllByUrls skipped: received null or empty urls"); - return; - } - String bucket = s3Properties.bucket(); - - List keys = - urls.stream() - .map(this::extractObjectKey) - .map(DeleteObjectsRequest.KeyVersion::new) - .toList(); - - DeleteObjectsRequest request = new DeleteObjectsRequest(bucket).withKeys(keys); - amazonS3.deleteObjects(request); - } - - public void deleteByUrl(String url) { - String bucket = s3Properties.bucket(); - String objectKey = extractObjectKey(url); - amazonS3.deleteObject(bucket, objectKey); - } - - private String extractObjectKey(String url) { - int comIndex = url.indexOf(".com/"); - return url.substring(comIndex + 5); - } - - private Date getPresignedUrlExpiration() { - Date expiration = new Date(); - long expTimeMillis = expiration.getTime(); - expTimeMillis += TimeUnit.HOURS.toMillis(1); - expiration.setTime(expTimeMillis); - - return expiration; - } - - public void updateTagToCompleteByUrl(String url) { - String bucket = s3Properties.bucket(); - String key = extractObjectKey(url); - - List tags = List.of(new Tag("status", "complete")); - ObjectTagging tagging = new ObjectTagging(tags); - - SetObjectTaggingRequest request = new SetObjectTaggingRequest(bucket, key, tagging); - amazonS3.setObjectTagging(request); - } - - public void updateTagsToCompleteByUrls(List urls) { - for (String url : urls) { - updateTagToCompleteByUrl(url); - } - } - - public boolean doesFileExistByUrl(String url) { - String bucket = s3Properties.bucket(); - String key = extractObjectKey(url); - try { - return amazonS3.doesObjectExist(bucket, key); - } catch (AmazonS3Exception e) { - if (e.getStatusCode() == 403) { - log.warn("Access denied for key={}, treating as non-existent", key); - return false; - } - log.error("S3 error while checking existence: {}", e.getErrorMessage(), e); - return false; - } catch (SdkClientException e) { - log.error("Network error while connecting to S3: {}", e.getMessage()); - return false; - } - } - - public boolean doAllFilesExistByUrls(List urls) { - for (String url : urls) { - if (!doesFileExistByUrl(url)) { - log.warn("File not found or inaccessible: {}", url); - return false; - } - } - - return true; - } -} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java new file mode 100644 index 00000000..819a6157 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java @@ -0,0 +1,262 @@ +package org.clokey.util; + +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import com.oracle.bmc.objectstorage.model.CreatePreauthenticatedRequestDetails; +import com.oracle.bmc.objectstorage.requests.*; +import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse; +import com.oracle.bmc.objectstorage.responses.HeadObjectResponse; +import java.time.Instant; +import java.util.Date; +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.clokey.enums.FileExtension; +import org.clokey.enums.ImageType; +import org.clokey.properties.StorageProperties; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class StorageUtil { + + private final ObjectStorageClient objectStorageClient; + private final StorageProperties storageProperties; + + // TODO : S3 -> OCI Storage로 이관하면서 MD5 해시를 사용하지 않게 되었습니다. 추후에 삭제할 필요가 있습니다. + public String createPresignedUrl( + ImageType imageType, Long memberId, FileExtension fileExtension, String md5Hash) { + String imageKey = UUID.randomUUID().toString(); + String fileName = createFileName(imageType, memberId, imageKey, fileExtension); + + CreatePreauthenticatedRequestDetails details = + CreatePreauthenticatedRequestDetails.builder() + .name("presigned-url-" + imageKey) + .objectName(fileName) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + .timeExpires(getPresignedUrlExpiration()) + .build(); + + CreatePreauthenticatedRequestRequest request = + CreatePreauthenticatedRequestRequest.builder() + .namespaceName(storageProperties.namespace()) + .bucketName(storageProperties.bucket()) + .createPreauthenticatedRequestDetails(details) + .build(); + + CreatePreauthenticatedRequestResponse response = + objectStorageClient.createPreauthenticatedRequest(request); + + String presignedUrl = + objectStorageClient.getEndpoint() + + response.getPreauthenticatedRequest().getAccessUri(); + + return presignedUrl; + } + + public String createPresignedUrlWithoutMd5( + ImageType imageType, Long memberId, FileExtension fileExtension) { + String imageKey = UUID.randomUUID().toString(); + String fileName = createFileName(imageType, memberId, imageKey, fileExtension); + + CreatePreauthenticatedRequestDetails details = + CreatePreauthenticatedRequestDetails.builder() + .name("presigned-url-" + imageKey) + .objectName(fileName) + .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) + .timeExpires(getPresignedUrlExpiration()) + .build(); + + CreatePreauthenticatedRequestRequest request = + CreatePreauthenticatedRequestRequest.builder() + .namespaceName(storageProperties.namespace()) + .bucketName(storageProperties.bucket()) + .createPreauthenticatedRequestDetails(details) + .build(); + + CreatePreauthenticatedRequestResponse response = + objectStorageClient.createPreauthenticatedRequest(request); + + return objectStorageClient.getEndpoint() + + response.getPreauthenticatedRequest().getAccessUri(); + } + + private String createFileName( + ImageType imageType, Long memberId, String imageKey, FileExtension fileExtension) { + return memberId + + "/" + + imageType.getType() + + "/" + + imageKey + + "." + + fileExtension.getExtension(); + } + + public void deleteAllByUrls(List urls) { + if (urls == null || urls.isEmpty()) { + log.info("deleteAllByUrls skipped: received null or empty urls"); + return; + } + + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + + for (String url : urls) { + try { + String objectKey = extractObjectKey(url); + DeleteObjectRequest request = + DeleteObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + objectStorageClient.deleteObject(request); + } catch (Exception e) { + log.error("Failed to delete object: {}", url, e); + } + } + } + + public void deleteByUrl(String url) { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + DeleteObjectRequest request = + DeleteObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + objectStorageClient.deleteObject(request); + } + + private String extractObjectKey(String url) { + // OCI Object Storage URL 형식에 맞게 파싱 + // 예: https://objectstorage.{region}.oraclecloud.com/n/{namespace}/b/{bucket}/o/{object} + int oIndex = url.indexOf("/o/"); + if (oIndex != -1) { + return url.substring(oIndex + 3); + } + int comIndex = url.indexOf(".com/"); + if (comIndex != -1) { + String afterCom = url.substring(comIndex + 5); + int firstSlash = afterCom.indexOf("/"); + if (firstSlash != -1) { + return afterCom.substring(firstSlash + 1); + } + } + throw new IllegalArgumentException("Invalid URL format: " + url); + } + + private Date getPresignedUrlExpiration() { + return Date.from(Instant.now().plusSeconds(3600)); // 1시간 후 + } + + public void updateTagToCompleteByUrl(String url) { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + try { + HeadObjectRequest headRequest = + HeadObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + HeadObjectResponse headResponse = objectStorageClient.headObject(headRequest); + + Map metadata = new HashMap<>(); + if (headResponse.getOpcMeta() != null) { + metadata.putAll(headResponse.getOpcMeta()); + } + + metadata.put("status", "complete"); + + com.oracle.bmc.objectstorage.model.CopyObjectDetails copyDetails = + com.oracle.bmc.objectstorage.model.CopyObjectDetails.builder() + .sourceObjectName(objectKey) + .destinationBucket(bucket) + .destinationObjectName(objectKey) + .build(); + + CopyObjectRequest copyRequest = + CopyObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .copyObjectDetails(copyDetails) + .build(); + + objectStorageClient.copyObject(copyRequest); + + log.info( + "Object copied for tag update (metadata update may require manual intervention): {}", + objectKey); + } catch (com.oracle.bmc.model.BmcException e) { + log.error( + "Failed to update tag for object: {}, status: {}, message: {}", + objectKey, + e.getStatusCode(), + e.getMessage(), + e); + log.warn("Tag update failed but continuing: {}", objectKey); + } catch (Exception e) { + log.error("Failed to update tag for object: {}", objectKey, e); + log.warn("Tag update failed but continuing: {}", objectKey); + } + } + + public void updateTagsToCompleteByUrls(List urls) { + for (String url : urls) { + updateTagToCompleteByUrl(url); + } + } + + public boolean doesFileExistByUrl(String url) { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); + + try { + HeadObjectRequest request = + HeadObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); + + HeadObjectResponse response = objectStorageClient.headObject(request); + return response.get__httpStatusCode__() == 200; + } catch (com.oracle.bmc.model.BmcException e) { + if (e.getStatusCode() == 404) { + return false; + } + if (e.getStatusCode() == 403) { + log.warn("Access denied for key={}, treating as non-existent", objectKey); + return false; + } + log.error("OCI error while checking existence: {}", e.getMessage(), e); + return false; + } catch (Exception e) { + log.error("Network error while connecting to OCI: {}", e.getMessage()); + return false; + } + } + + public boolean doAllFilesExistByUrls(List urls) { + for (String url : urls) { + if (!doesFileExistByUrl(url)) { + log.warn("File not found or inaccessible: {}", url); + return false; + } + } + + return true; + } +} From 052777a70b54dcb18607036475e91dae48f4114d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EB=82=98=EC=9A=A9=EC=A4=80?= <141994188+youngJun99@users.noreply.github.com> Date: Sun, 1 Feb 2026 21:27:05 +0900 Subject: [PATCH 02/28] =?UTF-8?q?chore:=20AWS=20->=20OCI=EB=A1=9C=20export?= =?UTF-8?q?=20=EB=B3=80=EA=B2=BD=20=EB=B0=8F=20Test=EB=A5=BC=20=EC=9C=84?= =?UTF-8?q?=ED=95=B4=20=ED=8A=B8=EB=A6=AC=EA=B1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 25 +++++++++++++++++-------- 1 file changed, 17 insertions(+), 8 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 469aceb2..6c481719 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -1,8 +1,14 @@ name: Clokey-Dev CD on: - push: - branches: [ develop ] + pull_request: + branches: [ "main", "develop" ] + paths: + - "clokey-api/**" + - "clokey-domain/**" + - "clokey-common/**" + - "clokey-common-web/**" + - ".github/workflows/ci.yml" jobs: dev-cd: @@ -63,7 +69,7 @@ jobs: username: ubuntu host: ${{ secrets.DEV_EC2_HOST }} key: ${{ secrets.DEV_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,DEV_AWS_ACCESS_KEY_ID,DEV_AWS_SECRET_ACCESS_KEY,AWS_REGION,DEV_S3_BUCKET,DEV_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app @@ -90,11 +96,14 @@ jobs: export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export DEV_AWS_ACCESS_KEY_ID=${{ secrets.DEV_AWS_ACCESS_KEY_ID }} - export DEV_AWS_SECRET_ACCESS_KEY=${{ secrets.DEV_AWS_SECRET_ACCESS_KEY }} - export AWS_REGION=${{ secrets.AWS_REGION }} - export DEV_S3_BUCKET=${{ secrets.DEV_S3_BUCKET }} - export DEV_S3_ENDPOINT=${{ secrets.DEV_S3_ENDPOINT }} + export OCI_TENANCY_ID=${{ secrets.DEV_OCI_TENANCY_ID }} + export OCI_USER_ID=${{ secrets.DEV_CI_USER_ID }} + export OCI_FINGERPRINT=${{ secrets.DEV_OCI_FINGERPRINT }} + export OCI_PRIVATE_KEY=${{ secrets.DEV_OCI_PRIVATE_KEY }} + export OCI_REGION=${{ secrets.DEV_OCI_REGION }} + export OCI_PASSPHRASE=${{ secrets.DEV_OCI_PASSPHRASE }} + export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.DEV_OCI_OBJECTSTORAGE_NAMESPACE }} + export OCI_OBJECTSTORAGE_BUCKET=${{ secrets.DEV_OCI_OBJECTSTORAGE_BUCKET }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} From ffbe3713f017c4b7f7f265d91a592e85e39c9c44 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Thu, 12 Feb 2026 22:16:05 -0600 Subject: [PATCH 03/28] =?UTF-8?q?refactor:=20application-*.yml=20/=20*-cd.?= =?UTF-8?q?yml=20/=20*-compose.yml=20OCI=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=EC=97=90=20=EB=A7=9E=EC=B6=B0?= =?UTF-8?q?=EC=84=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 58 ++++++++--------- .github/workflows/prod-cd.yml | 47 +++++++------- clokey-api/dev-compose.yml | 57 ++++++++++++----- clokey-api/prod-compose.yml | 62 +++++++++++++------ .../src/main/resources/application-dev.yml | 10 +-- .../src/main/resources/application-local.yml | 8 ++- .../src/main/resources/application-prod.yml | 10 +-- 7 files changed, 147 insertions(+), 105 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 6c481719..83a7ee35 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -2,13 +2,7 @@ name: Clokey-Dev CD on: pull_request: - branches: [ "main", "develop" ] - paths: - - "clokey-api/**" - - "clokey-domain/**" - - "clokey-common/**" - - "clokey-common-web/**" - - ".github/workflows/ci.yml" + branches: [ "develop" ] jobs: dev-cd: @@ -54,38 +48,36 @@ jobs: --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:dev-app \ . - - name: Copy dev-docker-compose.yml + - name: Copy dev-compose uses: appleboy/scp-action@v0.1.3 with: username: ubuntu - host: ${{ secrets.DEV_EC2_HOST }} - key: ${{ secrets.DEV_EC2_SSH_KEY }} - source: ./clokey-api/dev-compose.yml + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + source: "clokey-api/dev-compose.yml" target: /home/ubuntu/ + strip_components: 1 - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu - host: ${{ secrets.DEV_EC2_HOST }} - key: ${{ secrets.DEV_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,DEV_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,DEV_KAKAO_CLIENT_ID,DEV_KAKAO_CLIENT_SECRET,DEV_APPLE_CLIENT_ID,DEV_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | - export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app - export DEV_MYSQL_HOST=${{ secrets.DEV_MYSQL_HOST }} - export MYSQL_PORT=${{ secrets.MYSQL_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} + export OCI_DEV_MYSQL_DB_NAME=${{ secrets.OCI_DEV_MYSQL_DB_NAME }} + export OCI_MYSQL_USERNAME=${{ secrets.OCI_MYSQL_USERNAME }} + export OCI_MYSQL_PASSWORD=${{ secrets.OCI_MYSQL_PASSWORD }} - export REDIS_HOST=${{ secrets.REDIS_HOST }} - export REDIS_PORT=${{ secrets.REDIS_PORT }} export REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} export KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} - export KAKAO_CLIENT_SECRET=${{ secrets.DEV_KAKAO_CLIENT_SECRET }} + export KAKAO_CLIENT_SECRET=${{ secrets.KAKAO_CLIENT_SECRET }} export APPLE_CLIENT_ID=${{ secrets.APPLE_CLIENT_ID }} export APPLE_CLIENT_SECRET=${{ secrets.APPLE_CLIENT_SECRET }} @@ -96,14 +88,14 @@ jobs: export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export OCI_TENANCY_ID=${{ secrets.DEV_OCI_TENANCY_ID }} - export OCI_USER_ID=${{ secrets.DEV_CI_USER_ID }} - export OCI_FINGERPRINT=${{ secrets.DEV_OCI_FINGERPRINT }} - export OCI_PRIVATE_KEY=${{ secrets.DEV_OCI_PRIVATE_KEY }} - export OCI_REGION=${{ secrets.DEV_OCI_REGION }} - export OCI_PASSPHRASE=${{ secrets.DEV_OCI_PASSPHRASE }} - export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.DEV_OCI_OBJECTSTORAGE_NAMESPACE }} - export OCI_OBJECTSTORAGE_BUCKET=${{ secrets.DEV_OCI_OBJECTSTORAGE_BUCKET }} + export OCI_TENANCY_ID=${{ secrets.OCI_TENANCY_ID }} + export OCI_USER_ID=${{ secrets.OCI_USER_ID }} + export OCI_FINGERPRINT=${{ secrets.OCI_FINGERPRINT }} + export OCI_PRIVATE_KEY=${{ secrets.OCI_PRIVATE_KEY }} + export OCI_REGION=${{ secrets.OCI_REGION }} + export OCI_PASSPHRASE=${{ secrets.OCI_PASSPHRASE }} + export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.OCI_OBJECTSTORAGE_NAMESPACE }} + export OCI_DEV_OBJECTSTORAGE_BUCKET=${{ secrets.OCI_DEV_OBJECTSTORAGE_BUCKET }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} @@ -114,7 +106,7 @@ jobs: export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} - export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} sudo mkdir -p /home/ubuntu/secrets echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null @@ -125,7 +117,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/dev-compose.yml up -d + docker compose -f dev-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 8477e942..4d1b4552 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -5,7 +5,7 @@ on: branches: [ main ] jobs: - dev-cd: + prod-cd: runs-on: ubuntu-latest permissions: contents: read @@ -58,34 +58,32 @@ jobs: --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:prod-app \ . - - name: Copy prod-docker-compose.yml + - name: Copy prod-compose uses: appleboy/scp-action@v0.1.3 with: username: ubuntu - host: ${{ secrets.PROD_EC2_HOST }} - key: ${{ secrets.PROD_EC2_SSH_KEY }} - source: ./clokey-api/prod-compose.yml + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + source: "clokey-api/prod-compose.yml" target: /home/ubuntu/ + strip_components: 1 - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu - host: ${{ secrets.PROD_EC2_HOST }} - key: ${{ secrets.PROD_EC2_SSH_KEY }} - envs: DOCKERHUB_USERNAME,SPRING_PROFILES_ACTIVE,PROD_MYSQL_HOST,MYSQL_PORT,DB_NAME,DB_USERNAME,DB_PASSWORD,REDIS_HOST,REDIS_PORT,REDIS_PASSWORD,PROD_KAKAO_CLIENT_ID,PROD_KAKAO_CLIENT_SECRET,PROD_APPLE_CLIENT_ID,PROD_APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,PROD_AWS_ACCESS_KEY_ID,PROD_AWS_SECRET_ACCESS_KEY,AWS_REGION,PROD_S3_BUCKET,PROD_S3_ENDPOINT,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + host: ${{ secrets.OCI_HOST }} + key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} + envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_PROD_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_PROD_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | - export DOCKERHUB_NAME=${{ secrets.DOCKERHUB_USERNAME }} + export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=prod-app - export PROD_MYSQL_HOST=${{ secrets.PROD_MYSQL_HOST }} - export MYSQL_PORT=${{ secrets.MYSQL_PORT }} - export DB_NAME=${{ secrets.DB_NAME }} - export DB_USERNAME=${{ secrets.DB_USERNAME }} - export DB_PASSWORD=${{ secrets.DB_PASSWORD }} + export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} + export OCI_PROD_MYSQL_DB_NAME=${{ secrets.OCI_PROD_MYSQL_DB_NAME }} + export OCI_MYSQL_USERNAME=${{ secrets.OCI_MYSQL_USERNAME }} + export OCI_MYSQL_PASSWORD=${{ secrets.OCI_MYSQL_PASSWORD }} - export REDIS_HOST=${{ secrets.REDIS_HOST }} - export REDIS_PORT=${{ secrets.REDIS_PORT }} export REDIS_PASSWORD=${{ secrets.REDIS_PASSWORD }} export KAKAO_CLIENT_ID=${{ secrets.KAKAO_CLIENT_ID }} @@ -100,11 +98,14 @@ jobs: export JWT_REFRESH_TOKEN_EXPIRATION_TIME=${{ secrets.JWT_REFRESH_TOKEN_EXPIRATION_TIME }} export JWT_ISSUER=${{ secrets.JWT_ISSUER }} - export PROD_AWS_ACCESS_KEY_ID=${{ secrets.PROD_AWS_ACCESS_KEY_ID }} - export PROD_AWS_SECRET_ACCESS_KEY=${{ secrets.PROD_AWS_SECRET_ACCESS_KEY }} - export AWS_REGION=${{ secrets.AWS_REGION }} - export PROD_S3_BUCKET=${{ secrets.PROD_S3_BUCKET }} - export PROD_S3_ENDPOINT=${{ secrets.PROD_S3_ENDPOINT }} + export OCI_TENANCY_ID=${{ secrets.OCI_TENANCY_ID }} + export OCI_USER_ID=${{ secrets.OCI_USER_ID }} + export OCI_FINGERPRINT=${{ secrets.OCI_FINGERPRINT }} + export OCI_PRIVATE_KEY=${{ secrets.OCI_PRIVATE_KEY }} + export OCI_REGION=${{ secrets.OCI_REGION }} + export OCI_PASSPHRASE=${{ secrets.OCI_PASSPHRASE }} + export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.OCI_OBJECTSTORAGE_NAMESPACE }} + export OCI_PROD_OBJECTSTORAGE_BUCKET=${{ secrets.OCI_PROD_OBJECTSTORAGE_BUCKET }} export SWAGGER_USERNAME=${{ secrets.SWAGGER_USERNAME }} export SWAGGER_PASSWORD=${{ secrets.SWAGGER_PASSWORD }} @@ -115,7 +116,7 @@ jobs: export AI_SERVER_IP=${{ secrets.AI_SERVER_IP }} export CLOTH_INFERENCE_PATH=${{ secrets.CLOTH_INFERENCE_PATH }} export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} - export CLOTH_DETECT_PATH=${{ secrets.CLOTH_DETECT_PATH }} + export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} sudo mkdir -p /home/ubuntu/secrets echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null @@ -126,7 +127,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/prod-compose.yml up -d + docker compose -f prod-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index e837e67c..3295f9c4 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -1,25 +1,39 @@ version: "3.8" services: + redis-dev: + image: redis:7-alpine + container_name: clokey-redis-dev + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] + ports: + - "6379:6379" + networks: + - app_network + app: - image: ${DOCKERHUB_NAME}/clokey-docker-2025:${DOCKER_TAG} - container_name: clokey-app + image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} + container_name: clokey-app-dev pull_policy: always + depends_on: + - redis-dev ports: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: dev - # Database - DEV_MYSQL_HOST: ${DEV_MYSQL_HOST} - MYSQL_PORT: ${MYSQL_PORT} - DB_NAME: ${DB_NAME} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + # Database (MySQL HeatWave) + OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} + MYSQL_PORT: 3306 + OCI_DEV_MYSQL_DB_NAME: ${OCI_DEV_MYSQL_DB_NAME} + OCI_MYSQL_USERNAME: ${OCI_MYSQL_USERNAME} + OCI_MYSQL_PASSWORD: ${OCI_MYSQL_PASSWORD} # Redis - REDIS_HOST: ${REDIS_HOST} - REDIS_PORT: ${REDIS_PORT} + REDIS_HOST: redis-dev + REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} # Kakao OIDC @@ -40,21 +54,30 @@ services: JWT_REFRESH_TOKEN_EXPIRATION_TIME: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} JWT_ISSUER: ${JWT_ISSUER} - # AWS S3 - DEV_AWS_ACCESS_KEY_ID: ${DEV_AWS_ACCESS_KEY_ID} - DEV_AWS_SECRET_ACCESS_KEY: ${DEV_AWS_SECRET_ACCESS_KEY} - AWS_REGION: ${AWS_REGION} - DEV_S3_BUCKET: ${DEV_S3_BUCKET} - DEV_S3_ENDPOINT: ${DEV_S3_ENDPOINT} + # OCI Object Storage + OCI_TENANCY_ID: ${OCI_TENANCY_ID} + OCI_USER_ID: ${OCI_USER_ID} + OCI_FINGERPRINT: ${OCI_FINGERPRINT} + OCI_PRIVATE_KEY: ${OCI_PRIVATE_KEY} + OCI_REGION: ${OCI_REGION} + OCI_PASSPHRASE: ${OCI_PASSPHRASE} + OCI_OBJECTSTORAGE_NAMESPACE: ${OCI_OBJECTSTORAGE_NAMESPACE} + OCI_DEV_OBJECTSTORAGE_BUCKET: ${OCI_DEV_OBJECTSTORAGE_BUCKET} # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} - # MeilieSearch + # MeiliSearch MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} MEILISEARCH_KEY: ${MEILISEARCH_KEY} + # External API (AI) + AI_SERVER_IP: ${AI_SERVER_IP} + CLOTH_INFERENCE_PATH: ${CLOTH_INFERENCE_PATH} + STYLE_INFERENCE_PATH: ${STYLE_INFERENCE_PATH} + HISTORY_CLOTH_DETECT_PATH: ${HISTORY_CLOTH_DETECT_PATH} + volumes: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index c9c6540d..e49db653 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -1,25 +1,39 @@ version: "3.8" services: + redis-prod: + image: redis:7-alpine + container_name: clokey-redis-prod + restart: unless-stopped + environment: + REDIS_PASSWORD: ${REDIS_PASSWORD} + command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] + ports: + - "6380:6379" + networks: + - app_network + app: - image: ${DOCKERHUB_NAME}/clokey-docker-2025:${DOCKER_TAG} - container_name: clokey-app + image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} + container_name: clokey-app-prod pull_policy: always + depends_on: + - redis-prod ports: - - "8080:8080" + - "8081:8080" environment: SPRING_PROFILES_ACTIVE: prod - # Database - PROD_MYSQL_HOST: ${PROD_MYSQL_HOST} - MYSQL_PORT: ${MYSQL_PORT} - DB_NAME: ${DB_NAME} - DB_USERNAME: ${DB_USERNAME} - DB_PASSWORD: ${DB_PASSWORD} + # Database (MySQL HeatWave) + OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} + MYSQL_PORT: 3306 + OCI_PROD_MYSQL_DB_NAME: ${OCI_PROD_MYSQL_DB_NAME} + OCI_MYSQL_USERNAME: ${OCI_MYSQL_USERNAME} + OCI_MYSQL_PASSWORD: ${OCI_MYSQL_PASSWORD} # Redis - REDIS_HOST: ${REDIS_HOST} - REDIS_PORT: ${REDIS_PORT} + REDIS_HOST: redis-prod + REDIS_PORT: 6379 REDIS_PASSWORD: ${REDIS_PASSWORD} # Kakao OIDC @@ -40,21 +54,30 @@ services: JWT_REFRESH_TOKEN_EXPIRATION_TIME: ${JWT_REFRESH_TOKEN_EXPIRATION_TIME} JWT_ISSUER: ${JWT_ISSUER} - # AWS S3 - PROD_AWS_ACCESS_KEY_ID: ${PROD_AWS_ACCESS_KEY_ID} - PROD_AWS_SECRET_ACCESS_KEY: ${PROD_AWS_SECRET_ACCESS_KEY} - AWS_REGION: ${AWS_REGION} - PROD_S3_BUCKET: ${PROD_S3_BUCKET} - PROD_S3_ENDPOINT: ${PROD_S3_ENDPOINT} + # OCI Object Storage + OCI_TENANCY_ID: ${OCI_TENANCY_ID} + OCI_USER_ID: ${OCI_USER_ID} + OCI_FINGERPRINT: ${OCI_FINGERPRINT} + OCI_PRIVATE_KEY: ${OCI_PRIVATE_KEY} + OCI_REGION: ${OCI_REGION} + OCI_PASSPHRASE: ${OCI_PASSPHRASE} + OCI_OBJECTSTORAGE_NAMESPACE: ${OCI_OBJECTSTORAGE_NAMESPACE} + OCI_PROD_OBJECTSTORAGE_BUCKET: ${OCI_PROD_OBJECTSTORAGE_BUCKET} # Swagger SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} - # MeilieSearch + # MeiliSearch MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} MEILISEARCH_KEY: ${MEILISEARCH_KEY} + # External API (AI) + AI_SERVER_IP: ${AI_SERVER_IP} + CLOTH_INFERENCE_PATH: ${CLOTH_INFERENCE_PATH} + STYLE_INFERENCE_PATH: ${STYLE_INFERENCE_PATH} + HISTORY_CLOTH_DETECT_PATH: ${HISTORY_CLOTH_DETECT_PATH} + volumes: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro @@ -63,5 +86,4 @@ services: networks: app_network: - name: app_network - driver: bridge + external: true diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 26e0e733..67e4a236 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -1,9 +1,9 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${DEV_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_DEV_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${OCI_MYSQL_USERNAME} + password: ${OCI_MYSQL_PASSWORD} hikari: minimum-idle: 10 maximum-pool-size: 20 @@ -80,14 +80,14 @@ oci: passphrase: ${OCI_PASSPHRASE:} objectstorage: namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} - bucket: ${OCI_OBJECTSTORAGE_BUCKET} + bucket: ${OCI_DEV_OBJECTSTORAGE_BUCKET} external: api: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} swagger: username: ${SWAGGER_USERNAME} diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index c3465277..5a7bedec 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -80,14 +80,18 @@ oci: passphrase: ${OCI_PASSPHRASE:} objectstorage: namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} - bucket: ${OCI_OBJECTSTORAGE_BUCKET} + bucket: ${OCI_DEV_OBJECTSTORAGE_BUCKET} external: api: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} + +swagger: + username: ${SWAGGER_USERNAME} + password: ${SWAGGER_PASSWORD} spring-doc: default-consumes-media-type: application/json diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index ca95030c..13f95ab1 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -1,9 +1,9 @@ spring: datasource: driver-class-name: com.mysql.cj.jdbc.Driver - url: jdbc:mysql://${PROD_MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 - username: ${DB_USERNAME} - password: ${DB_PASSWORD} + url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_PROD_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 + username: ${OCI_MYSQL_USERNAME} + password: ${OCI_MYSQL_PASSWORD} hikari: minimum-idle: 10 maximum-pool-size: 20 @@ -80,7 +80,7 @@ oci: passphrase: ${OCI_PASSPHRASE:} objectstorage: namespace: ${OCI_OBJECTSTORAGE_NAMESPACE} - bucket: ${OCI_OBJECTSTORAGE_BUCKET} + bucket: ${OCI_PROD_OBJECTSTORAGE_BUCKET} firebase: credentials-path: ${FIREBASE_CREDENTIALS_PATH} @@ -90,7 +90,7 @@ external: ai-server-ip: ${AI_SERVER_IP} cloth-inference-path: ${CLOTH_INFERENCE_PATH} style-inference-path: ${STYLE_INFERENCE_PATH} - cloth-detect-path: ${CLOTH_DETECT_PATH} + cloth-detect-path: ${HISTORY_CLOTH_DETECT_PATH} app: oauth: redirect-scheme: codive From ef4d0075d5fc6103db2033522acf3e8b80d23d65 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Thu, 12 Feb 2026 23:55:29 -0600 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20OCI=20=EC=98=A4=EB=B8=8C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EC=8A=A4=ED=86=A0=EB=A6=AC=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clokey-infrastructure/build.gradle | 1 + ...S3Config.java => ObjectStorageConfig.java} | 19 ++++++++++++++++--- 2 files changed, 17 insertions(+), 3 deletions(-) rename clokey-infrastructure/src/main/java/org/clokey/config/{S3Config.java => ObjectStorageConfig.java} (60%) diff --git a/clokey-infrastructure/build.gradle b/clokey-infrastructure/build.gradle index 311bd530..baf7ab20 100644 --- a/clokey-infrastructure/build.gradle +++ b/clokey-infrastructure/build.gradle @@ -15,6 +15,7 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0' + implementation 'com.oracle.oci.sdk:oci-java-sdk-common-httpclient-jersey3:3.47.0' api 'com.google.firebase:firebase-admin:9.7.0' implementation 'org.springframework.boot:spring-boot-starter-webflux' implementation 'io.vanslog:spring-data-meilisearch:0.7.3' diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java similarity index 60% rename from clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java rename to clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java index f9c4dfec..706c9eb4 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/S3Config.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java @@ -3,6 +3,7 @@ import com.oracle.bmc.auth.SimpleAuthenticationDetailsProvider; import com.oracle.bmc.objectstorage.ObjectStorageClient; import java.io.ByteArrayInputStream; +import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import org.clokey.properties.OciProperties; import org.springframework.context.annotation.Bean; @@ -10,13 +11,22 @@ @Configuration @RequiredArgsConstructor -public class S3Config { +public class ObjectStorageConfig { private final OciProperties ociProperties; + /** 환경 변수에 PEM 키가 "\\n" 문자열로 들어온 경우 실제 줄바꿈으로 복원 */ + private static String normalizePemPrivateKey(String privateKey) { + if (privateKey == null || privateKey.isBlank()) { + return privateKey; + } + return privateKey.replace("\\n", "\n"); + } + @Bean public ObjectStorageClient objectStorageClient() { - String privateKey = ociProperties.privateKey(); + String rawKey = ociProperties.privateKey(); + String privateKey = normalizePemPrivateKey(rawKey); String passphrase = ociProperties.passphrase(); SimpleAuthenticationDetailsProvider provider = @@ -24,7 +34,10 @@ public ObjectStorageClient objectStorageClient() { .tenantId(ociProperties.tenancyId()) .userId(ociProperties.userId()) .fingerprint(ociProperties.fingerprint()) - .privateKeySupplier(() -> new ByteArrayInputStream(privateKey.getBytes())) + .privateKeySupplier( + () -> + new ByteArrayInputStream( + privateKey.getBytes(StandardCharsets.UTF_8))) .passPhrase(passphrase != null && !passphrase.isEmpty() ? passphrase : null) .region(com.oracle.bmc.Region.fromRegionId(ociProperties.region())) .build(); From 0810c7db45d03ae3f662be863c10eb3248cb8c6b Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 13 Feb 2026 00:26:55 -0600 Subject: [PATCH 05/28] =?UTF-8?q?refactor:=20=EC=9C=88=EB=8F=84=EC=9A=B0?= =?UTF-8?q?=20=EB=A1=9C=EC=BB=AC=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20?= =?UTF-8?q?=EC=9C=84=ED=95=B4=20githooks=20skip?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 1 + 1 file changed, 1 insertion(+) diff --git a/build.gradle b/build.gradle index c3efbc96..0310c302 100644 --- a/build.gradle +++ b/build.gradle @@ -128,6 +128,7 @@ tasks.register('updateGitHooks', Copy) { } tasks.register('makeGitHooksExecutable', Exec) { + onlyIf { !System.getProperty('os.name').toLowerCase(java.util.Locale.ROOT).contains('windows') } commandLine 'chmod', '+x', './.git/hooks/pre-commit' dependsOn updateGitHooks } From 793c3742bb823d9320c7931fda3660b11b577a82 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 13 Feb 2026 00:28:11 -0600 Subject: [PATCH 06/28] =?UTF-8?q?refactor:=20private=20key=20=EC=9D=B8?= =?UTF-8?q?=EC=BD=94=EB=94=A9=20=EB=A1=9C=EC=A7=81=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 22 +++++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 83a7ee35..6aa03210 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -58,13 +58,22 @@ jobs: target: /home/ubuntu/ strip_components: 1 + - name: Set OCI private key env (multiline) + run: | + echo "OCI_PRIVATE_KEY<> $GITHUB_ENV + echo "${{ secrets.OCI_PRIVATE_KEY }}" >> $GITHUB_ENV + echo "ENDOFKEY" >> $GITHUB_ENV + + - name: Prepare OCI key base64 for Deploy script + run: echo "OCI_PRIVATE_KEY_B64=$(echo -n "$OCI_PRIVATE_KEY" | base64 -w 0)" >> $GITHUB_ENV + - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu host: ${{ secrets.OCI_HOST }} key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} - envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app @@ -91,7 +100,6 @@ jobs: export OCI_TENANCY_ID=${{ secrets.OCI_TENANCY_ID }} export OCI_USER_ID=${{ secrets.OCI_USER_ID }} export OCI_FINGERPRINT=${{ secrets.OCI_FINGERPRINT }} - export OCI_PRIVATE_KEY=${{ secrets.OCI_PRIVATE_KEY }} export OCI_REGION=${{ secrets.OCI_REGION }} export OCI_PASSPHRASE=${{ secrets.OCI_PASSPHRASE }} export OCI_OBJECTSTORAGE_NAMESPACE=${{ secrets.OCI_OBJECTSTORAGE_NAMESPACE }} @@ -112,13 +120,17 @@ jobs: echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | sudo tee /home/ubuntu/secrets/oci_key.pem > /dev/null + sudo chmod 600 /home/ubuntu/secrets/oci_key.pem + export OCI_PRIVATE_KEY="$(cat /home/ubuntu/secrets/oci_key.pem)" + export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json - echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f dev-compose.yml up -d + sudo docker-compose -f dev-compose.yml up -d echo "Cleaning up dangling Docker images..." - docker image prune -f + sudo docker image prune -f From 63b255d9b1746d7f66e26af5a053d22323f0322c Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 13 Feb 2026 00:56:59 -0600 Subject: [PATCH 07/28] =?UTF-8?q?fix:=20ObjectStorage=20=EB=B9=88=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=EC=8B=9C=20=ED=99=98=EA=B2=BD=EB=B3=80?= =?UTF-8?q?=EC=88=98=EB=A5=BC=20=EA=B2=80=EC=A6=9D=ED=95=98=EA=B8=B0?= =?UTF-8?q?=EC=97=90=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EC=9A=A9=20Config=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clokey-api/build.gradle | 2 ++ .../java/org/clokey/TestObjectStorageConfig.java | 16 ++++++++++++++++ .../src/test/resources/application-test.yml | 13 +++++-------- .../org/clokey/config/ObjectStorageConfig.java | 2 ++ 4 files changed, 25 insertions(+), 8 deletions(-) create mode 100644 clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java diff --git a/clokey-api/build.gradle b/clokey-api/build.gradle index 24343d8c..01739638 100644 --- a/clokey-api/build.gradle +++ b/clokey-api/build.gradle @@ -21,4 +21,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-redis' implementation 'io.vanslog:spring-data-meilisearch:0.7.3' + + testImplementation 'com.oracle.oci.sdk:oci-java-sdk-objectstorage:3.47.0' } diff --git a/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java new file mode 100644 index 00000000..7e189fdd --- /dev/null +++ b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java @@ -0,0 +1,16 @@ +package org.clokey; + +import com.oracle.bmc.objectstorage.ObjectStorageClient; +import org.springframework.boot.autoconfigure.condition.ConditionalOnMissingBean; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class TestObjectStorageConfig { + + @Bean + @ConditionalOnMissingBean(ObjectStorageClient.class) + public ObjectStorageClient objectStorageClient() { + return org.mockito.Mockito.mock(ObjectStorageClient.class); + } +} diff --git a/clokey-api/src/test/resources/application-test.yml b/clokey-api/src/test/resources/application-test.yml index dbab1641..5a40dc4d 100644 --- a/clokey-api/src/test/resources/application-test.yml +++ b/clokey-api/src/test/resources/application-test.yml @@ -7,18 +7,15 @@ spring: redis: host: ${REDIS_HOST:localhost} port: ${REDIS_PORT:6379} - password: ${REDIS_PASSWORD} + password: ${REDIS_PASSWORD:} meilisearch: url: ${MEILISEARCH_ENDPOINT:http://localhost:7700} api-key: ${MEILISEARCH_KEY:dummy} -aws: - access-key-id: dummy - secret-access-key: dummy - region: dummy - s3: - bucket: dummy - endpoint: dummy +oci: + objectstorage: + namespace: test-namespace + bucket: test-bucket firebase: type: dummy diff --git a/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java index 706c9eb4..7a248904 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java +++ b/clokey-infrastructure/src/main/java/org/clokey/config/ObjectStorageConfig.java @@ -6,6 +6,7 @@ import java.nio.charset.StandardCharsets; import lombok.RequiredArgsConstructor; import org.clokey.properties.OciProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -24,6 +25,7 @@ private static String normalizePemPrivateKey(String privateKey) { } @Bean + @ConditionalOnProperty(name = "oci.region") public ObjectStorageClient objectStorageClient() { String rawKey = ociProperties.privateKey(); String privateKey = normalizePemPrivateKey(rawKey); From 0897ed267776861f99878d90c369563c6f15a4cf Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 13 Feb 2026 01:40:20 -0600 Subject: [PATCH 08/28] =?UTF-8?q?fix:=20fork=ED=95=9C=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=EC=97=90=EC=84=9C=EB=8F=84=20CI=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index e0ffa1f7..779d4071 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -57,4 +57,13 @@ jobs: env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} - run: ./gradlew test sonar --info --stacktrace + run: | + # 포크에서 온 PR이면 SonarCloud PR 연동 생략 (원본 레포에 PR이 없어서 조회 실패 방지) + if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then + ./gradlew test sonar --info --stacktrace \ + -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} \ + -Dsonar.pullrequest.branch=${{ github.head_ref }} \ + -Dsonar.pullrequest.base=${{ github.base_ref }} + else + ./gradlew test sonar --info --stacktrace + fi From 19d60f3c54c01c37e930b725ea50b7ae94f3f2ad Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 13 Feb 2026 02:02:06 -0600 Subject: [PATCH 09/28] =?UTF-8?q?fix:=20fork=ED=95=9C=20=EB=A0=88=ED=8F=AC?= =?UTF-8?q?=EC=A7=80=ED=86=A0=EB=A6=AC=EC=97=90=EC=84=9C=EB=8F=84=20CI=20?= =?UTF-8?q?=ED=85=8C=EC=8A=A4=ED=8A=B8=20=EB=90=98=EB=8F=84=EB=A1=9D=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/ci.yml | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 779d4071..8b4d500f 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -58,12 +58,14 @@ jobs: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} SONAR_TOKEN: ${{ secrets.SONAR_TOKEN }} run: | - # 포크에서 온 PR이면 SonarCloud PR 연동 생략 (원본 레포에 PR이 없어서 조회 실패 방지) + # 포크에서 온 PR: PR 연동 시 원본 레포에 PR이 없어 실패하므로 브랜치 분석만 수행 + # 같은 레포 PR: PR 데코레이션 사용 if [ "${{ github.event.pull_request.head.repo.full_name }}" = "${{ github.repository }}" ]; then ./gradlew test sonar --info --stacktrace \ -Dsonar.pullrequest.key=${{ github.event.pull_request.number }} \ -Dsonar.pullrequest.branch=${{ github.head_ref }} \ -Dsonar.pullrequest.base=${{ github.base_ref }} else - ./gradlew test sonar --info --stacktrace + ./gradlew test sonar --info --stacktrace \ + -Dsonar.branch.name=pr-${{ github.event.pull_request.number }} fi From 492780d92752159a4147a77dac0fdd1799b51b46 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 24 Feb 2026 21:21:31 -0600 Subject: [PATCH 10/28] =?UTF-8?q?fix:=20arm=20=ED=94=84=EB=A1=9C=EC=84=B8?= =?UTF-8?q?=EC=84=9C=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 6aa03210..4c8985cb 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -42,7 +42,7 @@ jobs: - name: Build & Push App Image run: | docker buildx build \ - --platform linux/amd64 \ + --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:dev-app \ diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 4d1b4552..0075cd12 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -52,7 +52,7 @@ jobs: - name: Build & Push App Image run: | docker buildx build \ - --platform linux/amd64 \ + --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:prod-app \ From 21fdda0ba1a78471e6e9f6230abc79c035ad4365 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 24 Feb 2026 21:37:57 -0600 Subject: [PATCH 11/28] =?UTF-8?q?fix:=20StorageUtil=20-=20deleteByUrl=20?= =?UTF-8?q?=EB=A9=94=EC=86=8C=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/org/clokey/util/StorageUtil.java | 28 ++++++++++++------- 1 file changed, 18 insertions(+), 10 deletions(-) diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java index 819a6157..75e4f4dd 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java +++ b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java @@ -121,18 +121,26 @@ public void deleteAllByUrls(List urls) { } public void deleteByUrl(String url) { - String namespace = storageProperties.namespace(); - String bucket = storageProperties.bucket(); - String objectKey = extractObjectKey(url); + if (url == null || url.isBlank()) { + log.info("deleteByUrl skipped: url is null or blank"); + return; + } + try { + String namespace = storageProperties.namespace(); + String bucket = storageProperties.bucket(); + String objectKey = extractObjectKey(url); - DeleteObjectRequest request = - DeleteObjectRequest.builder() - .namespaceName(namespace) - .bucketName(bucket) - .objectName(objectKey) - .build(); + DeleteObjectRequest request = + DeleteObjectRequest.builder() + .namespaceName(namespace) + .bucketName(bucket) + .objectName(objectKey) + .build(); - objectStorageClient.deleteObject(request); + objectStorageClient.deleteObject(request); + } catch (Exception e) { + log.error("Failed to delete object: {}", url, e); + } } private String extractObjectKey(String url) { From 152f3e1a05d81c91c3351baddc9d7d9c25104fb1 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 24 Feb 2026 21:46:21 -0600 Subject: [PATCH 12/28] =?UTF-8?q?fix:=20amd64=20=EB=9F=AC=EB=84=88=20?= =?UTF-8?q?=EC=9C=84=EC=97=90=EC=84=9C=20linux/arm64=20=EC=9D=B4=EB=AF=B8?= =?UTF-8?q?=EC=A7=80=20=EB=B9=8C=EB=93=9C=20=EC=9C=84=ED=95=B4=20QEMU=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 6 ++++++ .github/workflows/prod-cd.yml | 3 +++ 2 files changed, 9 insertions(+) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 4c8985cb..f674c964 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -39,6 +39,12 @@ jobs: username: ${{ secrets.DOCKERHUB_USERNAME }} password: ${{ secrets.DOCKERHUB_TOKEN }} + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + - name: Build & Push App Image run: | docker buildx build \ diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 0075cd12..b88fed8b 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -33,6 +33,9 @@ jobs: - name: Grant gradlew permission run: chmod +x ./gradlew + - name: Set up QEMU + uses: docker/setup-qemu-action@v3 + - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 with: From 506d193bb846fe8b957c72839770fec462ccffbd Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 24 Feb 2026 22:56:24 -0600 Subject: [PATCH 13/28] =?UTF-8?q?fix:=20cd=20=EC=9B=8C=ED=81=AC=ED=94=8C?= =?UTF-8?q?=EB=A1=9C=EC=9A=B0=EC=9D=98=20sudo=EB=A5=BC=20=EC=82=AD?= =?UTF-8?q?=EC=A0=9C=ED=95=98=EC=97=AC=EC=84=9C=20=ED=99=98=EA=B2=BD?= =?UTF-8?q?=EB=B3=80=EC=88=98=EA=B0=80=20=EB=84=98=EC=96=B4=EA=B0=80?= =?UTF-8?q?=EC=A7=80=20=EC=95=8A=EB=8D=98=20=EC=83=81=ED=99=A9=20=EB=B0=A9?= =?UTF-8?q?=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 16 ++++++++-------- .github/workflows/prod-cd.yml | 6 +++--- 2 files changed, 11 insertions(+), 11 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index f674c964..45aaf382 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -122,21 +122,21 @@ jobs: export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} - sudo mkdir -p /home/ubuntu/secrets - echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null - sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + chmod 600 /home/ubuntu/secrets/firebase-sa.json - echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | sudo tee /home/ubuntu/secrets/oci_key.pem > /dev/null - sudo chmod 600 /home/ubuntu/secrets/oci_key.pem + echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | tee /home/ubuntu/secrets/oci_key.pem > /dev/null + chmod 600 /home/ubuntu/secrets/oci_key.pem export OCI_PRIVATE_KEY="$(cat /home/ubuntu/secrets/oci_key.pem)" export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json - echo "${{ secrets.DOCKERHUB_TOKEN }}" | sudo docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin + echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - sudo docker-compose -f dev-compose.yml up -d + docker-compose -f dev-compose.yml up -d echo "Cleaning up dangling Docker images..." - sudo docker image prune -f + docker image prune -f diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index b88fed8b..e1e60599 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -121,9 +121,9 @@ jobs: export STYLE_INFERENCE_PATH=${{ secrets.STYLE_INFERENCE_PATH }} export HISTORY_CLOTH_DETECT_PATH=${{ secrets.HISTORY_CLOTH_DETECT_PATH }} - sudo mkdir -p /home/ubuntu/secrets - echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | sudo tee /home/ubuntu/secrets/firebase-sa.json > /dev/null - sudo chmod 600 /home/ubuntu/secrets/firebase-sa.json + mkdir -p /home/ubuntu/secrets + echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | tee /home/ubuntu/secrets/firebase-sa.json > /dev/null + chmod 600 /home/ubuntu/secrets/firebase-sa.json export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json From 3c5918c5a83d57822251946f03a0dc5462627f40 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 4 Mar 2026 01:57:29 -0600 Subject: [PATCH 14/28] =?UTF-8?q?merge:=20develop=20=EB=B8=8C=EB=9E=9C?= =?UTF-8?q?=EC=B9=98=20=EB=A8=B8=EC=A7=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/CODEOWNERS | 2 +- clokey-api/Dockerfile | 3 +- .../auth/handler/OidcLoginFailureHandler.java | 48 +- .../auth/handler/OidcLoginSuccessHandler.java | 61 ++- .../auth/service/CustomOAuth2UserService.java | 129 ++++-- .../clokey/domain/auth/util/UniqueUtil.java | 19 +- .../cloth/controller/ClothController.java | 2 +- .../dto/request/TestPresignedUrlRequest.java | 12 + .../repository/ClothRepositoryCustom.java | 2 +- .../cloth/repository/ClothRepositoryImpl.java | 22 +- .../cloth/service/ClothAiServiceImpl.java | 430 ++++++++++++------ .../cloth/service/ClothServiceImpl.java | 3 +- .../dto/response/CommentListResponse.java | 3 +- .../dto/response/ReplyListResponse.java | 3 +- .../repository/CommentRepositoryImpl.java | 13 +- .../comment/service/CommentServiceImpl.java | 6 +- .../controller/CoordinateController.java | 45 +- .../CoordinateDetailsListResponse.java | 1 + .../DailyCoordinateClothResponse.java | 20 - .../DailyCoordinatePreviewResponse.java | 17 + .../response/FavoriteCoordinateResponse.java | 8 +- .../exception/CoordinateErrorCode.java | 5 +- .../repository/CoordinateRepositoryImpl.java | 1 + .../coordinate/service/CoordinateService.java | 6 +- .../service/CoordinateServiceImpl.java | 93 +++- .../feed/repository/FeedQueryRepository.java | 21 + .../history/controller/HistoryController.java | 2 +- .../repository/HistoryHashtagRepository.java | 7 +- .../repository/HistoryStyleRepository.java | 6 + .../history/service/HistoryServiceImpl.java | 24 +- .../domain/like/event/NewLikeEvent.java | 9 + .../domain/like/service/LikeServiceImpl.java | 9 + .../repository/LookBookRepositoryImpl.java | 16 +- .../lookbook/service/LookBookServiceImpl.java | 4 +- .../batch/InactiveMemberDeletionBatch.java | 7 +- .../member/controller/MemberController.java | 4 +- .../dto/request/DuplicatedIdCheckRequest.java | 13 - .../DuplicatedNicknameCheckRequest.java | 15 + .../dto/request/ProfileUpdateRequest.java | 8 +- .../domain/member/service/MemberService.java | 4 +- .../member/service/MemberServiceImpl.java | 16 +- .../response/NotificationListResponse.java | 15 +- .../event/NotificationEventListener.java | 15 + .../CodiveNotificationRepositoryImpl.java | 13 +- .../service/CodiveNotificationService.java | 3 + .../CodiveNotificationServiceImpl.java | 134 ++++-- .../search/controller/SearchController.java | 4 +- .../search/document/HistoryDocument.java | 5 +- .../repository/MeiliSearchRepositoryImpl.java | 12 +- .../service/SearchDocumentServiceImpl.java | 21 +- .../search/service/SearchServiceImpl.java | 7 +- .../config/security/SecurityConfig.java | 67 ++- ...areOAuth2AuthorizationRequestResolver.java | 76 ++++ .../domain/auth/service/AuthServiceTest.java | 4 +- .../cloth/service/ClothServiceTest.java | 7 +- .../controller/CommentControllerTest.java | 65 ++- .../comment/service/CommentServiceTest.java | 133 +++++- .../controller/CoordinateControllerTest.java | 140 ++++-- .../service/CoordinateServiceImplTest.java | 353 +++++++++++--- .../domain/feed/service/FeedServiceTest.java | 123 ++++- .../controller/HistoryControllerTest.java | 8 +- .../service/HistoryServiceImplTest.java | 26 ++ .../lookbook/service/LookBookServiceTest.java | 3 + .../controller/MemberControllerTest.java | 149 +++++- .../member/service/MemberServiceTest.java | 28 +- .../NotificationControllerTest.java | 21 +- .../service/NotificationServiceTest.java | 90 +++- .../org/clokey/response/BaseResponse.java | 10 +- .../common/config/JpaAuditingConfig.java | 17 +- .../java/org/clokey/member/entity/Member.java | 5 +- .../entity/CodiveNotification.java | 11 +- .../notification/enums/NotificationType.java | 10 + .../notification/enums/RedirectType.java | 1 + ...tification_type_to_codive_notification.sql | 17 + .../java/org/clokey/util/WebClientUtil.java | 58 ++- 75 files changed, 2180 insertions(+), 590 deletions(-) create mode 100644 clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/TestPresignedUrlRequest.java delete mode 100644 clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinateClothResponse.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinatePreviewResponse.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java delete mode 100644 clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedIdCheckRequest.java create mode 100644 clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java create mode 100644 clokey-api/src/main/java/org/clokey/global/security/AppleAwareOAuth2AuthorizationRequestResolver.java create mode 100644 clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java create mode 100644 clokey-domain/src/main/resources/db/migration/V5__add_notification_type_to_codive_notification.sql diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index e27f4ff6..e84714d1 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -1 +1 @@ -* @yongjun0511 @Ssamssamukja @2ghrms @juuuuone @dev2yup +* @Ssamssamukja @2ghrms @juuuuone @dev2yup diff --git a/clokey-api/Dockerfile b/clokey-api/Dockerfile index 359aaebc..c8c99a3d 100644 --- a/clokey-api/Dockerfile +++ b/clokey-api/Dockerfile @@ -5,6 +5,7 @@ RUN gradle :clokey-api:build -x test --no-daemon FROM eclipse-temurin:21-jdk-jammy WORKDIR /app +ENV TZ=Asia/Seoul COPY --from=builder /app/clokey-api/build/libs/*.jar app.jar EXPOSE 8080 -ENTRYPOINT ["java", "-jar", "app.jar"] +ENTRYPOINT ["java", "-Duser.timezone=Asia/Seoul", "-jar", "app.jar"] diff --git a/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginFailureHandler.java b/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginFailureHandler.java index 512e24c1..d57dd244 100644 --- a/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginFailureHandler.java +++ b/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginFailureHandler.java @@ -5,12 +5,15 @@ import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.clokey.exception.GlobalBaseErrorCode; import org.clokey.response.BaseResponse; import org.springframework.security.core.AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class OidcLoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { @@ -24,15 +27,58 @@ public void onAuthenticationFailure( AuthenticationException exception) throws IOException { + String requestURI = request.getRequestURI(); + String queryString = request.getQueryString(); + String errorCode = null; + String errorDescription = null; + + log.error( + "[OIDC Failure] 로그인 실패 핸들러 시작 - RequestURI: {}, QueryString: {}", + requestURI, + queryString); + log.error( + "[OIDC Failure] 예외 정보 - ExceptionType: {}, Message: {}", + exception.getClass().getSimpleName(), + exception.getMessage()); + + if (exception instanceof OAuth2AuthenticationException oauth2Exception) { + errorCode = oauth2Exception.getError().getErrorCode(); + errorDescription = oauth2Exception.getError().getDescription(); + log.error( + "[OIDC Failure] OAuth2 예외 상세 - ErrorCode: {}, Description: {}", + errorCode, + errorDescription); + + if (oauth2Exception.getCause() != null) { + log.error( + "[OIDC Failure] 원인 예외 - CauseType: {}, CauseMessage: {}", + oauth2Exception.getCause().getClass().getSimpleName(), + oauth2Exception.getCause().getMessage(), + oauth2Exception.getCause()); + } + } + + // 요청 파라미터 로깅 + request.getParameterMap() + .forEach( + (key, values) -> + log.error( + "[OIDC Failure] Request Parameter - {}: {}", + key, + String.join(", ", values))); + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); response.setContentType("application/json"); response.setCharacterEncoding("UTF-8"); + String errorMessage = errorDescription != null ? errorDescription : exception.getMessage(); BaseResponse failureResponse = BaseResponse.onFailure( - GlobalBaseErrorCode.UNAUTHORIZED.getCode(), exception.getMessage(), null); + GlobalBaseErrorCode.UNAUTHORIZED.getCode(), errorMessage, null); String json = objectMapper.writeValueAsString(failureResponse); response.getWriter().write(json); + + log.error("[OIDC Failure] 실패 응답 전송 완료 - ErrorCode: {}", errorCode); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginSuccessHandler.java b/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginSuccessHandler.java index a8972682..c8736d58 100644 --- a/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginSuccessHandler.java +++ b/clokey-api/src/main/java/org/clokey/domain/auth/handler/OidcLoginSuccessHandler.java @@ -34,31 +34,52 @@ public void onAuthenticationSuccess( HttpServletRequest request, HttpServletResponse response, Authentication authentication) throws IOException { - CustomPrincipal principal = (CustomPrincipal) authentication.getPrincipal(); - Member member = principal.getMember(); + log.info("[OIDC Success] 로그인 성공 핸들러 시작 - RequestURI: {}", request.getRequestURI()); - String accessToken = - jwtTokenService.createAccessToken(member.getId(), member.getMemberRole()); - String refreshToken = jwtTokenService.createRefreshToken(member.getId()); + try { + CustomPrincipal principal = (CustomPrincipal) authentication.getPrincipal(); + Member member = principal.getMember(); + log.info( + "[OIDC Success] 인증 정보 추출 완료 - MemberId: {}, Email: {}", + member.getId(), + member.getEmail()); - TokenResponse tokenResponse = TokenResponse.of(accessToken, refreshToken); - BaseResponse jsonResponse = - BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, tokenResponse); - String jsonData = objectMapper.writeValueAsString(jsonResponse); + log.info("[OIDC Success] JWT 토큰 생성 시작 - MemberId: {}", member.getId()); + String accessToken = + jwtTokenService.createAccessToken(member.getId(), member.getMemberRole()); + String refreshToken = jwtTokenService.createRefreshToken(member.getId()); + log.info("[OIDC Success] JWT 토큰 생성 완료 - MemberId: {}", member.getId()); - String redirectUrl = - UriComponentsBuilder.fromUriString(redirectScheme + "://oauth/callback") - .queryParam("accessToken", accessToken) - .queryParam("refreshToken", refreshToken) - .build() - .toUriString(); + TokenResponse tokenResponse = TokenResponse.of(accessToken, refreshToken); + BaseResponse jsonResponse = + BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, tokenResponse); + String jsonData = objectMapper.writeValueAsString(jsonResponse); - String html = buildHtmlPage(jsonData, redirectUrl); + String redirectUrl = + UriComponentsBuilder.fromUriString(redirectScheme + "://oauth/callback") + .queryParam("accessToken", accessToken) + .queryParam("refreshToken", refreshToken) + .build() + .toUriString(); - response.setContentType("text/html; charset=UTF-8"); - response.setCharacterEncoding("UTF-8"); - response.getWriter().write(html); - response.getWriter().flush(); + log.info("[OIDC Success] 리다이렉트 URL 생성 완료 - RedirectUrl: {}", redirectUrl); + + String html = buildHtmlPage(jsonData, redirectUrl); + + response.setContentType("text/html; charset=UTF-8"); + response.setCharacterEncoding("UTF-8"); + response.getWriter().write(html); + response.getWriter().flush(); + + log.info("[OIDC Success] 로그인 성공 처리 완료 - MemberId: {}", member.getId()); + } catch (Exception e) { + log.error( + "[OIDC Success] 성공 핸들러 처리 중 오류 발생 - Error: {}, Message: {}", + e.getClass().getSimpleName(), + e.getMessage(), + e); + throw e; + } } // NOTE : 개발자들을 위해서 웹에서 보여주면서도 딥링크 리다이랙트를 동시에 보내줌. diff --git a/clokey-api/src/main/java/org/clokey/domain/auth/service/CustomOAuth2UserService.java b/clokey-api/src/main/java/org/clokey/domain/auth/service/CustomOAuth2UserService.java index 890d7476..106d9a67 100644 --- a/clokey-api/src/main/java/org/clokey/domain/auth/service/CustomOAuth2UserService.java +++ b/clokey-api/src/main/java/org/clokey/domain/auth/service/CustomOAuth2UserService.java @@ -13,9 +13,12 @@ import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest; import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService; import org.springframework.security.oauth2.core.OAuth2AuthenticationException; +import org.springframework.security.oauth2.core.OAuth2Error; +import org.springframework.security.oauth2.core.OAuth2ErrorCodes; import org.springframework.security.oauth2.core.oidc.user.OidcUser; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; @Slf4j @Service @@ -29,33 +32,105 @@ public class CustomOAuth2UserService extends OidcUserService { @Override @Transactional public OidcUser loadUser(OidcUserRequest userRequest) throws OAuth2AuthenticationException { - OidcUser oidcUser = super.loadUser(userRequest); - String provider = userRequest.getClientRegistration().getRegistrationId(); - String oauthId = oidcUser.getName(); - String email = oidcUser.getAttribute("email"); - - OauthProvider oauthProvider = OauthProvider.valueOf(provider.toUpperCase()); - OauthInfo oauthInfo = OauthInfo.createOauthInfo(oauthId, oauthProvider); - - Member member = - memberRepository - .findByOauthInfo(oauthInfo) - .orElseGet( - () -> { - Member newMember = - Member.createMember( - email, - uniqueUtil.generateRandomNickname(), - oauthInfo); - memberRepository.save(newMember); - eventPublisher.publishEvent( - MeiliSearchSyncEvent.of( - MeiliSearchSyncEvent.EntityType.MEMBER, - newMember.getId())); - return newMember; - }); - - return new CustomPrincipal(member, oidcUser.getAttributes(), oidcUser.getIdToken()); + String clientId = userRequest.getClientRegistration().getClientId(); + String clientAuthenticationMethod = + userRequest.getClientRegistration().getClientAuthenticationMethod().getValue(); + + log.info( + "[OIDC] 사용자 로드 시작 - Provider: {}, ClientId: {}, AuthMethod: {}", + provider, + clientId, + clientAuthenticationMethod); + + try { + log.info("[OIDC] Access Token 교환 시도 - Provider: {}", provider); + OidcUser oidcUser = super.loadUser(userRequest); + log.info("[OIDC] Access Token 교환 성공 - Provider: {}", provider); + + String oauthId = oidcUser.getName(); + String email = oidcUser.getAttribute("email"); + log.info( + "[OIDC] 사용자 정보 추출 - Provider: {}, OAuthId: {}, Email: {}", + provider, + oauthId, + email); + + OauthProvider oauthProvider = OauthProvider.valueOf(provider.toUpperCase()); + OauthInfo oauthInfo = OauthInfo.createOauthInfo(oauthId, oauthProvider); + String resolvedEmail = resolveEmail(email, oauthId, oauthProvider); + + log.info("[OIDC] 회원 조회 시작 - Provider: {}, OAuthId: {}", provider, oauthId); + Member member = + memberRepository + .findByOauthInfo(oauthInfo) + .orElseGet( + () -> { + log.info( + "[OIDC] 신규 회원 생성 - Provider: {}, Email: {}", + provider, + email); + Member newMember = + Member.createMember( + resolvedEmail, + uniqueUtil.generateRandomNickname(), + oauthInfo); + memberRepository.save(newMember); + eventPublisher.publishEvent( + MeiliSearchSyncEvent.of( + MeiliSearchSyncEvent.EntityType.MEMBER, + newMember.getId())); + log.info( + "[OIDC] 신규 회원 생성 완료 - MemberId: {}", + newMember.getId()); + return newMember; + }); + + log.info("[OIDC] 사용자 로드 완료 - Provider: {}, MemberId: {}", provider, member.getId()); + return new CustomPrincipal(member, oidcUser.getAttributes(), oidcUser.getIdToken()); + } catch (OAuth2AuthenticationException e) { + // OAuth2AuthenticationException은 그대로 전달 (이미 OAuth2Error 포함) + log.error( + "[OIDC] 사용자 로드 실패 - Provider: {}, ErrorCode: {}, Description: {}", + provider, + e.getError() != null ? e.getError().getErrorCode() : "UNKNOWN", + e.getError() != null ? e.getError().getDescription() : e.getMessage(), + e); + throw e; + } catch (Exception e) { + // 일반 Exception을 OAuth2AuthenticationException으로 변환 + log.error( + "[OIDC] 예상치 못한 오류 발생 - Provider: {}, Error: {}, Message: {}", + provider, + e.getClass().getSimpleName(), + e.getMessage(), + e); + + OAuth2Error oauth2Error = + new OAuth2Error( + OAuth2ErrorCodes.SERVER_ERROR, + "사용자 로드 중 오류 발생: " + e.getMessage(), + null); + throw new OAuth2AuthenticationException(oauth2Error, e); + } + } + + private String resolveEmail(String email, String oauthId, OauthProvider oauthProvider) { + if (StringUtils.hasText(email)) { + return email; + } + + String safeOauthId = oauthId.replaceAll("[^a-zA-Z0-9._-]", "_"); + if (safeOauthId.length() > 80) { + safeOauthId = safeOauthId.substring(0, 80); + } + String fallbackEmail = + oauthProvider.name().toLowerCase() + "_" + safeOauthId + "@noemail.local"; + log.warn( + "[OIDC] Email claim missing. Fallback email is used - Provider: {}, OAuthId: {}, FallbackEmail: {}", + oauthProvider, + oauthId, + fallbackEmail); + return fallbackEmail; } } diff --git a/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java b/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java index c593e2dd..845bdec5 100644 --- a/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java +++ b/clokey-api/src/main/java/org/clokey/domain/auth/util/UniqueUtil.java @@ -7,6 +7,7 @@ @Component public class UniqueUtil { + private static final int MAX_NICKNAME_LENGTH = 20; private static final String[] PREFIX_NAMES = {"미니멀한", "모던한", "케주얼한", "스트릿한"}; private static final String[] CLOTHING_CATEGORIES = { @@ -22,9 +23,23 @@ public String generateRandomNickname() { String prefix = PREFIX_NAMES[random.nextInt(PREFIX_NAMES.length)]; String category = CLOTHING_CATEGORIES[random.nextInt(CLOTHING_CATEGORIES.length)]; + String nicknamePrefix = prefix + "_" + category + "_"; - String uuidPart = UUID.randomUUID().toString().replace("-", "").substring(0, 16); + int suffixLength = MAX_NICKNAME_LENGTH - nicknamePrefix.length(); + if (suffixLength <= 0) { + return nicknamePrefix.substring(0, MAX_NICKNAME_LENGTH); + } - return prefix + "-" + category + "-" + uuidPart; + return nicknamePrefix + generateSuffix(suffixLength); + } + + private String generateSuffix(int length) { + StringBuilder suffix = new StringBuilder(length); + + while (suffix.length() < length) { + suffix.append(UUID.randomUUID().toString().replace("-", "")); + } + + return suffix.substring(0, length); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java index b80acd4e..305e2cde 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/controller/ClothController.java @@ -45,7 +45,7 @@ public BaseResponse> recommendCategory @Parameter(description = "이전 페이지의 옷ID (첫 요청 시 생략)") @RequestParam(required = false) Long lastClothId, @Parameter(description = "페이지당 조회할 옷 수") @RequestParam @PageSize Integer size, - @Parameter(description = "옷 카테고리 ID") @RequestParam Long categoryId, + @Parameter(description = "상위 카테고리 ID") @RequestParam Long categoryId, @Parameter(description = "요청 계절") @RequestParam Season season) { SliceResponse response = clothService.recommendCategoryClothes(lastClothId, size, categoryId, season); diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/TestPresignedUrlRequest.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/TestPresignedUrlRequest.java new file mode 100644 index 00000000..f5342ad2 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/request/TestPresignedUrlRequest.java @@ -0,0 +1,12 @@ +package org.clokey.domain.cloth.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; + +@Schema(description = "테스트용 presignedUrl 발급 요청 (로컬 환경 전용)") +public record TestPresignedUrlRequest( + @NotNull(message = "발급할 presignedUrl 개수는 필수입니다.") + @Min(value = 1, message = "발급할 presignedUrl 개수는 1개 이상이어야 합니다.") + @Schema(description = "발급할 presignedUrl 개수", example = "5") + Integer count) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryCustom.java b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryCustom.java index d2cd38e5..274b3f02 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryCustom.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryCustom.java @@ -10,7 +10,7 @@ public interface ClothRepositoryCustom { Slice findAllMemberRecommendClothesByCategoryAndSeason( - Long lastClothId, int size, Long categoryId, Long memberId, Season season); + Long lastClothId, int size, List categoryIds, Long memberId, Season season); Slice findAllMemberClothesByCategoriesAndSeasons( Long lastClothId, diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryImpl.java index 7b6d66c9..f9d529a4 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/repository/ClothRepositoryImpl.java @@ -30,15 +30,27 @@ public class ClothRepositoryImpl implements ClothRepositoryCustom { @Override public Slice findAllMemberRecommendClothesByCategoryAndSeason( - Long lastClothId, int size, Long categoryId, Long memberId, Season season) { + Long lastClothId, int size, List categoryIds, Long memberId, Season season) { + if (categoryIds == null || categoryIds.isEmpty()) { + return new SliceImpl<>(List.of(), PageRequest.of(0, size), false); + } Season nextSeason = season.next(); Season previousSeason = season.previous(); Season oppositeSeason = season.next().next(); + List targetSeasons = List.of(season, nextSeason, previousSeason, oppositeSeason); // 우선 순위에 맞게 페이징 합니다. // - Category는 고정입니다. // - 계절은 요청한 계절에 가까운 순서대로 페이징을 진행합니다. + // @ElementCollection에서는 exists 서브쿼리가 더 안정적입니다. + BooleanExpression seasonCondition = + cloth.seasons + .contains(season) + .or(cloth.seasons.contains(nextSeason)) + .or(cloth.seasons.contains(previousSeason)) + .or(cloth.seasons.contains(oppositeSeason)); + NumberExpression seasonPriority = new CaseBuilder() .when(cloth.seasons.contains(season)) @@ -56,13 +68,9 @@ public Slice findAllMemberRecommendClothesByCategory queryFactory .selectFrom(cloth) .where( - cloth.category.id.eq(categoryId), + cloth.category.id.in(categoryIds), cloth.member.id.eq(memberId), - cloth.seasons - .contains(season) - .or(cloth.seasons.contains(nextSeason)) - .or(cloth.seasons.contains(previousSeason)) - .or(cloth.seasons.contains(oppositeSeason))) + seasonCondition) .orderBy(seasonPriority.asc(), cloth.id.asc()) .fetch(); diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java index dd98ee4e..07b434dd 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -3,8 +3,10 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.clokey.category.entity.Category; import org.clokey.cloth.enums.Season; import org.clokey.domain.category.exception.CategoryErrorCode; @@ -43,8 +45,11 @@ // FIXME: 현재는 Tomcat Thread Pool을 점유하고 있는 비효율적인 구조이기 때문에 나중에 비동기 처리를 통해 트래픽이 생길 경우 최적화가 필요합니다. @Service @RequiredArgsConstructor +@Slf4j public class ClothAiServiceImpl implements ClothAiService { + private static final long SLOW_REQUEST_THRESHOLD_MS = 3000L; + private final MemberUtil memberUtil; private final CategoryRepository categoryRepository; private final StorageUtil storageUtil; @@ -74,94 +79,129 @@ public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( @Override public ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request) { final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final List clothImageUrls = request.clothImageUrls(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long presignMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrls(clothImageUrls); - - // AI Server에게 N개의 사진을 전처리한 후 업로드할 수 있는 presignedUrl을 넘겨줍니다. - List presignedUrls = - createPresignedUrls(currentMember.getId(), clothImageUrls.size()); - - ClothInfoExtractAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.clothInferencePath(), - new ClothInfoExtractAiRequestDTO(clothImageUrls, presignedUrls), - ClothInfoExtractAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (aiResponse == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { - throw new BaseCustomException(mapAiErrorCode(aiResponse.errorCode())); - } - - if (aiResponse.result() == null || aiResponse.result().isEmpty()) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } - - if (aiResponse.result().size() != clothImageUrls.size()) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_RESULT_MISMATCH); - } - - List resultItems = aiResponse.result(); - List payloads = - new java.util.ArrayList<>(resultItems.size()); - - Set categoryIds = - resultItems.stream() - .map(ClothInfoExtractAiResponseDTO.ResultItem::categories) - .filter(categories -> categories != null && !categories.isEmpty()) - .map(categories -> categories.get(0).id()) - .collect(Collectors.toSet()); - - Map categoryMap = - categoryRepository.findAllByIdWithParent(categoryIds).stream() - .collect(Collectors.toMap(Category::getId, c -> c)); - - for (int i = 0; i < resultItems.size(); i++) { - ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); - String clothImageUrl = resultItem.uploadedUrl(); + long phaseStartedAtNs = System.nanoTime(); + validateImageUrls(clothImageUrls); + validationMs = elapsedMillis(phaseStartedAtNs); + + // AI Server에게 N개의 사진을 전처리한 후 업로드할 수 있는 presignedUrl을 넘겨줍니다. + phaseStartedAtNs = System.nanoTime(); + List presignedUrls = createPresignedUrls(memberId, clothImageUrls.size()); + presignMs = elapsedMillis(phaseStartedAtNs); + + ClothInfoExtractAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothInferencePath(), + new ClothInfoExtractAiRequestDTO( + clothImageUrls, presignedUrls), + ClothInfoExtractAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - List categories = resultItem.categories(); - if (categories == null || categories.isEmpty()) { - throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + if (aiResponse == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); } - ClothInfoExtractAiResponseDTO.CategoryItem categoryItem = categories.get(0); - Category category = categoryMap.get(categoryItem.id()); - if (category == null) { - throw new BaseCustomException(CategoryErrorCode.CATEGORY_NOT_FOUND); + + if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { + ClothAiErrorCode mappedErrorCode = mapAiErrorCode(aiResponse.errorCode()); + throw new BaseCustomException(mappedErrorCode); } - Category parentCategory = category.getParent(); - List seasonItems = resultItem.seasons(); - if (seasonItems == null || seasonItems.isEmpty()) { - throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + if (aiResponse.result() == null || aiResponse.result().isEmpty()) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); } - List seasons = new java.util.ArrayList<>(seasonItems.size()); - for (ClothInfoExtractAiResponseDTO.SeasonItem seasonItem : seasonItems) { - seasons.add(convertSeasonNameToEnum(seasonItem.name())); + if (aiResponse.result().size() != clothImageUrls.size()) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_RESULT_MISMATCH); } - payloads.add( - new ClothInfoExtractResponse.Payload( - clothImageUrl, - seasons, - parentCategory != null ? parentCategory.getId() : null, - parentCategory != null ? parentCategory.getName() : null, - category.getId(), - category.getName())); + phaseStartedAtNs = System.nanoTime(); + List resultItems = aiResponse.result(); + List payloads = + new java.util.ArrayList<>(resultItems.size()); + + Set categoryIds = + resultItems.stream() + .map(ClothInfoExtractAiResponseDTO.ResultItem::categories) + .filter(categories -> categories != null && !categories.isEmpty()) + .map(categories -> categories.get(0).id()) + .collect(Collectors.toSet()); + + Map categoryMap = + categoryRepository.findAllByIdWithParent(categoryIds).stream() + .collect(Collectors.toMap(Category::getId, c -> c)); + + for (int i = 0; i < resultItems.size(); i++) { + ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); + String clothImageUrl = resultItem.uploadedUrl(); + + List categories = + resultItem.categories(); + if (categories == null || categories.isEmpty()) { + throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + } + ClothInfoExtractAiResponseDTO.CategoryItem categoryItem = categories.get(0); + Category category = categoryMap.get(categoryItem.id()); + if (category == null) { + throw new BaseCustomException(CategoryErrorCode.CATEGORY_NOT_FOUND); + } + Category parentCategory = category.getParent(); + + List seasonItems = resultItem.seasons(); + if (seasonItems == null || seasonItems.isEmpty()) { + throw new BaseCustomException(ClothErrorCode.ClOTH_NOT_FOUND); + } + + List seasons = new java.util.ArrayList<>(seasonItems.size()); + for (ClothInfoExtractAiResponseDTO.SeasonItem seasonItem : seasonItems) { + seasons.add(convertSeasonNameToEnum(seasonItem.name())); + } + + payloads.add( + new ClothInfoExtractResponse.Payload( + clothImageUrl, + seasons, + parentCategory != null ? parentCategory.getId() : null, + parentCategory != null ? parentCategory.getName() : null, + category.getId(), + category.getName())); + } + postProcessMs = elapsedMillis(phaseStartedAtNs); + + ClothInfoExtractResponse response = ClothInfoExtractResponse.of(payloads); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "extractClothInfo", + memberId, + clothImageUrls.size(), + elapsedMillis(startedAtNs), + validationMs, + presignMs, + aiCallMs, + postProcessMs, + errorCode); } - - return ClothInfoExtractResponse.of(payloads); } private Season convertSeasonNameToEnum(String seasonName) { @@ -176,89 +216,157 @@ private Season convertSeasonNameToEnum(String seasonName) { @Override public HistoryStyleInferenceResponse inferHistoryStyle(HistoryStyleInferenceRequest request) { + final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final String historyImageUrl = request.historyImageUrl(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrl(historyImageUrl); - - HistoryStyleInferenceAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.styleInferencePath(), - new HistoryStyleInferenceAiRequestDTO(historyImageUrl), - HistoryStyleInferenceAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } + long phaseStartedAtNs = System.nanoTime(); + validateImageUrl(historyImageUrl); + validationMs = elapsedMillis(phaseStartedAtNs); + + HistoryStyleInferenceAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.styleInferencePath(), + new HistoryStyleInferenceAiRequestDTO(historyImageUrl), + HistoryStyleInferenceAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - if (aiResponse.result() == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } + if (aiResponse == null || aiResponse.result() == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); + } - HistoryStyleInferenceAiResponseDTO.Result result = aiResponse.result(); + phaseStartedAtNs = System.nanoTime(); + HistoryStyleInferenceAiResponseDTO.Result result = aiResponse.result(); - if (result.situations() == null || result.situations().isEmpty()) { - throw new BaseCustomException(SituationErrorCode.SITUATION_NOT_FOUND); - } - HistoryStyleInferenceAiResponseDTO.SituationItem situationItem = result.situations().get(0); - - if (result.styles() == null || result.styles().isEmpty()) { - throw new BaseCustomException(StyleErrorCode.STYLE_NOT_FOUND); - } + if (result.situations() == null || result.situations().isEmpty()) { + throw new BaseCustomException(SituationErrorCode.SITUATION_NOT_FOUND); + } + HistoryStyleInferenceAiResponseDTO.SituationItem situationItem = + result.situations().get(0); - List styles = - result.styles().stream() - .map( - style -> - new HistoryStyleInferenceResponse.StylePayload( - style.id(), style.name())) - .toList(); + if (result.styles() == null || result.styles().isEmpty()) { + throw new BaseCustomException(StyleErrorCode.STYLE_NOT_FOUND); + } - return HistoryStyleInferenceResponse.of(situationItem.id(), situationItem.name(), styles); + List styles = + result.styles().stream() + .map( + style -> + new HistoryStyleInferenceResponse.StylePayload( + style.id(), style.name())) + .toList(); + postProcessMs = elapsedMillis(phaseStartedAtNs); + + HistoryStyleInferenceResponse response = + HistoryStyleInferenceResponse.of( + situationItem.id(), situationItem.name(), styles); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "inferHistoryStyle", + memberId, + 1, + elapsedMillis(startedAtNs), + validationMs, + 0L, + aiCallMs, + postProcessMs, + errorCode); + } } @Override public ClothDetectResponse detectClothes(ClothDetectRequest request) { final Member currentMember = memberUtil.getCurrentMember(); + final Long memberId = currentMember.getId(); final String imageUrl = request.imageUrl(); + final long startedAtNs = System.nanoTime(); + long validationMs = 0L; + long presignMs = 0L; + long aiCallMs = 0L; + long postProcessMs = 0L; + String errorCode = null; - validateImageUrl(imageUrl); - - List presignedUrls = createPresignedUrls(currentMember.getId(), 10); - - ClothDetectAiResponseDTO aiResponse; try { - aiResponse = - webClientUtil - .postToAiServer( - webClientProperties.clothDetectPath(), - new ClothDetectAiRequestDTO(imageUrl, presignedUrls), - ClothDetectAiResponseDTO.class) - .block(); - } catch (Exception e) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } - - if (aiResponse == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); - } + long phaseStartedAtNs = System.nanoTime(); + validateImageUrl(imageUrl); + validationMs = elapsedMillis(phaseStartedAtNs); + + phaseStartedAtNs = System.nanoTime(); + List presignedUrls = createPresignedUrls(memberId, 10); + presignMs = elapsedMillis(phaseStartedAtNs); + + ClothDetectAiResponseDTO aiResponse; + try { + phaseStartedAtNs = System.nanoTime(); + aiResponse = + webClientUtil + .postToAiServer( + webClientProperties.clothDetectPath(), + new ClothDetectAiRequestDTO(imageUrl, presignedUrls), + ClothDetectAiResponseDTO.class) + .block(); + } catch (Exception e) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } finally { + aiCallMs = elapsedMillis(phaseStartedAtNs); + } - if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { - throw new BaseCustomException(mapAiErrorCode(aiResponse.errorCode())); - } + if (aiResponse == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_REQUEST_FAILED); + } - if (aiResponse.result() == null || aiResponse.result().uploadedUrls() == null) { - throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); - } + if (!Boolean.TRUE.equals(aiResponse.isSuccess())) { + ClothAiErrorCode mappedErrorCode = mapAiErrorCode(aiResponse.errorCode()); + throw new BaseCustomException(mappedErrorCode); + } - List payloads = - aiResponse.result().uploadedUrls().stream() - .map(ClothDetectResponse.Payload::new) - .toList(); + if (aiResponse.result() == null || aiResponse.result().uploadedUrls() == null) { + throw new BaseCustomException(ClothAiErrorCode.AI_SERVER_INVALID_RESPONSE); + } - return ClothDetectResponse.of(payloads); + phaseStartedAtNs = System.nanoTime(); + List payloads = + aiResponse.result().uploadedUrls().stream() + .map(ClothDetectResponse.Payload::new) + .toList(); + postProcessMs = elapsedMillis(phaseStartedAtNs); + + ClothDetectResponse response = ClothDetectResponse.of(payloads); + return response; + } catch (BaseCustomException e) { + errorCode = e.getErrorReasonDto().code(); + throw e; + } finally { + logClothAiObservation( + "detectClothes", + memberId, + 1, + elapsedMillis(startedAtNs), + validationMs, + presignMs, + aiCallMs, + postProcessMs, + errorCode); + } } private void validateImageUrls(List imageUrls) { @@ -299,4 +407,48 @@ private List createPresignedUrls(Long memberId, int count) { ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) .toList(); } + + private void logClothAiObservation( + String operation, + Long memberId, + int itemCount, + long totalMs, + long validationMs, + long presignMs, + long aiCallMs, + long postProcessMs, + String errorCode) { + if (errorCode != null) { + log.warn( + "[cloth-ai] {} 실패 - memberId: {}, itemCount: {}, errorCode: {}, totalMs: {}, validationMs: {}, presignMs: {}, aiCallMs: {}, postProcessMs: {}", + operation, + memberId, + itemCount, + errorCode, + totalMs, + validationMs, + presignMs, + aiCallMs, + postProcessMs); + return; + } + + if (totalMs >= SLOW_REQUEST_THRESHOLD_MS) { + log.warn( + "[cloth-ai] {} 지연 감지 - memberId: {}, itemCount: {}, totalMs: {}, validationMs: {}, presignMs: {}, aiCallMs: {}, postProcessMs: {}", + operation, + memberId, + itemCount, + totalMs, + validationMs, + presignMs, + aiCallMs, + postProcessMs); + return; + } + } + + private long elapsedMillis(long startedAtNs) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNs); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java index 383cc9b8..329c19f2 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java @@ -90,10 +90,11 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { public SliceResponse recommendCategoryClothes( Long lastClothId, int size, Long categoryId, Season season) { final Member currentMember = memberUtil.getCurrentMember(); + List categoryIds = resolveCategoryIds(categoryId); Slice result = clothRepository.findAllMemberRecommendClothesByCategoryAndSeason( - lastClothId, size, categoryId, currentMember.getId(), season); + lastClothId, size, categoryIds, currentMember.getId(), season); return SliceResponse.from(result); } diff --git a/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/CommentListResponse.java b/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/CommentListResponse.java index 2b2ccb44..eb0ae23f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/CommentListResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/CommentListResponse.java @@ -11,4 +11,5 @@ public record CommentListResponse( @Schema(description = "댓글 내용", example = "이 옷 정보 알려주세요!") String content, @Schema(description = "대댓글 존재 여부", example = "false") boolean replied, @Schema(description = "총 대댓글 개수", example = "0") long replyCount, - @Schema(description = "내가 작성한 댓글인가?", example = "false") boolean isMine) {} + @Schema(description = "내가 작성한 댓글인가?", example = "false") boolean isMine, + @Schema(description = "댓글 삭제 가능 여부", example = "false") boolean canDelete) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/ReplyListResponse.java b/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/ReplyListResponse.java index fbe7a1a3..e1f6149a 100644 --- a/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/ReplyListResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/comment/dto/response/ReplyListResponse.java @@ -9,4 +9,5 @@ public record ReplyListResponse( @Schema(description = "댓글 작성자 이미지 주소", example = "https://s3.amazonaws.com/example.jpg") String profileImageUrl, @Schema(description = "댓글 내용", example = "이 옷 정보 알려주세요!") String content, - @Schema(description = "내가 작성한 대댓글인가?", example = "false") boolean isMine) {} + @Schema(description = "내가 작성한 대댓글인가?", example = "false") boolean isMine, + @Schema(description = "댓글 삭제 가능 여부", example = "false") boolean canDelete) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepositoryImpl.java index f18cc699..b50ab786 100644 --- a/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/comment/repository/CommentRepositoryImpl.java @@ -55,7 +55,10 @@ public Slice findAllParentCommentByHistoryId( comment.content, Expressions.constant(false), Expressions.constant(0L), - member.id.eq(currentMemberId))) + member.id.eq(currentMemberId), + member.id + .eq(currentMemberId) + .or(history.member.id.eq(currentMemberId)))) .from(comment) .join(comment.member, member) .where( @@ -107,7 +110,8 @@ public Slice findAllParentCommentByHistoryId( c.content(), replyCountMap.getOrDefault(c.commentId(), 0L) > 0, replyCountMap.getOrDefault(c.commentId(), 0L), - c.isMine())) + c.isMine(), + c.canDelete())) .toList(); return new SliceImpl<>(finalResults, PageRequest.of(0, size), hasNext); @@ -131,7 +135,10 @@ public Slice findAllRepliesByCommentId( member.nickname, member.profileImageUrl, comment.content, - member.id.eq(currentMemberId))) + member.id.eq(currentMemberId), + member.id + .eq(currentMemberId) + .or(history.member.id.eq(currentMemberId)))) .from(comment) .join(comment.member, member) .where( diff --git a/clokey-api/src/main/java/org/clokey/domain/comment/service/CommentServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/comment/service/CommentServiceImpl.java index 9c4fc792..aacb27f8 100644 --- a/clokey-api/src/main/java/org/clokey/domain/comment/service/CommentServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/comment/service/CommentServiceImpl.java @@ -151,7 +151,11 @@ public void deleteComment(Long commentId) { final Member currentMember = memberUtil.getCurrentMember(); final Comment comment = getCommentById(commentId); - validateCommentOwner(currentMember, comment); + boolean isHistoryOwner = + Objects.equals(comment.getHistory().getMember().getId(), currentMember.getId()); + if (!isHistoryOwner) { + validateCommentOwner(currentMember, comment); + } if (comment.getComment() != null) { commentRepository.delete(comment); diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/controller/CoordinateController.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/controller/CoordinateController.java index 9909e187..a4f0e82e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/controller/CoordinateController.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/controller/CoordinateController.java @@ -17,6 +17,7 @@ import org.clokey.global.paging.SortDirection; import org.clokey.response.BaseResponse; import org.clokey.response.SliceResponse; +import org.springframework.beans.TypeMismatchException; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.*; @@ -101,14 +102,24 @@ public BaseResponse> getDailyCoordina return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } - @GetMapping("/daily/today") + @GetMapping("/daily/today/preview") @Operation( - operationId = "Coordinate_getTodayDailyCoordinateClothes", - summary = "오늘의 코디 옷 정보 조회", - description = "오늘의 코디에 포함된 옷 정보를 조회하는 API입니다.") - public BaseResponse> getTodayDailyCoordinateClothes() { - List response = - coordinateService.getTodayDailyCoordinateClothes(); + operationId = "Coordinate_getTodayCoordinatePreview", + summary = "오늘의 코디 Preview 조회", + description = "오늘의 코디의 Preview를 조회하는 API입니다.") + public BaseResponse getTodayCoordinatePreview() { + DailyCoordinatePreviewResponse response = coordinateService.getTodayCoordinatePreview(); + return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); + } + + @GetMapping("/daily/today/details") + @Operation( + operationId = "Coordinate_getTodayCoordinateDetails", + summary = "오늘의 코디 Details 조회", + description = "오늘의 코디의 Details를 조회하는 API입니다.") + public BaseResponse> getTodayCoordinateDetails() { + List response = + coordinateService.getTodayCoordinateDetails(); return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } @@ -145,13 +156,23 @@ public BaseResponse toggleCoordinateLike(@PathVariable Long coordinateId) return BaseResponse.onSuccess(GlobalBaseSuccessCode.NO_CONTENT, null); } - @GetMapping("/my-favorites") + @GetMapping("/favorites") @Operation( operationId = "Coordinate_getFavoriteCoordinates", - summary = "나의 최애 코디 조회", - description = "나의 최애 코디를 조회하는 API입니다.") - public BaseResponse> getFavoriteCoordinates() { - List response = coordinateService.getFavoriteCoordinates(); + summary = "최애 코디 조회", + description = "최애 코디를 조회하는 API입니다.") + public BaseResponse> getFavoriteCoordinates( + @RequestParam(required = false) String memberId) { + Long parsedMemberId = null; + if (memberId != null && !memberId.isBlank()) { + try { + parsedMemberId = Long.valueOf(memberId); + } catch (NumberFormatException e) { + throw new TypeMismatchException(memberId, Long.class, e); + } + } + List response = + coordinateService.getFavoriteCoordinates(parsedMemberId); return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/CoordinateDetailsListResponse.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/CoordinateDetailsListResponse.java index bfb07d43..b17fb917 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/CoordinateDetailsListResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/CoordinateDetailsListResponse.java @@ -9,6 +9,7 @@ public record CoordinateDetailsListResponse( @Schema(description = "코디-옷의 비율", example = "1.5") Double ratio, @Schema(description = "코디-옷의 각도", example = "240.6") Double degree, @Schema(description = "코디-옷의 순서", example = "1") int order, + @Schema(description = "옷 ID", example = "1") Long clothId, @Schema(description = "옷의 imageUrl", example = "https://example.jpg") String imageUrl, @Schema(description = "옷 브랜드", example = "나이키") String brand, @Schema(description = "옷 이름", example = "나이키 맨투맨") String name, diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinateClothResponse.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinateClothResponse.java deleted file mode 100644 index 5056f71f..00000000 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinateClothResponse.java +++ /dev/null @@ -1,20 +0,0 @@ -package org.clokey.domain.coordinate.dto.response; - -import io.swagger.v3.oas.annotations.media.Schema; - -@Schema(name = "DailyCoordinateClothResponse", description = "오늘의 코디 옷 정보") -public record DailyCoordinateClothResponse( - @Schema(description = "옷 이미지 URL", example = "https://example.jpg") String imageUrl, - @Schema(description = "브랜드", example = "나이키") String brand, - @Schema(description = "옷 이름", example = "맨투맨") String name, - @Schema(description = "하위 카테고리", example = "맨투맨") String category, - @Schema(description = "상위 카테고리", example = "상의") String parentCategory) { - public static DailyCoordinateClothResponse from(CoordinateDetailsListResponse details) { - return new DailyCoordinateClothResponse( - details.imageUrl(), - details.brand(), - details.name(), - details.category(), - details.parentCategory()); - } -} diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinatePreviewResponse.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinatePreviewResponse.java new file mode 100644 index 00000000..cb655322 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/DailyCoordinatePreviewResponse.java @@ -0,0 +1,17 @@ +package org.clokey.domain.coordinate.dto.response; + +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import org.clokey.coordinate.entity.Coordinate; + +public record DailyCoordinatePreviewResponse( + @Schema(description = "오늘의 코디의 ID", example = "1") Long coordinateId, + @Schema(description = "오늘의 코디의 imageUrl", example = "https://example.jpg") String imageUrl, + @Schema(description = "오늘의 코디의 날짜", example = "2026-02-04") LocalDate date) { + public static DailyCoordinatePreviewResponse from(Coordinate coordinate) { + return new DailyCoordinatePreviewResponse( + coordinate.getId(), + coordinate.getImageUrl(), + coordinate.getUpdatedAt().toLocalDate()); + } +} diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/FavoriteCoordinateResponse.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/FavoriteCoordinateResponse.java index 7c6a5ad7..e064a052 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/FavoriteCoordinateResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/dto/response/FavoriteCoordinateResponse.java @@ -4,9 +4,11 @@ import org.clokey.coordinate.entity.Coordinate; public record FavoriteCoordinateResponse( - @Schema(description = "코디 ID") Long coordinateId, - @Schema(description = "코디 이미지 url") String imageUrl) { + @Schema(description = "코디 ID", example = "1") Long coordinateId, + @Schema(description = "코디 이미지 url", example = "https://exampe.com") String imageUrl, + @Schema(description = "코디 이름", example = "영화관 데이트") String coordinateName) { public static FavoriteCoordinateResponse from(Coordinate coordinates) { - return new FavoriteCoordinateResponse(coordinates.getId(), coordinates.getImageUrl()); + return new FavoriteCoordinateResponse( + coordinates.getId(), coordinates.getImageUrl(), coordinates.getName()); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/exception/CoordinateErrorCode.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/exception/CoordinateErrorCode.java index ff1eb770..9da201d7 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/exception/CoordinateErrorCode.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/exception/CoordinateErrorCode.java @@ -15,9 +15,10 @@ public enum CoordinateErrorCode implements BaseErrorCode { COORDINATE_NOT_IN_LOOK_BOOK(400, "COORDINATE_4005", "룩북에 속해있지 않은 (오늘의) 코디입니다."), COORDINATE_LIKE_LIMIT(400, "COORDINATE_4006", "최대 5개의 코디에 좋아요를 누를 수 있습니다."), - NOT_COORDINATE_OWNER(400, "COORDINATE_4031", "나의 코디가 아닙니다. 권한이 없습니다."), + NOT_COORDINATE_OWNER(403, "COORDINATE_4031", "나의 코디가 아닙니다. 권한이 없습니다."), - COORDINATE_NOT_FOUND(400, "COORDINATE_4041", "존재하지 않는 코디입니다."); + COORDINATE_NOT_FOUND(404, "COORDINATE_4041", "존재하지 않는 코디입니다."), + DAILY_COORDINATE_NOT_FOUND(404, "COORDINATE_4042", "오늘의 코디가 존재하지 않습니다."); private final int status; private final String code; diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepositoryImpl.java index ee4e5ad2..9ca08639 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/repository/CoordinateRepositoryImpl.java @@ -73,6 +73,7 @@ public List findAllCoordinateDetailsByCoordinateI coordinateCloth.ratio, coordinateCloth.degree, coordinateCloth.order, + coordinateCloth.cloth.id, coordinateCloth.cloth.clothImageUrl, coordinateCloth.cloth.brand, coordinateCloth.cloth.name, diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateService.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateService.java index 1165a19f..53a3d576 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateService.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateService.java @@ -24,7 +24,9 @@ public interface CoordinateService { SliceResponse getDailyCoordinates( Long lastCoordinateId, int size, SortDirection direction); - List getTodayDailyCoordinateClothes(); + DailyCoordinatePreviewResponse getTodayCoordinatePreview(); + + List getTodayCoordinateDetails(); CoordinatePreviewResponse getCoordinatePreview(Long coordinateId); @@ -32,5 +34,5 @@ SliceResponse getDailyCoordinates( void toggleCoordinateLike(Long coordinateId); - List getFavoriteCoordinates(); + List getFavoriteCoordinates(Long memberId); } diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java index 63d41751..7b829051 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java @@ -1,6 +1,7 @@ package org.clokey.domain.coordinate.service; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.Function; @@ -24,11 +25,16 @@ import org.clokey.domain.image.event.ImageDeleteEvent; import org.clokey.domain.lookbook.exception.LookBookErrorCode; import org.clokey.domain.lookbook.repository.LookBookRepository; +import org.clokey.domain.member.exception.MemberErrorCode; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.FollowRepository; +import org.clokey.domain.member.repository.MemberRepository; import org.clokey.exception.BaseCustomException; import org.clokey.global.paging.SortDirection; import org.clokey.global.util.MemberUtil; import org.clokey.lookbook.entity.LookBook; import org.clokey.member.entity.Member; +import org.clokey.member.enums.Visibility; import org.clokey.response.SliceResponse; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; @@ -41,11 +47,16 @@ @Transactional(readOnly = true) public class CoordinateServiceImpl implements CoordinateService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberUtil memberUtil; private final CoordinateRepository coordinateRepository; private final ClothRepository clothRepository; private final LookBookRepository lookBookRepository; + private final MemberRepository memberRepository; + private final BlockRepository blockRepository; + private final FollowRepository followRepository; private final CoordinateClothRepository coordinateClothRepository; private final RedisTemplate redisTemplate; @@ -83,7 +94,7 @@ public CoordinateCreateResponse createDailyCoordinate(DailyCoordinateCreateReque validateExceedingCoordinationClothesLimit(request.payloads()); validateDuplicatedClothes(clothes); validateAllClothesOwnership(currentMember, clothes); - validateDailyCoordinateExist(currentMember.getId(), LocalDate.now()); + validateDailyCoordinateExist(currentMember.getId(), LocalDate.now(KST)); Coordinate coordinate = Coordinate.createDailyCoordinate(request.coordinateImageUrl(), currentMember); @@ -322,25 +333,19 @@ public SliceResponse getDailyCoordinates( } @Override - public List getTodayDailyCoordinateClothes() { + public DailyCoordinatePreviewResponse getTodayCoordinatePreview() { final Member currentMember = memberUtil.getCurrentMember(); - Optional coordinate = - coordinateRepository.findDailyCoordinateByDateAndMemberId( - LocalDate.now(), currentMember.getId()); - - if (coordinate.isEmpty()) { - return List.of(); - } + final Coordinate coordinate = getTodayDailyCoordinate(currentMember); - List details = - coordinateRepository.findAllCoordinateDetailsByCoordinateId( - coordinate.get().getId()); + return DailyCoordinatePreviewResponse.from(coordinate); + } - if (details.isEmpty()) { - return List.of(); - } + @Override + public List getTodayCoordinateDetails() { + final Member currentMember = memberUtil.getCurrentMember(); + final Coordinate coordinate = getTodayDailyCoordinate(currentMember); - return details.stream().map(DailyCoordinateClothResponse::from).toList(); + return coordinateRepository.findAllCoordinateDetailsByCoordinateId(coordinate.getId()); } @Override @@ -348,7 +353,7 @@ public CoordinatePreviewResponse getCoordinatePreview(Long coordinateId) { final Member currentMember = memberUtil.getCurrentMember(); final Coordinate coordinate = getCoordinateById(coordinateId); - validateCoordinateOwner(coordinate, currentMember.getId()); + validateCoordinateViewAccess(currentMember, coordinate.getMember()); validateCoordinateInLookBook(coordinate); return CoordinatePreviewResponse.from(coordinate); @@ -359,7 +364,7 @@ public List getCoordinateDetails(Long coordinateI final Member currentMember = memberUtil.getCurrentMember(); final Coordinate coordinate = getCoordinateById(coordinateId); - validateCoordinateOwner(coordinate, currentMember.getId()); + validateCoordinateViewAccess(currentMember, coordinate.getMember()); validateCoordinateInLookBook(coordinate); return coordinateRepository.findAllCoordinateDetailsByCoordinateId(coordinate.getId()); @@ -379,11 +384,13 @@ public void toggleCoordinateLike(Long coordinateId) { } @Override - public List getFavoriteCoordinates() { + public List getFavoriteCoordinates(Long memberId) { final Member currentMember = memberUtil.getCurrentMember(); + final Long targetMemberId = + resolveFavoriteCoordinateTargetMemberId(currentMember, memberId); List favoriteCoordinates = - coordinateRepository.findLikedCoordinatesByMemberId(currentMember.getId()); + coordinateRepository.findLikedCoordinatesByMemberId(targetMemberId); if (favoriteCoordinates.isEmpty()) { return List.of(); @@ -392,6 +399,22 @@ public List getFavoriteCoordinates() { return favoriteCoordinates.stream().map(FavoriteCoordinateResponse::from).toList(); } + private Long resolveFavoriteCoordinateTargetMemberId(Member currentMember, Long memberId) { + if (memberId == null || memberId.equals(currentMember.getId())) { + return currentMember.getId(); + } + + Member targetMember = + memberRepository + .findById(memberId) + .orElseThrow( + () -> new BaseCustomException(MemberErrorCode.MEMBER_NOT_FOUND)); + + validateCoordinateViewAccess(currentMember, targetMember); + + return targetMember.getId(); + } + private void validateAllClothesExist(List clothIds, Map clothMap) { boolean hasMissing = clothIds.stream().anyMatch(clothId -> !clothMap.containsKey(clothId)); @@ -475,6 +498,27 @@ private void validateCoordinateLikeLimit(Long memberId, Coordinate coordinate) { } } + private void validateNotBlocked(Long currentMemberId, Long targetMemberId) { + if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( + currentMemberId, targetMemberId, targetMemberId, currentMemberId)) { + throw new BaseCustomException(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED); + } + } + + private void validateCoordinateViewAccess(Member currentMember, Member targetMember) { + if (Objects.equals(currentMember.getId(), targetMember.getId())) { + return; + } + + validateNotBlocked(currentMember.getId(), targetMember.getId()); + + if (targetMember.getVisibility().equals(Visibility.PRIVATE) + && !followRepository.existsByFollowFrom_IdAndFollowTo_Id( + currentMember.getId(), targetMember.getId())) { + throw new BaseCustomException(MemberErrorCode.PRIVATE_MEMBER_ACCESS_DENIED); + } + } + private LookBook getLookBookById(Long lookBookId) { return lookBookRepository .findById(lookBookId) @@ -487,4 +531,13 @@ private Coordinate getCoordinateById(Long coordinateId) { .orElseThrow( () -> new BaseCustomException(CoordinateErrorCode.COORDINATE_NOT_FOUND)); } + + private Coordinate getTodayDailyCoordinate(Member member) { + return coordinateRepository + .findDailyCoordinateByDateAndMemberId(LocalDate.now(KST), member.getId()) + .orElseThrow( + () -> + new BaseCustomException( + CoordinateErrorCode.DAILY_COORDINATE_NOT_FOUND)); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/feed/repository/FeedQueryRepository.java b/clokey-api/src/main/java/org/clokey/domain/feed/repository/FeedQueryRepository.java index 1020536b..1bea9d7e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/feed/repository/FeedQueryRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/feed/repository/FeedQueryRepository.java @@ -16,6 +16,7 @@ import org.clokey.domain.feed.query.FollowScope; import org.clokey.history.entity.History; import org.clokey.member.entity.QBlock; +import org.clokey.member.enums.Visibility; import org.springframework.stereotype.Repository; @Repository @@ -42,6 +43,8 @@ public List findFeeds( .fetchJoin() .where( history.banned.isFalse(), + notSelfCondition(currentMemberId), + visibilityCondition(currentMemberId), followScopeCondition(currentMemberId, followScope), notBlockedCondition(currentMemberId), styleFilterCondition(styleIds), @@ -62,6 +65,8 @@ public List findFeedsByIds(Long currentMemberId, List historyIds) .fetchJoin() .where( history.banned.isFalse(), + notSelfCondition(currentMemberId), + visibilityCondition(currentMemberId), history.id.in(historyIds), notBlockedCondition(currentMemberId)) .fetch(); @@ -135,4 +140,20 @@ private BooleanExpression notBlockedCondition(Long currentMemberId) { JPAExpressions.selectOne().from(blockCheck).where(blockCondition).exists()) .not(); } + + private BooleanExpression visibilityCondition(Long currentMemberId) { + if (currentMemberId == null) { + return member.visibility.eq(Visibility.PUBLIC); + } + + return member.visibility.eq(Visibility.PUBLIC).or(member.id.eq(currentMemberId)); + } + + private BooleanExpression notSelfCondition(Long currentMemberId) { + if (currentMemberId == null) { + return null; + } + + return member.id.ne(currentMemberId); + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java b/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java index 8af848b4..9e0b3bab 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/controller/HistoryController.java @@ -53,7 +53,7 @@ public BaseResponse createHistory( return BaseResponse.onSuccess(GlobalBaseSuccessCode.CREATED, response); } - @PatchMapping("/{historyId}") + @PutMapping("/{historyId}") @Operation( operationId = "History_updateHistory", summary = "기록 수정", diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryHashtagRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryHashtagRepository.java index 412f2071..3300b719 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryHashtagRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryHashtagRepository.java @@ -1,11 +1,12 @@ package org.clokey.domain.history.repository; -import io.lettuce.core.dynamic.annotation.Param; import java.util.List; import org.clokey.history.entity.HistoryHashtag; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface HistoryHashtagRepository extends JpaRepository, HistoryHashtagRepositoryCustom { @@ -13,6 +14,10 @@ public interface HistoryHashtagRepository @EntityGraph(attributePaths = "hashtag") List findByHistoryId(Long historyId); + @Modifying + @Query("delete from HistoryHashtag hh where hh.history.id = :historyId") + void deleteAllByHistoryId(@Param("historyId") Long historyId); + @Query( """ select hh from HistoryHashtag hh diff --git a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryStyleRepository.java b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryStyleRepository.java index 0f2a3d4e..72a8caed 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryStyleRepository.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/repository/HistoryStyleRepository.java @@ -3,12 +3,18 @@ import java.util.List; import org.clokey.history.entity.HistoryStyle; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; public interface HistoryStyleRepository extends JpaRepository, HistoryStyleRepositoryCustom { List findByHistoryId(Long historyId); + @Modifying + @Query("delete from HistoryStyle hs where hs.history.id = :historyId") + void deleteAllByHistoryId(@Param("historyId") Long historyId); + @Query( """ select new org.clokey.domain.history.repository.HistoryStyleRepository$HistoryStyleInfo( diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index fff0f752..5bece370 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -1,6 +1,7 @@ package org.clokey.domain.history.service; import java.time.LocalDate; +import java.time.ZoneId; import java.util.*; import java.util.function.Function; import java.util.stream.Collectors; @@ -48,6 +49,8 @@ @Transactional(readOnly = true) public class HistoryServiceImpl implements HistoryService { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberUtil memberUtil; private final HistoryRepository historyRepository; @@ -90,7 +93,7 @@ public HistoryCreateResponse createHistory(HistoryCreateRequest request) { final String content = Optional.ofNullable(request.content()).map(String::trim).orElse(null); final History history = - History.createHistory(LocalDate.now(), content, currentMember, situation); + History.createHistory(LocalDate.now(KST), content, currentMember, situation); historyRepository.save(history); List images = new ArrayList<>(); @@ -155,7 +158,11 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { List existingImages = historyImageRepository.findByHistoryId(historyId); Map existingImageMap = existingImages.stream() - .collect(Collectors.toMap(HistoryImage::getImageUrl, Function.identity())); + .collect( + Collectors.toMap( + HistoryImage::getImageUrl, + Function.identity(), + (left, right) -> left)); Set requestedImageUrls = request.payloads().stream() @@ -362,6 +369,7 @@ public void deleteHistory(Long historyId) { clearStylesAndHashtags(historyId); memberLikeRepository.deleteAllByHistoryId(historyId); commentRepository.deleteAllByHistoryId(historyId); + commentRepository.flush(); reportRepository.deleteAllByTargetTypeAndTargetId(TargetType.HISTORY, historyId); historyRepository.delete(history); @@ -565,15 +573,11 @@ private void validateHistoryOwner(History history, Long memberId) { } private void clearStylesAndHashtags(Long historyId) { - List styles = historyStyleRepository.findByHistoryId(historyId); - if (!styles.isEmpty()) { - historyStyleRepository.deleteAll(styles); - } + historyStyleRepository.deleteAllByHistoryId(historyId); + historyStyleRepository.flush(); - List hashtags = historyHashtagRepository.findByHistoryId(historyId); - if (!hashtags.isEmpty()) { - historyHashtagRepository.deleteAll(hashtags); - } + historyHashtagRepository.deleteAllByHistoryId(historyId); + historyHashtagRepository.flush(); } private void deleteClothTagsByHistoryImageIds(List historyImageIds) { diff --git a/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java b/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java new file mode 100644 index 00000000..ee2a68a2 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/like/event/NewLikeEvent.java @@ -0,0 +1,9 @@ +package org.clokey.domain.like.event; + +public record NewLikeEvent( + Long likeId, + Long historyId, + Long receiverId, + Long likerId, + String likerNickname, + String likerProfileImageUrl) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java index e27c1c7e..dcf7d047 100644 --- a/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/like/service/LikeServiceImpl.java @@ -12,6 +12,7 @@ import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.like.dto.response.LikedHistoriesResponse; import org.clokey.domain.like.dto.response.LikedMembersResponse; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.like.repository.MemberLikeRepository; import org.clokey.domain.like.repository.MemberLikeRepositoryCustom; import org.clokey.domain.member.repository.BlockRepository; @@ -111,6 +112,14 @@ public void toggleLike(Long historyId) { } else { MemberLike newLike = MemberLike.createMemberLike(currentMember, history); memberLikeRepository.save(newLike); + eventPublisher.publishEvent( + new NewLikeEvent( + newLike.getId(), + history.getId(), + historyOwner.getId(), + currentMember.getId(), + currentMember.getNickname(), + currentMember.getProfileImageUrl())); } eventPublisher.publishEvent( diff --git a/clokey-api/src/main/java/org/clokey/domain/lookbook/repository/LookBookRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/lookbook/repository/LookBookRepositoryImpl.java index 8f70e5d1..8276ba72 100644 --- a/clokey-api/src/main/java/org/clokey/domain/lookbook/repository/LookBookRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/lookbook/repository/LookBookRepositoryImpl.java @@ -9,6 +9,7 @@ import com.querydsl.jpa.impl.JPAQueryFactory; import java.util.List; import lombok.RequiredArgsConstructor; +import org.clokey.coordinate.entity.QCoordinate; import org.clokey.domain.lookbook.dto.response.LookBookListResponse; import org.clokey.global.paging.SortDirection; import org.springframework.data.domain.PageRequest; @@ -25,6 +26,8 @@ public class LookBookRepositoryImpl implements LookBookRepositoryCustom { @Override public Slice findAllLookBookByMemberId( Long currentMemberId, Long lastLookBookId, int size, SortDirection direction) { + QCoordinate firstCoordinate = new QCoordinate("firstCoordinate"); + List results = queryFactory .select( @@ -35,12 +38,15 @@ public Slice findAllLookBookByMemberId( JPAExpressions.select(coordinate.count()) .from(coordinate) .where(coordinate.lookBook.id.eq(lookBook.id)), - JPAExpressions.select(coordinate.imageUrl) - .from(coordinate) - .where(coordinate.lookBook.id.eq(lookBook.id)) - .orderBy(coordinate.id.asc()) - .limit(1))) + firstCoordinate.imageUrl)) .from(lookBook) + .leftJoin(firstCoordinate) + .on( + firstCoordinate.lookBook.id.eq(lookBook.id), + firstCoordinate.id.eq( + JPAExpressions.select(coordinate.id.min()) + .from(coordinate) + .where(coordinate.lookBook.id.eq(lookBook.id)))) .where( lookBook.member.id.eq(currentMemberId), lastLookBookIdCondition(lastLookBookId, direction)) diff --git a/clokey-api/src/main/java/org/clokey/domain/lookbook/service/LookBookServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/lookbook/service/LookBookServiceImpl.java index 581fb943..d7c145e7 100644 --- a/clokey-api/src/main/java/org/clokey/domain/lookbook/service/LookBookServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/lookbook/service/LookBookServiceImpl.java @@ -84,7 +84,9 @@ public void deleteLookBook(Long lookBookId) { eventPublisher.publishEvent( ImagesDeleteEvent.of( defaultCoordinates.stream().map(Coordinate::getImageUrl).toList())); - coordinateRepository.deleteAllInBatch(defaultCoordinates); + coordinateRepository.deleteAll(defaultCoordinates); + + lookBookRepository.delete(lookBook); } @Override diff --git a/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java b/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java index 1dacdf25..b0ab3dc3 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/batch/InactiveMemberDeletionBatch.java @@ -1,6 +1,7 @@ package org.clokey.domain.member.batch; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; @@ -17,13 +18,15 @@ @Slf4j public class InactiveMemberDeletionBatch { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final MemberRepository memberRepository; private final AuthService authService; - @Scheduled(cron = "0 0 0 * * *") // 매일 00:00:00 + @Scheduled(cron = "0 0 0 * * *", zone = "Asia/Seoul") // 매일 00:00:00 KST @Transactional public void deleteInactiveMembers() { - LocalDateTime cutoffDate = LocalDateTime.now().minusDays(15); + LocalDateTime cutoffDate = LocalDateTime.now(KST).minusDays(15); List inactiveMembers = memberRepository.findInactiveMembersBefore(MemberStatus.INACTIVE, cutoffDate); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/controller/MemberController.java b/clokey-api/src/main/java/org/clokey/domain/member/controller/MemberController.java index 32226907..9918fdfd 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/controller/MemberController.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/controller/MemberController.java @@ -6,7 +6,7 @@ import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.clokey.code.GlobalBaseSuccessCode; -import org.clokey.domain.member.dto.request.DuplicatedIdCheckRequest; +import org.clokey.domain.member.dto.request.DuplicatedNicknameCheckRequest; import org.clokey.domain.member.dto.request.ProfileUpdateRequest; import org.clokey.domain.member.dto.response.*; import org.clokey.domain.member.service.MemberService; @@ -42,7 +42,7 @@ public BaseResponse updateProfile(@Valid @RequestBody ProfileUpdateRequest summary = "닉네임 중복확인", description = "닉네임 중복을 확인합니다.") public BaseResponse checkDuplicateNickname( - @Valid @RequestBody DuplicatedIdCheckRequest request) { + @Valid @RequestBody DuplicatedNicknameCheckRequest request) { DuplicatedIdCheckResponse response = memberService.checkDuplicateNickname(request); return BaseResponse.onSuccess(GlobalBaseSuccessCode.OK, response); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedIdCheckRequest.java b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedIdCheckRequest.java deleted file mode 100644 index 5760cb71..00000000 --- a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedIdCheckRequest.java +++ /dev/null @@ -1,13 +0,0 @@ -package org.clokey.domain.member.dto.request; - -import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; - -public record DuplicatedIdCheckRequest( - @NotNull(message = "닉네임은 비워둘 수 없습니다.") - @Pattern( - regexp = "^[a-z0-9._]+$", - message = "닉네임은 영어 소문자, 숫자, 언더바(_), 점(.)만 허용됩니다.") - @Schema(description = "중복을 확인할 닉네임", example = "clokey11") - String nickname) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java new file mode 100644 index 00000000..a0cafc50 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/DuplicatedNicknameCheckRequest.java @@ -0,0 +1,15 @@ +package org.clokey.domain.member.dto.request; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record DuplicatedNicknameCheckRequest( + @NotBlank(message = "닉네임은 비워둘 수 없습니다.") + @Size(max = 20, message = "닉네임은 20자 이하여야 합니다.") + @Pattern( + regexp = "^[a-z0-9가-힣._]+$", + message = "닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.") + @Schema(description = "중복을 확인할 닉네임", example = "clokey.홍길동") + String nickname) {} diff --git a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java index db3d0b43..f56b54f0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/dto/request/ProfileUpdateRequest.java @@ -3,11 +3,17 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; import org.clokey.member.enums.Visibility; public record ProfileUpdateRequest( - @NotBlank(message = "닉네임은 비워둘 수 없습니다.") @Schema(description = "사용자의 닉네임", example = "juwon") + @NotBlank(message = "닉네임은 비워둘 수 없습니다.") + @Size(max = 20, message = "닉네임은 20자 이하여야 합니다.") + @Pattern( + regexp = "^[a-z0-9가-힣._]+$", + message = "닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.") + @Schema(description = "사용자의 닉네임", example = "juwon") String nickname, @Schema(description = "사용자의 한줄 소개", example = "한줄 소개") @Size(max = 100, message = "바이오는 100자를 넘길 수 없습니다.") diff --git a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberService.java b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberService.java index ed55ddab..2d57148b 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberService.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberService.java @@ -1,6 +1,6 @@ package org.clokey.domain.member.service; -import org.clokey.domain.member.dto.request.DuplicatedIdCheckRequest; +import org.clokey.domain.member.dto.request.DuplicatedNicknameCheckRequest; import org.clokey.domain.member.dto.request.ProfileUpdateRequest; import org.clokey.domain.member.dto.response.*; import org.clokey.global.paging.SortDirection; @@ -10,7 +10,7 @@ public interface MemberService { void updateProfile(ProfileUpdateRequest request); - DuplicatedIdCheckResponse checkDuplicateNickname(DuplicatedIdCheckRequest request); + DuplicatedIdCheckResponse checkDuplicateNickname(DuplicatedNicknameCheckRequest request); void toggleFollow(Long userId); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java index 57f58e69..27825525 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java @@ -4,7 +4,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; import org.clokey.domain.history.repository.HistoryRepository; -import org.clokey.domain.member.dto.request.DuplicatedIdCheckRequest; +import org.clokey.domain.member.dto.request.DuplicatedNicknameCheckRequest; import org.clokey.domain.member.dto.request.ProfileUpdateRequest; import org.clokey.domain.member.dto.response.*; import org.clokey.domain.member.event.NewFollowerEvent; @@ -70,7 +70,8 @@ public void updateProfile(ProfileUpdateRequest request) { } @Override - public DuplicatedIdCheckResponse checkDuplicateNickname(DuplicatedIdCheckRequest request) { + public DuplicatedIdCheckResponse checkDuplicateNickname( + DuplicatedNicknameCheckRequest request) { final Member currentMember = memberUtil.getCurrentMember(); boolean duplicated = @@ -148,6 +149,10 @@ public MemberInfoResponse getMemberInfo(Long memberId) { Member currentMember = memberUtil.getCurrentMember(); Member targetMember = getMemberById(memberId); + if (!currentMember.getId().equals(targetMember.getId())) { + validateBlockedMutual(currentMember.getId(), targetMember.getId()); + } + return memberRepository.findMemberInfoById(currentMember.getId(), memberId); } @@ -250,6 +255,13 @@ private void validateBlocked(Member currentMember, Member targetMember) { } } + private void validateBlockedMutual(Long currentMemberId, Long targetMemberId) { + if (blockRepository.existsByBlockerIdAndBlockedIdOrBlockerIdAndBlockedId( + currentMemberId, targetMemberId, targetMemberId, currentMemberId)) { + throw new BaseCustomException(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED); + } + } + private void validateFollowMyself(Member followFrom, Member followTo) { if (followFrom.getId().equals(followTo.getId())) { throw new BaseCustomException(MemberErrorCode.CANNOT_FOLLOW_MYSELF); diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/dto/response/NotificationListResponse.java b/clokey-api/src/main/java/org/clokey/domain/notification/dto/response/NotificationListResponse.java index d0d2a5bf..efae7130 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/dto/response/NotificationListResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/dto/response/NotificationListResponse.java @@ -2,17 +2,22 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDateTime; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; public record NotificationListResponse( - @Schema(description = "알림의 ID", example = "1L") Long notificationId, + @Schema(description = "알림의 ID", example = "1") Long notificationId, @Schema(description = "알림의 이미지 URL", example = "https://testiamge.com") String notificationImageUrl, @Schema(description = "알림의 내용", example = "테스트 알림입니다.") String notificationContent, - @Schema(description = "redirectInfo(historyId 등)", example = "1L") String redirectInfo, - @Schema(description = "RedirectType(Member, History 등)", example = "MEMBER_REDIRECT") - RedirectType redirectType, + @Schema(description = "알림의 타입", example = "COMMENT") NotificationType notificationType, + @Schema(description = "알림 이동 정보") NotificationActionResponse action, @Schema(description = "읽음 상태(ReadStatus)", example = "NOT_READ") ReadStatus readStatus, @Schema(description = "알림이 생성된 시간", example = "2025-11-25T12:39:03.123456") - LocalDateTime createdAt) {} + LocalDateTime createdAt) { + public record NotificationActionResponse( + @Schema(description = "알림 이동 타입", example = "HISTORY_REDIRECT") + RedirectType redirectType, + @Schema(description = "알림 이동 정보(historyId 등)", example = "1") String redirectInfo) {} +} diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java b/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java index a4c554ea..91684b9b 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/event/NotificationEventListener.java @@ -4,6 +4,7 @@ import lombok.extern.slf4j.Slf4j; import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.event.NewFollowerEvent; import org.clokey.domain.member.event.NewPendingFollowerEvent; import org.clokey.domain.notification.service.CodiveNotificationService; @@ -84,4 +85,18 @@ public void handleNewReply(NewReplyEvent event) { e); } } + + @Async + @TransactionalEventListener(classes = NewLikeEvent.class, phase = TransactionPhase.AFTER_COMMIT) + public void handleNewLike(NewLikeEvent event) { + try { + codiveNotificationService.sendNewLikeNotification(event); + } catch (Exception e) { + log.error( + "새 좋아요 알림 전송 실패 - historyId: {}, likeId: {}", + event.historyId(), + event.likeId(), + e); + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java index e92b7717..72748e9f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/repository/CodiveNotificationRepositoryImpl.java @@ -6,6 +6,7 @@ import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.impl.JPAQueryFactory; import java.time.LocalDateTime; +import java.time.ZoneId; import java.util.List; import lombok.RequiredArgsConstructor; import org.clokey.domain.notification.dto.response.NotificationListResponse; @@ -19,6 +20,8 @@ @RequiredArgsConstructor public class CodiveNotificationRepositoryImpl implements CodiveNotificationRepositoryCustom { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + private final JPAQueryFactory queryFactory; @Override @@ -32,8 +35,12 @@ public Slice findAllNotificationsByMemberId( codiveNotification.id, codiveNotification.notificationImageUrl, codiveNotification.content, - codiveNotification.redirectInfo, - codiveNotification.redirectType, + codiveNotification.notificationType, + Projections.constructor( + NotificationListResponse.NotificationActionResponse + .class, + codiveNotification.redirectType, + codiveNotification.redirectInfo), codiveNotification.readStatus, codiveNotification.createdAt)) .from(codiveNotification) @@ -52,7 +59,7 @@ public void updateAllReadStatusByMemberId(Long memberId) { queryFactory .update(codiveNotification) .set(codiveNotification.readStatus, ReadStatus.READ) - .set(codiveNotification.updatedAt, LocalDateTime.now()) + .set(codiveNotification.updatedAt, LocalDateTime.now(KST)) .where( codiveNotification.member.id.eq(memberId), codiveNotification.readStatus.eq(ReadStatus.NOT_READ)) diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java index a9bbb400..bb85c15e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationService.java @@ -2,6 +2,7 @@ import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; @@ -17,6 +18,8 @@ public interface CodiveNotificationService { void sendNewReplyNotification(NewReplyEvent event); + void sendNewLikeNotification(NewLikeEvent event); + void sendNewTemperatureNotification(TemperatureNotificationRequest request); SliceResponse getNotificationList( diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index 4389070d..c303e6e0 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -5,6 +5,7 @@ import com.google.firebase.messaging.Message; import com.google.firebase.messaging.Notification; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.clokey.comment.entitiy.Comment; import org.clokey.domain.comment.event.NewCommentEvent; import org.clokey.domain.comment.event.NewReplyEvent; @@ -12,6 +13,7 @@ import org.clokey.domain.comment.repository.CommentRepository; import org.clokey.domain.history.exception.HistoryErrorCode; import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.exception.MemberErrorCode; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.domain.notification.dto.request.TemperatureNotificationRequest; @@ -28,12 +30,19 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.MemberStatus; import org.clokey.notification.entity.CodiveNotification; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; import org.clokey.response.SliceResponse; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -45,6 +54,7 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private final HistoryRepository historyRepository; private final CommentRepository commentRepository; private final FirebaseMessaging firebaseMessaging; + private final ApplicationEventPublisher eventPublisher; private final MemberUtil memberUtil; @@ -52,11 +62,14 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private static final String NEW_PENDING_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장에 팔로우를 요청했습니다."; private static final String NEW_COMMENT_NOTIFICATION = "%s님이 회원님의 기록에 댓글을 남겼습니다. : %s"; private static final String NEW_REPLY_NOTIFICATION = "%s님이 회원님의 댓글에 답장을 남겼습니다. : %s"; + private static final String NEW_LIKE_NOTIFICATION = "%s님이 회원님의 기록을 좋아합니다."; private static final String TODAY_TEMPERATURE_NOTIFICATION = "오늘의 기온은 %d도 입니다!\n날씨에 맞는 오늘의 옷차림이 기다리고 있어요👀"; + private static final String TODAY_TEMPERATURE_IMAGE_URL = "https://example.com/temperature.png"; @Override + @Transactional public void sendNewFollowerNotification(Long followFromId, Long followToId) { Member followFromMember = getMemberById(followFromId); Member followToMember = getMemberById(followToId); @@ -78,25 +91,22 @@ public void sendNewFollowerNotification(Long followFromId, Long followToId) { .putData("redirectType", "MEMBER_PROFILE") .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( followToMember, content, profileImageUrl, followToMember.getNickname(), - RedirectType.MEMBER_REDIRECT); + RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewPendingFollowerNotification(Long followFromId, Long followToId) { Member followFromMember = getMemberById(followFromId); Member followToMember = getMemberById(followToId); @@ -119,25 +129,22 @@ public void sendNewPendingFollowerNotification(Long followFromId, Long followToI .putData("redirectType", "MEMBER_PROFILE") .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( followToMember, content, profileImageUrl, followToMember.getNickname(), - RedirectType.MEMBER_REDIRECT); + RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW_REQUEST); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewCommentNotification(NewCommentEvent event) { Member receiver = getMemberById(event.receiverId()); @@ -161,25 +168,22 @@ public void sendNewCommentNotification(NewCommentEvent event) { .putData("commentId", String.valueOf(event.commentId())) .putData("commenterId", String.valueOf(event.commenterId())) .build(); - - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( receiver, content, profileImageUrl, String.valueOf(event.historyId()), - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewReplyNotification(NewReplyEvent event) { Member receiver = getMemberById(event.receiverId()); @@ -200,43 +204,83 @@ public void sendNewReplyNotification(NewReplyEvent event) { .putData("replierId", String.valueOf(event.replierId())) .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } - CodiveNotification codiveNotification = CodiveNotification.createCodiveNotification( receiver, content, profileImageUrl, String.valueOf(event.historyId()), - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.REPLY); + + codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); + } + } + + @Override + @Transactional + public void sendNewLikeNotification(NewLikeEvent event) { + Member receiver = getMemberById(event.receiverId()); + + if (isAbleToSendNotification(receiver)) { + String content = String.format(NEW_LIKE_NOTIFICATION, event.likerNickname()); + String profileImageUrl = event.likerProfileImageUrl(); + + Notification notification = + Notification.builder().setBody(content).setImage(profileImageUrl).build(); + Message message = + Message.builder() + .setToken(receiver.getDeviceToken()) + .setNotification(notification) + .putData("historyId", String.valueOf(event.historyId())) + .putData("likerId", String.valueOf(event.likerId())) + .putData("likerNickName", String.valueOf(event.likerNickname())) + .build(); + CodiveNotification codiveNotification = + CodiveNotification.createCodiveNotification( + receiver, + content, + profileImageUrl, + event.likerNickname(), + RedirectType.MEMBER_REDIRECT, + NotificationType.LIKE); codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @Override + @Transactional public void sendNewTemperatureNotification(TemperatureNotificationRequest request) { Member receiver = memberUtil.getCurrentMember(); - String content = ""; + String content = + String.format(TODAY_TEMPERATURE_NOTIFICATION, Math.round(request.temperature())); if (isAbleToSendNotification(receiver)) { - - Notification notification = Notification.builder().setBody(content).build(); + Notification notification = + Notification.builder() + .setBody(content) + .setImage(TODAY_TEMPERATURE_IMAGE_URL) + .build(); Message message = Message.builder() .setToken(receiver.getDeviceToken()) .setNotification(notification) .build(); - try { - firebaseMessaging.send(message); - } catch (FirebaseMessagingException e) { - throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); - } + CodiveNotification codiveNotification = + CodiveNotification.createCodiveNotification( + receiver, + content, + TODAY_TEMPERATURE_IMAGE_URL, + "", + RedirectType.NONE, + NotificationType.TEMPERATURE_DAILY); + + codiveNotificationRepository.save(codiveNotification); + eventPublisher.publishEvent(PushSendEvent.from(message)); } } @@ -321,4 +365,22 @@ private boolean isAbleToSendNotification(Member followToMember) { return isActive && hasDeviceToken && hasAgreed; } + + @Async + @Transactional(propagation = Propagation.NOT_SUPPORTED) + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void sendPushAfterCommit(PushSendEvent event) { + try { + firebaseMessaging.send(event.message()); + } catch (FirebaseMessagingException e) { + log.warn("[Notification] Firebase 전송 실패", e); + throw new BaseCustomException(NotificationErrorCode.NOTIFICATION_FIREBASE_ERROR); + } + } + + private record PushSendEvent(Message message) { + private static PushSendEvent from(Message message) { + return new PushSendEvent(message); + } + } } diff --git a/clokey-api/src/main/java/org/clokey/domain/search/controller/SearchController.java b/clokey-api/src/main/java/org/clokey/domain/search/controller/SearchController.java index 91f69797..fc07ff5f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/controller/SearchController.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/controller/SearchController.java @@ -97,9 +97,9 @@ public BaseResponse unSyncAllHistories() { @Operation( operationId = "Search_searchUserByNickname", summary = "유저 검색", - description = "검색탭에서 클로키ID와 닉네임을 통해 유저를 검색할 수 있는 API입니다.") + description = "검색탭에서 클로키ID와 닉네임을 통해 유저를 검색할 수 있는 API입니다. (본인 제외)") public BaseResponse> searchUserByNickname( - @Parameter(description = "검색 키워드 (클로키ID or 닉네임)") @RequestParam String keyword, + @Parameter(description = "검색 키워드 (닉네임)") @RequestParam String keyword, @Parameter(description = "페이지 번호") @RequestParam Long page, @Parameter(description = "페이지당 조회할 유저의 수") @RequestParam @PageSize Integer size) { SliceResponse response = diff --git a/clokey-api/src/main/java/org/clokey/domain/search/document/HistoryDocument.java b/clokey-api/src/main/java/org/clokey/domain/search/document/HistoryDocument.java index bef20a5e..6726e39a 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/document/HistoryDocument.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/document/HistoryDocument.java @@ -11,7 +11,7 @@ @Setter @Document(indexUid = "histories") @Setting( - searchableAttributes = {"hashtagNames", "categoryNames"}, + searchableAttributes = {"styleNames", "hashtagNames", "categoryNames"}, displayedAttributes = { "id", "memberId", @@ -21,6 +21,7 @@ "historyImageUrl", "profileImageUrl", "nickname", + "styleNames", "hashtagNames", "categoryNames" }, @@ -45,6 +46,8 @@ public class HistoryDocument { private String nickname; // Member.nickname + private List styleNames; + private List hashtagNames; private List categoryNames; diff --git a/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java index 6b0911c2..7920448e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/repository/MeiliSearchRepositoryImpl.java @@ -27,18 +27,18 @@ public class MeiliSearchRepositoryImpl implements SearchRepository { private static final String HISTORY_INDEX = "histories"; private static final String MEMBER_INDEX = "members"; private final MeilisearchOperations meilisearchOperations; - private final MeilisearchHistoryRepository historyRepository; - private final MeilisearchMemberRepository memberRepository; + private final MeilisearchHistoryRepository historyDocumentRepository; + private final MeilisearchMemberRepository memberDocumentRepository; // MemberDocument 관련 메서드 구현 @Override public void saveAllMembers(List documents) { - memberRepository.saveAll(documents); + memberDocumentRepository.saveAll(documents); } @Override public void deleteMember(String id) { - memberRepository.deleteById(id); + memberDocumentRepository.deleteById(id); } @Override @@ -96,12 +96,12 @@ public SliceResponse findUsersByKeyword( // HistoryDocument 관련 메서드 구현 @Override public void saveAllHistories(List documents) { - historyRepository.saveAll(documents); + historyDocumentRepository.saveAll(documents); } @Override public void deleteHistory(String id) { - historyRepository.deleteById(id); + historyDocumentRepository.deleteById(id); } @Override diff --git a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchDocumentServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchDocumentServiceImpl.java index 0f9930f9..abb88204 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchDocumentServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchDocumentServiceImpl.java @@ -5,18 +5,12 @@ import java.util.Set; import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; -import org.clokey.domain.history.repository.HistoryClothTagRepository; -import org.clokey.domain.history.repository.HistoryHashtagRepository; -import org.clokey.domain.history.repository.HistoryImageRepository; -import org.clokey.domain.history.repository.HistoryRepository; +import org.clokey.domain.history.repository.*; import org.clokey.domain.like.repository.MemberLikeRepository; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.domain.search.document.HistoryDocument; import org.clokey.domain.search.document.MemberDocument; -import org.clokey.history.entity.History; -import org.clokey.history.entity.HistoryClothTag; -import org.clokey.history.entity.HistoryHashtag; -import org.clokey.history.entity.HistoryImage; +import org.clokey.history.entity.*; import org.clokey.member.entity.Member; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -29,6 +23,7 @@ public class SearchDocumentServiceImpl implements SearchDocumentService { private final HistoryRepository historyRepository; private final MemberRepository memberRepository; private final MemberLikeRepository memberLikeRepository; + private final HistoryStyleRepository historyStyleRepository; private final HistoryHashtagRepository historyHashtagRepository; private final HistoryImageRepository historyImageRepository; private final HistoryClothTagRepository historyClothTagRepository; @@ -51,13 +46,16 @@ public HistoryDocument toHistoryDocument(Long historyId) { List images = historyImageRepository.findByHistoryId(historyId); String historyImageUrl = images.isEmpty() ? null : images.get(0).getImageUrl(); + // 스타일 이름 리스트 + List historyStyles = historyStyleRepository.findByHistoryId(historyId); + List styleNames = + historyStyles.stream().map(hs -> hs.getStyle().getName()).toList(); + // 해시태그 이름 리스트 List historyHashtags = historyHashtagRepository.findAllByHistoryIdWithHashtag(historyId); List hashtagNames = - historyHashtags.stream() - .map(hh -> hh.getHashtag().getName()) - .collect(Collectors.toList()); + historyHashtags.stream().map(hh -> hh.getHashtag().getName()).toList(); // 카테고리 이름 리스트 (중복 제거) Set categoryNamesSet = @@ -85,6 +83,7 @@ public HistoryDocument toHistoryDocument(Long historyId) { document.setHistoryImageUrl(historyImageUrl); document.setProfileImageUrl(member.getProfileImageUrl()); document.setNickname(member.getNickname()); + document.setStyleNames(styleNames); document.setHashtagNames(hashtagNames); document.setCategoryNames(categoryNames); diff --git a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java index e578e64e..b26dc005 100644 --- a/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/search/service/SearchServiceImpl.java @@ -165,10 +165,11 @@ public void unSyncAllHistories() { @Transactional(readOnly = true) public SliceResponse searchUserByNickname( String keyword, Long page, Integer size) { - Member currentMember = memberUtil.getCurrentMember(); + Long memberId = memberUtil.getCurrentMember().getId(); - List excludedMemberIds = - blockRepository.findBlockedMemberIdsByBlockerId(currentMember.getId()); + // 차단한 회원과 본인 제외 + List excludedMemberIds = blockRepository.findBlockedMemberIdsByBlockerId(memberId); + excludedMemberIds.add(memberId); return searchRepository.findUsersByKeyword(keyword, page, size, excludedMemberIds); } diff --git a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java index 7936bbff..8a595f96 100644 --- a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java +++ b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java @@ -7,12 +7,10 @@ import org.clokey.domain.auth.handler.OidcLoginSuccessHandler; import org.clokey.domain.auth.service.CustomOAuth2UserService; import org.clokey.domain.auth.service.JwtTokenService; +import org.clokey.global.security.AppleAwareOAuth2AuthorizationRequestResolver; import org.clokey.global.security.JwtAuthenticationFilter; import org.clokey.helper.SpringEnvironmentHelper; -import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Value; -import org.springframework.boot.autoconfigure.condition.ConditionalOnBean; -import org.springframework.context.ApplicationContext; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; @@ -26,7 +24,6 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; -import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestCustomizers; import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; import org.springframework.security.provisioning.InMemoryUserDetailsManager; @@ -44,10 +41,6 @@ public class SecurityConfig { private final SpringEnvironmentHelper springEnvironmentHelper; private final CustomOAuth2UserService customOAuth2UserService; private final OidcLoginSuccessHandler oidcLoginSuccessHandler; - private final ApplicationContext applicationContext; - - @Autowired(required = false) - private ClientRegistrationRepository clientRegistrationRepository; @Value("${swagger.username:default}") private String swaggerUsername; @@ -61,7 +54,8 @@ private void defaultFilterChain(HttpSecurity http) throws Exception { .cors(withDefaults()) .csrf(AbstractHttpConfigurer::disable) .sessionManagement( - session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)); + session -> + session.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)); } @Bean @@ -88,10 +82,7 @@ public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exceptio http.securityMatcher("/swagger-ui/**", "/v3/api-docs/**").httpBasic(withDefaults()); - http.authorizeHttpRequests( - (springEnvironmentHelper.isDevProfile()) - ? authorize -> authorize.anyRequest().authenticated() - : authorize -> authorize.anyRequest().permitAll()); + http.authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()); return http.build(); } @@ -101,13 +92,20 @@ public SecurityFilterChain swaggerFilterChain(HttpSecurity http) throws Exceptio @Order(2) @Profile({"local", "dev", "prod"}) public SecurityFilterChain apiFilterChain( - HttpSecurity http, JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception { + HttpSecurity http, + JwtAuthenticationFilter jwtAuthenticationFilter, + OAuth2AuthorizationRequestResolver authorizationRequestResolver) + throws Exception { defaultFilterChain(http); http.authorizeHttpRequests( auth -> auth.requestMatchers( - "/public/**", "/swagger-ui/**", "/v3/api-docs/**") + "/public/**", + "/swagger-ui/**", + "/v3/api-docs/**", + "/oauth2/**", + "/login/oauth2/**") .permitAll() .anyRequest() .authenticated()) @@ -118,19 +116,10 @@ public SecurityFilterChain apiFilterChain( userInfo.oidcUserService( customOAuth2UserService)) .successHandler(oidcLoginSuccessHandler); - if (clientRegistrationRepository != null) { - try { - OAuth2AuthorizationRequestResolver resolver = - applicationContext.getBean( - OAuth2AuthorizationRequestResolver.class); - oauth2.authorizationEndpoint( - authorization -> - authorization.authorizationRequestResolver( - resolver)); - } catch (Exception e) { - // Resolver bean이 없으면 기본 resolver 사용 - } - } + oauth2.authorizationEndpoint( + a -> + a.authorizationRequestResolver( + authorizationRequestResolver)); }) .addFilterBefore( jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); @@ -141,19 +130,24 @@ public SecurityFilterChain apiFilterChain( @Bean public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); - configuration.setAllowedOriginPatterns( List.of( "http://localhost:3000", "https://dev.clokey.store", "https://prod.clokey.store")); - configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); configuration.setAllowCredentials(true); + CorsConfiguration appleCallbackConfiguration = new CorsConfiguration(); + appleCallbackConfiguration.setAllowedOriginPatterns(List.of("https://appleid.apple.com")); + appleCallbackConfiguration.setAllowedMethods(List.of("POST", "OPTIONS")); + appleCallbackConfiguration.setAllowedHeaders(List.of("*")); + appleCallbackConfiguration.setAllowCredentials(true); + UrlBasedCorsConfigurationSource source = new UrlBasedCorsConfigurationSource(); + source.registerCorsConfiguration("/login/oauth2/code/**", appleCallbackConfiguration); source.registerCorsConfiguration("/**", configuration); return source; } @@ -164,14 +158,13 @@ public JwtAuthenticationFilter jwtAuthenticationFilter(JwtTokenService jwtTokenS } @Bean - @ConditionalOnBean(ClientRegistrationRepository.class) - public OAuth2AuthorizationRequestResolver oauth2AuthorizationRequestResolver() { - if (clientRegistrationRepository == null) { - throw new IllegalStateException("ClientRegistrationRepository is required for OAuth2"); - } - DefaultOAuth2AuthorizationRequestResolver resolver = - new DefaultOAuth2AuthorizationRequestResolver( + @Profile({"local", "dev", "prod"}) + public OAuth2AuthorizationRequestResolver oauth2AuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository) { + AppleAwareOAuth2AuthorizationRequestResolver resolver = + new AppleAwareOAuth2AuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization"); + resolver.setAuthorizationRequestCustomizer( OAuth2AuthorizationRequestCustomizers.withPkce()); return resolver; diff --git a/clokey-api/src/main/java/org/clokey/global/security/AppleAwareOAuth2AuthorizationRequestResolver.java b/clokey-api/src/main/java/org/clokey/global/security/AppleAwareOAuth2AuthorizationRequestResolver.java new file mode 100644 index 00000000..a1ef84f8 --- /dev/null +++ b/clokey-api/src/main/java/org/clokey/global/security/AppleAwareOAuth2AuthorizationRequestResolver.java @@ -0,0 +1,76 @@ +package org.clokey.global.security; + +import jakarta.servlet.http.HttpServletRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository; +import org.springframework.security.oauth2.client.web.DefaultOAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.client.web.OAuth2AuthorizationRequestResolver; +import org.springframework.security.oauth2.core.endpoint.OAuth2AuthorizationRequest; +import org.springframework.web.util.UriComponentsBuilder; + +@RequiredArgsConstructor +public class AppleAwareOAuth2AuthorizationRequestResolver + implements OAuth2AuthorizationRequestResolver { + + private final DefaultOAuth2AuthorizationRequestResolver delegate; + + public AppleAwareOAuth2AuthorizationRequestResolver( + ClientRegistrationRepository clientRegistrationRepository, + String authorizationBaseUri) { + this.delegate = + new DefaultOAuth2AuthorizationRequestResolver( + clientRegistrationRepository, authorizationBaseUri); + } + + @Override + public OAuth2AuthorizationRequest resolve(HttpServletRequest request) { + OAuth2AuthorizationRequest authorizationRequest = delegate.resolve(request); + return customizeIfApple(request, authorizationRequest); + } + + @Override + public OAuth2AuthorizationRequest resolve( + HttpServletRequest request, String clientRegistrationId) { + OAuth2AuthorizationRequest authorizationRequest = + delegate.resolve(request, clientRegistrationId); + if (authorizationRequest == null) { + return null; + } + return "apple".equals(clientRegistrationId) + ? enforceAppleResponseMode(authorizationRequest) + : authorizationRequest; + } + + public void setAuthorizationRequestCustomizer( + java.util.function.Consumer + authorizationRequestCustomizer) { + delegate.setAuthorizationRequestCustomizer(authorizationRequestCustomizer); + } + + private OAuth2AuthorizationRequest customizeIfApple( + HttpServletRequest request, OAuth2AuthorizationRequest authorizationRequest) { + if (authorizationRequest == null) { + return null; + } + String requestUri = request.getRequestURI(); + if (requestUri != null && requestUri.endsWith("/apple")) { + return enforceAppleResponseMode(authorizationRequest); + } + return authorizationRequest; + } + + private OAuth2AuthorizationRequest enforceAppleResponseMode( + OAuth2AuthorizationRequest authorizationRequest) { + String authorizationRequestUri = + UriComponentsBuilder.fromUriString( + authorizationRequest.getAuthorizationRequestUri()) + .replaceQueryParam("response_mode", "form_post") + .build(true) + .toUriString(); + + return OAuth2AuthorizationRequest.from(authorizationRequest) + .additionalParameters(params -> params.put("response_mode", "form_post")) + .authorizationRequestUri(authorizationRequestUri) + .build(); + } +} diff --git a/clokey-api/src/test/java/org/clokey/domain/auth/service/AuthServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/auth/service/AuthServiceTest.java index 6398b988..535ee25a 100644 --- a/clokey-api/src/test/java/org/clokey/domain/auth/service/AuthServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/auth/service/AuthServiceTest.java @@ -74,6 +74,7 @@ import org.clokey.member.enums.OauthProvider; import org.clokey.member.enums.Visibility; import org.clokey.notification.entity.CodiveNotification; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.RedirectType; import org.clokey.report.entity.Report; import org.clokey.report.enums.ReportReason; @@ -479,7 +480,8 @@ void setUp() { "testContent", "notificationImageUrl", "redirectInfo", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); codiveNotificationRepository.save(notification); // Follow 생성 (targetMember가 팔로우하는 경우) diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java index 4ce4df22..dbf2edcd 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java @@ -259,9 +259,10 @@ void setUp() { memberRepository.save(member); given(memberUtil.getCurrentMember()).willReturn(member); - Category category1 = Category.createCategory("testCategory1", null); - Category category2 = Category.createCategory("testCategory2", null); - categoryRepository.saveAll(List.of(category1, category2)); + Category parentCategory1 = Category.createCategory("testParentCategory1", null); + Category parentCategory2 = Category.createCategory("testParentCategory2", null); + Category category1 = Category.createCategory("testCategory1", parentCategory1); + categoryRepository.saveAll(List.of(parentCategory1, parentCategory2, category1)); Cloth cloth1 = Cloth.createCloth( diff --git a/clokey-api/src/test/java/org/clokey/domain/comment/controller/CommentControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/comment/controller/CommentControllerTest.java index 1f95e3ec..3dbf1666 100644 --- a/clokey-api/src/test/java/org/clokey/domain/comment/controller/CommentControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/comment/controller/CommentControllerTest.java @@ -292,6 +292,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent1", false, 0L, + true, true), new CommentListResponse( 2L, @@ -301,6 +302,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent2", false, 0L, + true, true)); given(commentService.getHistoryComments(1L, null, 2, SortDirection.ASC)) @@ -319,6 +321,7 @@ class 기록의_댓글_목록_조회_요청_시 { .andExpect(jsonPath("$.code").value("COMMON200")) .andExpect(jsonPath("$.result.content[0].commentId").value(1)) .andExpect(jsonPath("$.result.content[1].commentId").value(2)) + .andExpect(jsonPath("$.result.content[0].canDelete").value(true)) .andExpect(jsonPath("$.result.isLast").value(true)); } @@ -335,6 +338,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent2", false, 0L, + true, true), new CommentListResponse( 1L, @@ -344,6 +348,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent1", false, 0L, + true, true)); given(commentService.getHistoryComments(1L, null, 2, SortDirection.DESC)) @@ -378,6 +383,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent2", false, 0L, + true, true)); given(commentService.getHistoryComments(1L, null, 1, SortDirection.ASC)) @@ -411,6 +417,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent1", false, 0L, + true, true), new CommentListResponse( 2L, @@ -420,6 +427,7 @@ class 기록의_댓글_목록_조회_요청_시 { "testContent2", false, 0L, + true, true)); given(commentService.getHistoryComments(1L, null, 1, SortDirection.ASC)) @@ -508,9 +516,21 @@ class 댓글의_대댓글_목록_조회_요청_시 { List commentReplies = List.of( new ReplyListResponse( - 1L, 1L, "testNickName", "testProfile", "testContent1", true), + 1L, + 1L, + "testNickName", + "testProfile", + "testContent1", + true, + true), new ReplyListResponse( - 2L, 1L, "testNickName", "testProfile", "testContent2", true)); + 2L, + 1L, + "testNickName", + "testProfile", + "testContent2", + true, + true)); given(commentService.getCommentReplies(1L, null, 2, SortDirection.ASC)) .willReturn(new SliceResponse<>(commentReplies, true)); @@ -527,6 +547,7 @@ class 댓글의_대댓글_목록_조회_요청_시 { .andExpect(jsonPath("$.code").value("COMMON200")) .andExpect(jsonPath("$.result.content[0].replyId").value(1)) .andExpect(jsonPath("$.result.content[1].replyId").value(2)) + .andExpect(jsonPath("$.result.content[0].canDelete").value(true)) .andExpect(jsonPath("$.result.isLast").value(true)); } @@ -536,9 +557,21 @@ class 댓글의_대댓글_목록_조회_요청_시 { List commentReplies = List.of( new ReplyListResponse( - 2L, 1L, "testNickName", "testProfile", "testContent2", true), + 2L, + 1L, + "testNickName", + "testProfile", + "testContent2", + true, + true), new ReplyListResponse( - 1L, 1L, "testNickName", "testProfile", "testContent1", true)); + 1L, + 1L, + "testNickName", + "testProfile", + "testContent1", + true, + true)); given(commentService.getCommentReplies(1L, null, 2, SortDirection.DESC)) .willReturn(new SliceResponse<>(commentReplies, true)); @@ -564,7 +597,13 @@ class 댓글의_대댓글_목록_조회_요청_시 { List commentReplies = List.of( new ReplyListResponse( - 2L, 1L, "testNickName", "testProfile", "testContent2", true)); + 2L, + 1L, + "testNickName", + "testProfile", + "testContent2", + true, + true)); given(commentService.getCommentReplies(1L, null, 1, SortDirection.ASC)) .willReturn(new SliceResponse<>(commentReplies, true)); @@ -589,9 +628,21 @@ class 댓글의_대댓글_목록_조회_요청_시 { List commentsReplies = List.of( new ReplyListResponse( - 1L, 1L, "testNickName", "testProfile", "testContent1", true), + 1L, + 1L, + "testNickName", + "testProfile", + "testContent1", + true, + true), new ReplyListResponse( - 2L, 1L, "testNickName", "testProfile", "testContent2", true)); + 2L, + 1L, + "testNickName", + "testProfile", + "testContent2", + true, + true)); given(commentService.getCommentReplies(1L, null, 2, SortDirection.ASC)) .willReturn(new SliceResponse<>(commentsReplies, false)); diff --git a/clokey-api/src/test/java/org/clokey/domain/comment/service/CommentServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/comment/service/CommentServiceTest.java index 11eab2dd..5fd8abe8 100644 --- a/clokey-api/src/test/java/org/clokey/domain/comment/service/CommentServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/comment/service/CommentServiceTest.java @@ -331,7 +331,12 @@ void setUp() { "testNickName2", OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); member2.changeVisibility(); - memberRepository.saveAll(List.of(member1, member2)); + Member member3 = + Member.createMember( + "testEmail3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2, member3)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation = Situation.createSituation("testSituation"); @@ -351,8 +356,9 @@ void setUp() { Comment comment1 = Comment.createParentComment("testContent1", member1, history1); Comment comment2 = Comment.createParentComment("testContent2", member2, history1); Comment comment3 = Comment.createParentComment("testContent3", member2, history1); + Comment comment4 = Comment.createParentComment("testContent4", member3, history1); Comment reply1 = Comment.createReply("testContent1", member1, history1, comment1); - commentRepository.saveAll(List.of(comment1, comment2, comment3, reply1)); + commentRepository.saveAll(List.of(comment1, comment2, comment3, comment4, reply1)); } @Test @@ -373,10 +379,10 @@ void setUp() { void 정렬_조건이_DESC면_commentId를_내림차순으로_조회한다() { // when SliceResponse response = - commentService.getHistoryComments(1L, null, 3, SortDirection.DESC); + commentService.getHistoryComments(1L, null, 4, SortDirection.DESC); // then - assertThat(response.content()).extracting("commentId").containsExactly(3L, 2L, 1L); + assertThat(response.content()).extracting("commentId").containsExactly(4L, 3L, 2L, 1L); } @Test @@ -405,11 +411,11 @@ void setUp() { void 마지막_페이지인_경우_isLast를_true로_반환한다() { // when SliceResponse response = - commentService.getHistoryComments(1L, null, 3, SortDirection.ASC); + commentService.getHistoryComments(1L, null, 4, SortDirection.ASC); // then Assertions.assertAll( - () -> assertThat(response.content().size()).isEqualTo(3), + () -> assertThat(response.content().size()).isEqualTo(4), () -> assertThat(response.isLast()).isTrue()); } @@ -444,6 +450,27 @@ void setUp() { .isInstanceOf(BaseCustomException.class) .hasMessage(HistoryErrorCode.LIMITED_AUTHORITY.getMessage()); } + + @Test + void 내가_아닌_공개_계정의_기록에_다른_사람의_댓글을_조회하면_canDelete가_false로_반환된다() { + // given + Member member = memberRepository.findById(3L).orElse(null); + given(memberUtil.getCurrentMember()).willReturn(member); + + // when + SliceResponse response = + commentService.getHistoryComments(1L, null, 4, SortDirection.ASC); + + // then + assertThat(response.content()) + .extracting("commentId", "canDelete") + .containsExactly( + tuple(1L, false), // 다른 사람이 쓴 댓글 + tuple(2L, false), // 다른 사람이 쓴 댓글 + tuple(3L, false), // 다른 사람이 쓴 댓글 + tuple(4L, true) // 내가 쓴 댓글 + ); + } } @Nested @@ -463,7 +490,12 @@ void setUp() { "testNickName2", OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); member2.changeVisibility(); - memberRepository.saveAll(List.of(member1, member2)); + Member member3 = + Member.createMember( + "testEmail3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2, member3)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation = Situation.createSituation("testSituation"); @@ -483,8 +515,9 @@ void setUp() { Comment reply1 = Comment.createReply("testContent1", member1, history1, comment1); Comment reply2 = Comment.createReply("testContent2", member2, history1, comment1); Comment reply3 = Comment.createReply("testContent3", member2, history2, comment2); + Comment reply4 = Comment.createReply("testContent3", member3, history1, comment1); commentRepository.saveAll( - List.of(comment1, comment2, comment3, reply1, reply2, reply3)); + List.of(comment1, comment2, comment3, reply1, reply2, reply3, reply4)); } @Test @@ -504,7 +537,7 @@ void setUp() { commentService.getCommentReplies(1L, null, 2, SortDirection.DESC); // then - assertThat(response.content()).extracting("replyId").containsExactly(5L, 4L); + assertThat(response.content()).extracting("replyId").containsExactly(7L, 5L); } @Test @@ -514,7 +547,7 @@ void setUp() { commentService.getCommentReplies(1L, 4L, 2, SortDirection.ASC); // then - assertThat(response.content()).extracting("replyId").containsExactly(5L); + assertThat(response.content()).extracting("replyId").containsExactly(5L, 7L); } @Test @@ -533,11 +566,11 @@ void setUp() { void 마지막_페이지인_경우_isLast를_true로_반환한다() { // when SliceResponse response = - commentService.getCommentReplies(1L, null, 2, SortDirection.ASC); + commentService.getCommentReplies(1L, null, 3, SortDirection.ASC); // then Assertions.assertAll( - () -> assertThat(response.content().size()).isEqualTo(2), + () -> assertThat(response.content().size()).isEqualTo(3), () -> assertThat(response.isLast()).isTrue()); } @@ -572,6 +605,26 @@ void setUp() { .isInstanceOf(BaseCustomException.class) .hasMessage(HistoryErrorCode.LIMITED_AUTHORITY.getMessage()); } + + @Test + void 내가_아닌_공개_계정의_기록의_대댓글을_조회하면_canDelete가_false로_반환된다() { + // given + Member member = memberRepository.findById(3L).orElse(null); + given(memberUtil.getCurrentMember()).willReturn(member); + + // when + SliceResponse response = + commentService.getCommentReplies(1L, null, 3, SortDirection.ASC); + + // then + assertThat(response.content()) + .extracting("replyId", "canDelete") + .containsExactly( + tuple(4L, false), // 다른 사람이 쓴 댓글 + tuple(5L, false), // 다른 사람이 쓴 댓글 + tuple(7L, true) // 내가 쓴 댓글 + ); + } } @Nested @@ -590,7 +643,14 @@ void setUp() { "testEmail2", "testNickName2", OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); - memberRepository.saveAll(List.of(member1, member2)); + + Member member3 = + Member.createMember( + "testEmail3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2, member3)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation = Situation.createSituation("testSituation"); @@ -606,7 +666,10 @@ void setUp() { Comment reply1 = Comment.createReply("testContent1", member1, history, comment1); Comment reply2 = Comment.createReply("testContent2", member1, history, comment1); Comment reply3 = Comment.createReply("testContent3", member1, history, comment2); - commentRepository.saveAll(List.of(comment1, comment2, reply1, reply2, reply3)); + Comment otherComment = Comment.createParentComment("otherContent", member2, history); + Comment otherReply = Comment.createReply("otherReply", member2, history, otherComment); + commentRepository.saveAll( + List.of(comment1, comment2, reply1, reply2, reply3, otherComment, otherReply)); } @Test @@ -642,13 +705,49 @@ void setUp() { } @Test - void 댓글_작성자가_아닌_경우_예외가_발생한다() { + void 글_작성자는_내_글에_달린_남의_댓글을_삭제할_수_있고_대댓글도_함께_삭제된다() { // given - Member member = memberRepository.findById(2L).orElseThrow(); + Member historyOwner = memberRepository.findById(1L).orElseThrow(); + given(memberUtil.getCurrentMember()).willReturn(historyOwner); + + Comment otherComment = + commentRepository.findAll().stream() + .filter(c -> c.getComment() == null) + .filter(c -> "otherContent".equals(c.getContent())) + .findFirst() + .orElseThrow(); + + Comment otherReply = + commentRepository.findAll().stream() + .filter(c -> c.getComment() != null) + .filter(c -> "otherReply".equals(c.getContent())) + .findFirst() + .orElseThrow(); + + // when + commentService.deleteComment(otherComment.getId()); + + // then + Assertions.assertAll( + () -> assertThat(commentRepository.findById(otherComment.getId())).isEmpty(), + () -> assertThat(commentRepository.findById(otherReply.getId())).isEmpty()); + } + + @Test + void 글_작성자도_아니고_댓글_작성자도_아니면_남의_댓글을_삭제할_수_없다() { + // given + Member member = memberRepository.findById(3L).orElseThrow(); given(memberUtil.getCurrentMember()).willReturn(member); + Comment otherComment = + commentRepository.findAll().stream() + .filter(c -> c.getComment() == null) + .filter(c -> "otherContent".equals(c.getContent())) + .findFirst() + .orElseThrow(); + // when & then - assertThatThrownBy(() -> commentService.deleteComment(1L)) + assertThatThrownBy(() -> commentService.deleteComment(otherComment.getId())) .isInstanceOf(BaseCustomException.class) .hasMessage(CommentErrorCode.NOT_MY_COMMENT.getMessage()); } diff --git a/clokey-api/src/test/java/org/clokey/domain/coordinate/controller/CoordinateControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/coordinate/controller/CoordinateControllerTest.java index a2dd5062..3ca27dfb 100644 --- a/clokey-api/src/test/java/org/clokey/domain/coordinate/controller/CoordinateControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/coordinate/controller/CoordinateControllerTest.java @@ -7,6 +7,7 @@ import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; import com.fasterxml.jackson.databind.ObjectMapper; +import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; import org.clokey.domain.coordinate.dto.request.CoordinateAutoCreateRequest; @@ -1555,6 +1556,7 @@ class 코디_Details_조회_요청_시 { 1.5, 240.1, 1, + 14L, "testImageUrl1", "testBrand1", "testName1", @@ -1567,6 +1569,7 @@ class 코디_Details_조회_요청_시 { 1.5, 240.1, 2, + 15L, "testImageUrl2", "testBrand2", "testName2", @@ -1590,6 +1593,7 @@ class 코디_Details_조회_요청_시 { .andExpect(jsonPath("$.result[0].ratio").value(1.5)) .andExpect(jsonPath("$.result[0].degree").value(240.1)) .andExpect(jsonPath("$.result[0].order").value(1)) + .andExpect(jsonPath("$.result[0].clothId").value(14)) .andExpect(jsonPath("$.result[0].imageUrl").value("testImageUrl1")) .andExpect(jsonPath("$.result[0].brand").value("testBrand1")) .andExpect(jsonPath("$.result[0].name").value("testName1")) @@ -1602,6 +1606,7 @@ class 코디_Details_조회_요청_시 { .andExpect(jsonPath("$.result[1].ratio").value(1.5)) .andExpect(jsonPath("$.result[1].degree").value(240.1)) .andExpect(jsonPath("$.result[1].order").value(2)) + .andExpect(jsonPath("$.result[1].clothId").value(15)) .andExpect(jsonPath("$.result[1].imageUrl").value("testImageUrl2")) .andExpect(jsonPath("$.result[1].brand").value("testBrand2")) .andExpect(jsonPath("$.result[1].name").value("testName2")) @@ -1640,66 +1645,129 @@ class 최애_코디_조회_요청_시 { // given List response = List.of( - new FavoriteCoordinateResponse(1L, "testImageUrl1"), - new FavoriteCoordinateResponse(2L, "testImageUrl2")); + new FavoriteCoordinateResponse( + 1L, "testImageUrl1", "testCoordinateName1"), + new FavoriteCoordinateResponse( + 2L, "testImageUrl2", "testCoordinateName2")); - given(coordinateService.getFavoriteCoordinates()).willReturn(response); + given(coordinateService.getFavoriteCoordinates(null)).willReturn(response); // when & then - ResultActions perform = mockMvc.perform(get("/coordinate/my-favorites")); + ResultActions perform = mockMvc.perform(get("/coordinate/favorites")); perform.andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) .andExpect(jsonPath("$.code").value("COMMON200")) .andExpect(jsonPath("$.result[0].coordinateId").value(1)) .andExpect(jsonPath("$.result[0].imageUrl").value("testImageUrl1")) + .andExpect(jsonPath("$.result[0].coordinateName").value("testCoordinateName1")) .andExpect(jsonPath("$.result[1].coordinateId").value(2)) - .andExpect(jsonPath("$.result[1].imageUrl").value("testImageUrl2")); + .andExpect(jsonPath("$.result[1].imageUrl").value("testImageUrl2")) + .andExpect(jsonPath("$.result[1].coordinateName").value("testCoordinateName2")); + } + } + + @Nested + class 오늘의_코디_Preview_조회_요청_시 { + + @Test + void 유효한_요청이면_오늘의_코디_Preview를_반환한다() throws Exception { + // given + LocalDate today = LocalDate.now(); + DailyCoordinatePreviewResponse response = + new DailyCoordinatePreviewResponse(1L, "testImageUrl", LocalDate.now()); + given(coordinateService.getTodayCoordinatePreview()).willReturn(response); + + // when & then + ResultActions perform = + mockMvc.perform( + get("/coordinate/daily/today/preview") + .contentType(MediaType.APPLICATION_JSON)); + + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.coordinateId").value(1)) + .andExpect(jsonPath("$.result.imageUrl").value("testImageUrl")) + .andExpect(jsonPath("$.result.date").value(today.toString())); } } @Nested - class 오늘의_코디_조회_요청_시 { + class 오늘의_코디_Details_조회_요청_시 { @Test - void 유효한_요청이면_오늘의_코디_정보를_반환한다() throws Exception { + void 유효한_요청이면_오늘의_코디_Details를_반환한다() throws Exception { // given - List response = + List response = List.of( - new DailyCoordinateClothResponse( - "https://image.example/cloth1.jpg", - "brand1", - "name1", - "category1", - "parent1"), - new DailyCoordinateClothResponse( - "https://image.example/cloth2.jpg", - "brand2", - "name2", - "category2", - "parent2")); - - given(coordinateService.getTodayDailyCoordinateClothes()).willReturn(response); - - ResultActions perform = mockMvc.perform(get("/coordinate/daily/today")); + new CoordinateDetailsListResponse( + 1L, + 50.2, + 60.1, + 1.5, + 240.1, + 1, + 14L, + "testImageUrl1", + "testBrand1", + "testName1", + "testCategoryName1", + "testParentCategoryName1"), + new CoordinateDetailsListResponse( + 2L, + 50.2, + 60.1, + 1.5, + 240.1, + 2, + 15L, + "testImageUrl2", + "testBrand2", + "testName2", + "testCategoryName2", + "testParentCategoryName2")); + + given(coordinateService.getTodayCoordinateDetails()).willReturn(response); + // when & then + ResultActions perform = + mockMvc.perform( + get("/coordinate/daily/today/details") + .contentType(MediaType.APPLICATION_JSON)); + perform.andExpect(status().isOk()) .andExpect(jsonPath("$.isSuccess").value(true)) .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result[0].coordinateClothId").value(1)) + .andExpect(jsonPath("$.result[0].locationX").value(50.2)) + .andExpect(jsonPath("$.result[0].locationY").value(60.1)) + .andExpect(jsonPath("$.result[0].ratio").value(1.5)) + .andExpect(jsonPath("$.result[0].degree").value(240.1)) + .andExpect(jsonPath("$.result[0].order").value(1)) + .andExpect(jsonPath("$.result[0].clothId").value(14)) + .andExpect(jsonPath("$.result[0].imageUrl").value("testImageUrl1")) + .andExpect(jsonPath("$.result[0].brand").value("testBrand1")) + .andExpect(jsonPath("$.result[0].name").value("testName1")) + .andExpect(jsonPath("$.result[0].category").value("testCategoryName1")) .andExpect( - jsonPath("$.result[0].imageUrl") - .value("https://image.example/cloth1.jpg")) - .andExpect(jsonPath("$.result[0].brand").value("brand1")) - .andExpect(jsonPath("$.result[0].name").value("name1")) - .andExpect(jsonPath("$.result[0].category").value("category1")) - .andExpect(jsonPath("$.result[0].parentCategory").value("parent1")) + jsonPath("$.result[0].parentCategory").value("testParentCategoryName1")) + .andExpect(jsonPath("$.result[1].coordinateClothId").value(2)) + .andExpect(jsonPath("$.result[1].locationX").value(50.2)) + .andExpect(jsonPath("$.result[1].locationY").value(60.1)) + .andExpect(jsonPath("$.result[1].ratio").value(1.5)) + .andExpect(jsonPath("$.result[1].degree").value(240.1)) + .andExpect(jsonPath("$.result[1].order").value(2)) + .andExpect(jsonPath("$.result[1].clothId").value(15)) + .andExpect(jsonPath("$.result[1].imageUrl").value("testImageUrl2")) + .andExpect(jsonPath("$.result[1].brand").value("testBrand2")) + .andExpect(jsonPath("$.result[1].name").value("testName2")) + .andExpect(jsonPath("$.result[1].category").value("testCategoryName2")) .andExpect( - jsonPath("$.result[1].imageUrl") - .value("https://image.example/cloth2.jpg")) - .andExpect(jsonPath("$.result[1].brand").value("brand2")) - .andExpect(jsonPath("$.result[1].name").value("name2")) - .andExpect(jsonPath("$.result[1].category").value("category2")) - .andExpect(jsonPath("$.result[1].parentCategory").value("parent2")); + jsonPath("$.result[1].parentCategory") + .value("testParentCategoryName2")); } } } diff --git a/clokey-api/src/test/java/org/clokey/domain/coordinate/service/CoordinateServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/coordinate/service/CoordinateServiceImplTest.java index 7682838b..027b03dc 100644 --- a/clokey-api/src/test/java/org/clokey/domain/coordinate/service/CoordinateServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/coordinate/service/CoordinateServiceImplTest.java @@ -6,6 +6,7 @@ import static org.mockito.BDDMockito.given; import java.time.LocalDate; +import java.time.ZoneId; import java.time.format.DateTimeFormatter; import java.util.List; import org.clokey.IntegrationTest; @@ -24,22 +25,23 @@ import org.clokey.domain.coordinate.dto.request.CoordinateManualCreateRequest; import org.clokey.domain.coordinate.dto.request.CoordinateUpdateRequest; import org.clokey.domain.coordinate.dto.request.DailyCoordinateCreateRequest; -import org.clokey.domain.coordinate.dto.response.CoordinateDetailsListResponse; -import org.clokey.domain.coordinate.dto.response.CoordinatePreviewResponse; -import org.clokey.domain.coordinate.dto.response.DailyCoordinateClothResponse; -import org.clokey.domain.coordinate.dto.response.DailyCoordinateListResponse; -import org.clokey.domain.coordinate.dto.response.FavoriteCoordinateResponse; +import org.clokey.domain.coordinate.dto.response.*; import org.clokey.domain.coordinate.exception.CoordinateErrorCode; import org.clokey.domain.coordinate.repository.CoordinateClothRepository; import org.clokey.domain.coordinate.repository.CoordinateRepository; import org.clokey.domain.image.event.ImageDeleteEvent; import org.clokey.domain.lookbook.exception.LookBookErrorCode; import org.clokey.domain.lookbook.repository.LookBookRepository; +import org.clokey.domain.member.exception.MemberErrorCode; +import org.clokey.domain.member.repository.BlockRepository; +import org.clokey.domain.member.repository.FollowRepository; import org.clokey.domain.member.repository.MemberRepository; import org.clokey.exception.BaseCustomException; import org.clokey.global.paging.SortDirection; import org.clokey.global.util.MemberUtil; import org.clokey.lookbook.entity.LookBook; +import org.clokey.member.entity.Block; +import org.clokey.member.entity.Follow; import org.clokey.member.entity.Member; import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; @@ -60,6 +62,8 @@ class CoordinateServiceImplTest extends IntegrationTest { @Autowired private CoordinateRepository coordinateRepository; @Autowired private MemberRepository memberRepository; + @Autowired private BlockRepository blockRepository; + @Autowired private FollowRepository followRepository; @Autowired private CoordinateClothRepository coordinateClothRepository; @Autowired private CategoryRepository categoryRepository; @Autowired private ClothRepository clothRepository; @@ -1619,11 +1623,58 @@ void setUp() { } @Test - void 나의_코디가_아닌_경우_예외가_발생한다() { + void 타인의_공개_코디면_조회할_수_있다() { + // when + CoordinatePreviewResponse response = coordinateService.getCoordinatePreview(2L); + + // then + assertThat(response) + .extracting("coordinateId", "imageUrl", "coordinateName", "coordinateMemo") + .containsExactly(2L, "testImageUrl2", "testName2", "testMemo2"); + } + + @Test + void 타인의_비공개_코디이고_팔로우하지_않으면_예외가_발생한다() { + // given + Member member = memberRepository.findById(2L).orElseThrow(); + member.changeVisibility(); + memberRepository.saveAndFlush(member); + // when & then assertThatThrownBy(() -> coordinateService.getCoordinatePreview(2L)) .isInstanceOf(BaseCustomException.class) - .hasMessage(CoordinateErrorCode.NOT_COORDINATE_OWNER.getMessage()); + .hasMessage(MemberErrorCode.PRIVATE_MEMBER_ACCESS_DENIED.getMessage()); + } + + @Test + void 타인의_비공개_코디여도_승인된_팔로우면_조회할_수_있다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + targetMember.changeVisibility(); + memberRepository.saveAndFlush(targetMember); + followRepository.save(Follow.createFollow(currentMember, targetMember)); + + // when + CoordinatePreviewResponse response = coordinateService.getCoordinatePreview(2L); + + // then + assertThat(response) + .extracting("coordinateId", "imageUrl", "coordinateName", "coordinateMemo") + .containsExactly(2L, "testImageUrl2", "testName2", "testMemo2"); + } + + @Test + void 차단_관계의_타인_코디면_예외가_발생한다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + blockRepository.save(Block.createBlock(currentMember, targetMember)); + + // when & then + assertThatThrownBy(() -> coordinateService.getCoordinatePreview(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED.getMessage()); } @Test @@ -1719,6 +1770,7 @@ void setUp() { CoordinateDetailsListResponse::ratio, CoordinateDetailsListResponse::degree, CoordinateDetailsListResponse::order, + CoordinateDetailsListResponse::clothId, CoordinateDetailsListResponse::imageUrl, CoordinateDetailsListResponse::brand, CoordinateDetailsListResponse::name, @@ -1732,6 +1784,7 @@ void setUp() { 1.5, 240.1, 1, + 1L, "testImageUrl1", null, null, @@ -1744,6 +1797,7 @@ void setUp() { 1.5, 240.1, 2, + 2L, "testImageUrl2", null, null, @@ -1760,11 +1814,56 @@ void setUp() { } @Test - void 나의_코디가_아닌_경우_예외가_발생한다() { + void 타인의_공개_코디면_Details를_조회할_수_있다() { + // when + List response = + coordinateService.getCoordinateDetails(2L); + + // then + assertThat(response).isEmpty(); + } + + @Test + void 타인의_비공개_코디이고_팔로우하지_않으면_예외가_발생한다() { + // given + Member member = memberRepository.findById(2L).orElseThrow(); + member.changeVisibility(); + memberRepository.saveAndFlush(member); + // when & then assertThatThrownBy(() -> coordinateService.getCoordinateDetails(2L)) .isInstanceOf(BaseCustomException.class) - .hasMessage(CoordinateErrorCode.NOT_COORDINATE_OWNER.getMessage()); + .hasMessage(MemberErrorCode.PRIVATE_MEMBER_ACCESS_DENIED.getMessage()); + } + + @Test + void 타인의_비공개_코디여도_승인된_팔로우면_Details를_조회할_수_있다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + targetMember.changeVisibility(); + memberRepository.saveAndFlush(targetMember); + followRepository.save(Follow.createFollow(currentMember, targetMember)); + + // when + List response = + coordinateService.getCoordinateDetails(2L); + + // then + assertThat(response).isEmpty(); + } + + @Test + void 차단_관계의_타인_코디면_예외가_발생한다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + blockRepository.save(Block.createBlock(currentMember, targetMember)); + + // when & then + assertThatThrownBy(() -> coordinateService.getCoordinateDetails(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED.getMessage()); } @Test @@ -1945,111 +2044,261 @@ void setUp() { @Test void 유효한_요청이면_좋아요한_코디를_반환한다() { // when - List responses = coordinateService.getFavoriteCoordinates(); + List responses = + coordinateService.getFavoriteCoordinates(null); // then assertThat(responses) - .extracting("coordinateId", "imageUrl") - .containsExactly(tuple(1L, "testUrl1"), tuple(2L, "testUrl2")); + .extracting("coordinateId", "imageUrl", "coordinateName") + .containsExactly( + tuple(1L, "testUrl1", "testName1"), tuple(2L, "testUrl2", "testName2")); } @Test - void 좋아요한_코디가_없는_경우_빈_리스틀_반환한다() { + void 좋아요한_코디가_없는_경우_빈_리스트를_반환한다() { // given Member member = memberRepository.findById(2L).orElseThrow(); given(memberUtil.getCurrentMember()).willReturn(member); // when - List responses = coordinateService.getFavoriteCoordinates(); + List responses = + coordinateService.getFavoriteCoordinates(null); // then assertThat(responses).isEmpty(); } + + @Test + void memberId를_전달하면_해당_회원의_좋아요한_코디를_반환한다() { + // when + List responses = + coordinateService.getFavoriteCoordinates(1L); + + // then + assertThat(responses) + .extracting("coordinateId", "imageUrl", "coordinateName") + .containsExactly( + tuple(1L, "testUrl1", "testName1"), tuple(2L, "testUrl2", "testName2")); + } + + @Test + void 비공개_계정의_memberId를_팔로우하지_않고_조회하면_예외가_발생한다() { + // given + Member member = memberRepository.findById(2L).orElseThrow(); + member.changeVisibility(); + memberRepository.saveAndFlush(member); + + // when & then + assertThatThrownBy(() -> coordinateService.getFavoriteCoordinates(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.PRIVATE_MEMBER_ACCESS_DENIED.getMessage()); + } + + @Test + void 비공개_계정의_memberId여도_승인된_팔로우면_조회할_수_있다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + targetMember.changeVisibility(); + memberRepository.saveAndFlush(targetMember); + followRepository.save(Follow.createFollow(currentMember, targetMember)); + + LookBook lookBook = lookBookRepository.findById(2L).orElseThrow(); + Coordinate targetLikedCoordinate = + Coordinate.createCoordinateManual( + "targetLike", "memo", "targetUrl", targetMember, lookBook); + targetLikedCoordinate.toggleLike(); + coordinateRepository.saveAndFlush(targetLikedCoordinate); + + // when + List responses = + coordinateService.getFavoriteCoordinates(2L); + + // then + assertThat(responses) + .extracting("coordinateId", "imageUrl", "coordinateName") + .containsExactly( + tuple(targetLikedCoordinate.getId(), "targetUrl", "targetLike")); + } + + @Test + void 차단_관계의_memberId로_조회하면_예외가_발생한다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + blockRepository.save(Block.createBlock(currentMember, targetMember)); + + // when & then + assertThatThrownBy(() -> coordinateService.getFavoriteCoordinates(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED.getMessage()); + } } @Nested - class 오늘의_코디를_조회할_때 { + class 오늘의_코디_Preview를_조회할_때 { @BeforeEach void setUp() { Member member1 = Member.createMember( - "todayEmail1", - "todayNickName1", - OauthInfo.createOauthInfo("todayOauthId1", OauthProvider.KAKAO)); + "testEmail1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); Member member2 = Member.createMember( - "todayEmail2", - "todayNickName2", - OauthInfo.createOauthInfo("todayOauthId2", OauthProvider.KAKAO)); + "testEmail2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + memberRepository.saveAll(List.of(member1, member2)); given(memberUtil.getCurrentMember()).willReturn(member1); - Category parentCategory = Category.createCategory("parentCategory", null); - Category category = Category.createCategory("category", parentCategory); + Coordinate coordinate1 = Coordinate.createDailyCoordinate("testImageUrl1", member1); + coordinateRepository.save(coordinate1); + } + + @Test + void 유효한_요청이면_Preview를_반환한다() { + // when + DailyCoordinatePreviewResponse response = coordinateService.getTodayCoordinatePreview(); + + // then + assertThat(response) + .extracting("coordinateId", "imageUrl", "date") + .containsExactly(1L, "testImageUrl1", LocalDate.now(ZoneId.of("Asia/Seoul"))); + } + + @Test + void 오늘의_코디가_존재하지_않으면_예외가_발생한다() { + // given + Member member = memberRepository.findById(2L).orElseThrow(); + given(memberUtil.getCurrentMember()).willReturn(member); + + // when & then + assertThatThrownBy(() -> coordinateService.getTodayCoordinatePreview()) + .isInstanceOf(BaseCustomException.class) + .hasMessage(CoordinateErrorCode.DAILY_COORDINATE_NOT_FOUND.getMessage()); + } + } + + @Nested + class 오늘의_코디_Details를_조회할_때 { + + @BeforeEach + void setUp() { + Member member1 = + Member.createMember( + "testEmail1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + Member member2 = + Member.createMember( + "testEmail2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + + memberRepository.saveAll(List.of(member1, member2)); + given(memberUtil.getCurrentMember()).willReturn(member1); + + Coordinate coordinate1 = Coordinate.createDailyCoordinate("testImageUrl1", member1); + coordinateRepository.save(coordinate1); + + Category parentCategory = Category.createCategory("testParentCategory", null); + Category category = Category.createCategory("testCategory", parentCategory); categoryRepository.saveAll(List.of(parentCategory, category)); Cloth cloth1 = Cloth.createCloth( - "imageUrl1", + "testImageUrl1", + null, + null, null, - "name1", - "brand1", List.of(Season.SPRING), category, member1); Cloth cloth2 = Cloth.createCloth( - "imageUrl2", + "testImageUrl2", + null, + null, null, - "name2", - "brand2", List.of(Season.SPRING), category, member1); clothRepository.saveAll(List.of(cloth1, cloth2)); - Coordinate coordinate = Coordinate.createDailyCoordinate("coordImage", member1); - coordinateRepository.save(coordinate); - CoordinateCloth coordinateCloth1 = CoordinateCloth.createCoordinateCloth( - 10.0, 20.0, 1.0, 30.0, 1, coordinate, cloth1); + 50.1, 120.1, 1.5, 240.1, 1, coordinate1, cloth1); + CoordinateCloth coordinateCloth2 = CoordinateCloth.createCoordinateCloth( - 11.0, 21.0, 1.0, 31.0, 2, coordinate, cloth2); + 50.1, 120.1, 1.5, 240.1, 2, coordinate1, cloth2); + coordinateClothRepository.saveAll(List.of(coordinateCloth1, coordinateCloth2)); } @Test - void 유효한_요청이면_오늘의_코디를_반환한다() { + void 유효한_요청이면_코디_Details를_반환한다() { // when - List responses = - coordinateService.getTodayDailyCoordinateClothes(); + List response = + coordinateService.getTodayCoordinateDetails(); // then - assertThat(responses) + assertThat(response) .extracting( - DailyCoordinateClothResponse::imageUrl, - DailyCoordinateClothResponse::brand, - DailyCoordinateClothResponse::name, - DailyCoordinateClothResponse::category, - DailyCoordinateClothResponse::parentCategory) + CoordinateDetailsListResponse::coordinateClothId, + CoordinateDetailsListResponse::locationX, + CoordinateDetailsListResponse::locationY, + CoordinateDetailsListResponse::ratio, + CoordinateDetailsListResponse::degree, + CoordinateDetailsListResponse::order, + CoordinateDetailsListResponse::clothId, + CoordinateDetailsListResponse::imageUrl, + CoordinateDetailsListResponse::brand, + CoordinateDetailsListResponse::name, + CoordinateDetailsListResponse::category, + CoordinateDetailsListResponse::parentCategory) .containsExactly( - tuple("imageUrl1", "brand1", "name1", "category", "parentCategory"), - tuple("imageUrl2", "brand2", "name2", "category", "parentCategory")); + tuple( + 1L, + 50.1, + 120.1, + 1.5, + 240.1, + 1, + 1L, + "testImageUrl1", + null, + null, + "testCategory", + "testParentCategory"), + tuple( + 2L, + 50.1, + 120.1, + 1.5, + 240.1, + 2, + 2L, + "testImageUrl2", + null, + null, + "testCategory", + "testParentCategory")); } @Test - void 오늘의_코디가_없으면_빈_리스트를_반환한다() { + void 오늘의_코디가_존재하지_않으면_예외가_발생한다() { // given - Member member = memberRepository.findByNickname("todayNickName2").orElseThrow(); + Member member = memberRepository.findById(2L).orElseThrow(); given(memberUtil.getCurrentMember()).willReturn(member); - // when - List responses = - coordinateService.getTodayDailyCoordinateClothes(); - // then - assertThat(responses).isEmpty(); + + // when & then + assertThatThrownBy(() -> coordinateService.getTodayCoordinateDetails()) + .isInstanceOf(BaseCustomException.class) + .hasMessage(CoordinateErrorCode.DAILY_COORDINATE_NOT_FOUND.getMessage()); } } } diff --git a/clokey-api/src/test/java/org/clokey/domain/feed/service/FeedServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/feed/service/FeedServiceTest.java index 147b7ca5..347da3e9 100644 --- a/clokey-api/src/test/java/org/clokey/domain/feed/service/FeedServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/feed/service/FeedServiceTest.java @@ -59,20 +59,26 @@ class 전체_피드_조회할_때 { void setUp() { Member member1 = Member.createMember( - "testEmail10", - "testNickName10", - OauthInfo.createOauthInfo("oauth-current-10", OauthProvider.KAKAO)); + "testEmail1", + "testNickName1", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); Member member2 = Member.createMember( - "testEmail20", - "testNickName20", - OauthInfo.createOauthInfo("oauth-a-20", OauthProvider.KAKAO)); + "testEmail2", + "testNickName2", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); Member member3 = Member.createMember( - "testEmail30", - "testNickName30", - OauthInfo.createOauthInfo("oauth-b-30", OauthProvider.KAKAO)); - memberRepository.saveAll(List.of(member1, member2, member3)); + "testEmail3", + "testNickName3", + OauthInfo.createOauthInfo("testOauthId3", OauthProvider.KAKAO)); + Member member4 = + Member.createMember( + "testEmail4", + "testNickName4", + OauthInfo.createOauthInfo("testOauthId4", OauthProvider.KAKAO)); + member4.changeVisibility(); + memberRepository.saveAll(List.of(member1, member2, member3, member4)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation = Situation.createSituation("daily"); @@ -90,7 +96,12 @@ void setUp() { History.createHistory(LocalDate.of(2025, 1, 4), "B1", member3, situation); History history3_2 = History.createHistory(LocalDate.of(2025, 1, 5), "B2", member3, situation); - historyRepository.saveAll(List.of(history3_1, history3_2)); + + History history4_1 = + History.createHistory(LocalDate.of(2025, 1, 4), "C1", member4, situation); + History history4_2 = + History.createHistory(LocalDate.of(2025, 1, 5), "C2", member4, situation); + historyRepository.saveAll(List.of(history3_1, history3_2, history4_1, history4_2)); historyImageRepository.saveAll( List.of( @@ -98,7 +109,9 @@ void setUp() { HistoryImage.createHistoryImage("img-a2", history2_2), HistoryImage.createHistoryImage("img-a3", history2_3), HistoryImage.createHistoryImage("img-b1", history3_1), - HistoryImage.createHistoryImage("img-b2", history3_2))); + HistoryImage.createHistoryImage("img-b2", history3_2), + HistoryImage.createHistoryImage("img-c1", history4_1), + HistoryImage.createHistoryImage("img-c2", history4_2))); } @Test @@ -187,9 +200,10 @@ void setUp() { @Test void 차단한_사용자의_피드는_노출되지_않는다() { // given - Member member1 = memberRepository.findByNickname("testNickName10").orElseThrow(); - Member member2 = memberRepository.findByNickname("testNickName20").orElseThrow(); + Member member1 = memberRepository.findByNickname("testNickName1").orElseThrow(); + Member member2 = memberRepository.findByNickname("testNickName2").orElseThrow(); blockRepository.save(Block.createBlock(member1, member2)); + // when FeedListResponse response = feedService.getFeeds(FollowScope.ALL, List.of(), List.of(), 3, null); @@ -215,6 +229,36 @@ void setUp() { assertThat(response.items()).hasSize(2); assertThat(response.hasNext()).isFalse(); } + + @Test + void 비공개_계정의_피드는_전체_피드에서_노출되지_않는다() { + // when + FeedListResponse response = + feedService.getFeeds(FollowScope.ALL, List.of(), List.of(), 3, null); + + // then + Map historyIds = + historyRepository.findAll().stream() + .collect( + Collectors.toMap( + History::getContent, + History::getId, + (left, right) -> left)); + Long history3_1 = historyIds.get("B1"); + Long history3_2 = historyIds.get("B2"); + Long history2_3 = historyIds.get("A3"); + Long history4_1 = historyIds.get("C1"); + Long history4_2 = historyIds.get("C2"); + + assertThat(response.items()).hasSize(3); + List feedIds = + response.items().stream() + .map(FeedListResponse.FeedItemResponse::feedId) + .toList(); + assertThat(feedIds).containsExactly(history3_2, history2_3, history3_1); + assertThat(feedIds).doesNotContain(history4_1, history4_2); + assertThat(response.hasNext()).isTrue(); + } } @Nested @@ -237,7 +281,13 @@ void setUp() { "testEmail3", "testNickName3", OauthInfo.createOauthInfo("oauth-b", OauthProvider.KAKAO)); - memberRepository.saveAll(List.of(member1, member2, member3)); + Member member4 = + Member.createMember( + "testEmail4", + "testNickName4", + OauthInfo.createOauthInfo("testOauthId4", OauthProvider.KAKAO)); + member4.changeVisibility(); + memberRepository.saveAll(List.of(member1, member2, member3, member4)); given(memberUtil.getCurrentMember()).willReturn(member1); Situation situation = Situation.createSituation("daily"); @@ -255,7 +305,11 @@ void setUp() { History.createHistory(LocalDate.of(2025, 1, 4), "B1", member3, situation); History history3_2 = History.createHistory(LocalDate.of(2025, 1, 5), "B2", member3, situation); - historyRepository.saveAll(List.of(history3_1, history3_2)); + History history4_1 = + History.createHistory(LocalDate.of(2025, 1, 4), "C1", member4, situation); + History history4_2 = + History.createHistory(LocalDate.of(2025, 1, 5), "C2", member4, situation); + historyRepository.saveAll(List.of(history3_1, history3_2, history4_1, history4_2)); historyImageRepository.saveAll( List.of( @@ -263,12 +317,15 @@ void setUp() { HistoryImage.createHistoryImage("img-a2", history2_2), HistoryImage.createHistoryImage("img-a3", history2_3), HistoryImage.createHistoryImage("img-b1", history3_1), - HistoryImage.createHistoryImage("img-b2", history3_2))); + HistoryImage.createHistoryImage("img-b2", history3_2), + HistoryImage.createHistoryImage("img-c1", history4_1), + HistoryImage.createHistoryImage("img-c2", history4_2))); followRepository.saveAll( List.of( Follow.createFollow(member1, member2), - Follow.createFollow(member1, member3))); + Follow.createFollow(member1, member3), + Follow.createFollow(member1, member4))); memberLikeRepository.save(MemberLike.createMemberLike(member1, history3_2)); } @@ -331,5 +388,35 @@ void setUp() { assertThat(second.hasNext()).isFalse(); assertThat(second.nextCursor()).isNull(); } + + @Test + void 비공개_계정의_피드는_팔로우하더라도_노출되지_않는다() { + // when + FeedListResponse response = + feedService.getFeeds(FollowScope.FOLLOWING, List.of(), List.of(), 3, null); + + // then + Map historyIds = + historyRepository.findAll().stream() + .collect( + Collectors.toMap( + History::getContent, + History::getId, + (left, right) -> left)); + Long history3_1 = historyIds.get("B1"); + Long history3_2 = historyIds.get("B2"); + Long history2_3 = historyIds.get("A3"); + Long history4_1 = historyIds.get("C1"); + Long history4_2 = historyIds.get("C2"); + + assertThat(response.items()).hasSize(3); + List feedIds = + response.items().stream() + .map(FeedListResponse.FeedItemResponse::feedId) + .toList(); + assertThat(feedIds).containsExactly(history3_2, history3_1, history2_3); + assertThat(feedIds).doesNotContain(history4_1, history4_2); + assertThat(response.hasNext()).isTrue(); + } } } diff --git a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java index 1846fe1b..d623429c 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java @@ -556,7 +556,7 @@ class 기록_수정_요청_시 { // when & then ResultActions perform = mockMvc.perform( - patch("/histories/1") + put("/histories/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); @@ -586,7 +586,7 @@ class 기록_수정_요청_시 { ResultActions perform = mockMvc.perform( - patch("/histories/1") + put("/histories/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); @@ -605,7 +605,7 @@ class 기록_수정_요청_시 { ResultActions perform = mockMvc.perform( - patch("/histories/1") + put("/histories/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); @@ -628,7 +628,7 @@ class 기록_수정_요청_시 { ResultActions perform = mockMvc.perform( - patch("/histories/1") + put("/histories/1") .contentType(MediaType.APPLICATION_JSON) .content(objectMapper.writeValueAsString(request))); diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index 7e01d525..c3889978 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -646,6 +646,32 @@ void setUp() { .isInstanceOf(BaseCustomException.class) .hasMessage(ClothErrorCode.NOT_CLOTH_OWNER.getMessage()); } + + @Test + void 기록에_중복된_이미지_URL이_존재할_때_수정_요청하면_성공해야_한다() { + // given + // 강제로 중복 이미지 생성 + History history = + transactionUtil.getResult(() -> historyRepository.findById(1L).orElseThrow()); + HistoryImage duplicateImage = HistoryImage.createHistoryImage("image1", history); + historyImageRepository.save(duplicateImage); + + HistoryUpdateRequest request = + new HistoryUpdateRequest( + "new content ", + 2L, + List.of(2L, 3L), + List.of("testHashtag2", "newHash"), + List.of( + new HistoryUpdateRequest.Payload( + "image1", + List.of( + new HistoryUpdateRequest.ClothTag( + 2L, 0.5, 0.6))))); + + // when & then + historyService.updateHistory(1L, request); + } } @Nested diff --git a/clokey-api/src/test/java/org/clokey/domain/lookbook/service/LookBookServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/lookbook/service/LookBookServiceTest.java index 9ebb1819..33f1a384 100644 --- a/clokey-api/src/test/java/org/clokey/domain/lookbook/service/LookBookServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/lookbook/service/LookBookServiceTest.java @@ -221,6 +221,9 @@ void setUp() { // then Assertions.assertAll( + // 룩북 자체가 삭제된다. + () -> assertThat(lookBookRepository.findById(1L)).isEmpty(), + // 룩북에서 생성된 코디는 삭제, 오늘의 코디는 삭제되지 않고 룩북에서만 제거된다. () -> assertThat(coordinateRepository.findAllById(List.of(1L, 2L))).isEmpty(), () -> diff --git a/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java index fd34a6e3..bd27d2ca 100644 --- a/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/member/controller/MemberControllerTest.java @@ -1,5 +1,6 @@ package org.clokey.domain.member.controller; +import static org.hamcrest.Matchers.containsString; import static org.mockito.ArgumentMatchers.any; import static org.mockito.BDDMockito.given; import static org.mockito.BDDMockito.willDoNothing; @@ -9,7 +10,7 @@ import com.fasterxml.jackson.databind.ObjectMapper; import java.util.List; -import org.clokey.domain.member.dto.request.DuplicatedIdCheckRequest; +import org.clokey.domain.member.dto.request.DuplicatedNicknameCheckRequest; import org.clokey.domain.member.dto.request.ProfileUpdateRequest; import org.clokey.domain.member.dto.response.*; import org.clokey.domain.member.service.MemberService; @@ -48,7 +49,7 @@ class 프로필_수정_요청_시 { // given ProfileUpdateRequest request = new ProfileUpdateRequest( - "testNickname", + "testnickname", "testBio", Visibility.PUBLIC, "https://img.example.com/bg.jpg"); @@ -94,7 +95,9 @@ class 프로필_수정_요청_시 { .andExpect(jsonPath("$.isSuccess").value(false)) .andExpect(jsonPath("$.code").value("COMMON400")) .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) - .andExpect(jsonPath("$.result.nickname").value("닉네임은 비워둘 수 없습니다.")); + .andExpect( + jsonPath("$.result.nickname") + .value(containsString("닉네임은 비워둘 수 없습니다."))); } @ParameterizedTest @@ -121,7 +124,9 @@ class 프로필_수정_요청_시 { .andExpect(jsonPath("$.isSuccess").value(false)) .andExpect(jsonPath("$.code").value("COMMON400")) .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) - .andExpect(jsonPath("$.result.nickname").value("닉네임은 비워둘 수 없습니다.")); + .andExpect( + jsonPath("$.result.nickname") + .value(containsString("닉네임은 비워둘 수 없습니다."))); } @Test @@ -130,7 +135,7 @@ class 프로필_수정_요청_시 { String longBio = "a".repeat(101); ProfileUpdateRequest request = new ProfileUpdateRequest( - "testNickname", + "testnickname", longBio, Visibility.PRIVATE, "https://img.example.com/bg.jpg"); @@ -149,6 +154,59 @@ class 프로필_수정_요청_시 { .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) .andExpect(jsonPath("$.result.bio").value("바이오는 100자를 넘길 수 없습니다.")); } + + @ParameterizedTest + @ValueSource(strings = {"clokey clokey", "CLOKEY", "clokey-user"}) + void 닉네임_패턴을_위배하면_예외가_발생한다(String nickname) throws Exception { + // given + ProfileUpdateRequest request = + new ProfileUpdateRequest( + nickname, + "testBio", + Visibility.PRIVATE, + "https://img.example.com/bg.jpg"); + + // when + ResultActions perform = + mockMvc.perform( + patch("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect( + jsonPath("$.result.nickname") + .value("닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.")); + } + + @Test + void 닉네임이_20자를_초과하면_예외가_발생한다() throws Exception { + // given + ProfileUpdateRequest request = + new ProfileUpdateRequest( + "abcdefghijklmnopqrstu", + "testBio", + Visibility.PRIVATE, + "https://img.example.com/bg.jpg"); + + // when + ResultActions perform = + mockMvc.perform( + patch("/users") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.result.nickname").value("닉네임은 20자 이하여야 합니다.")); + } } @Nested @@ -157,7 +215,8 @@ class 아이디_중복확인_요청_시 { @Test void 유효한_요청이면_중복_여부를_반환한다() throws Exception { // given - DuplicatedIdCheckRequest request = new DuplicatedIdCheckRequest("test_clokey_id"); + DuplicatedNicknameCheckRequest request = + new DuplicatedNicknameCheckRequest("clokey.홍길동"); DuplicatedIdCheckResponse response = new DuplicatedIdCheckResponse(true); given(memberService.checkDuplicateNickname(request)).willReturn(response); @@ -180,7 +239,7 @@ class 아이디_중복확인_요청_시 { @Test void 닉네임이_null이면_예외가_발생한다() throws Exception { // given - DuplicatedIdCheckRequest request = new DuplicatedIdCheckRequest(null); + DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(null); // when ResultActions perform = @@ -197,12 +256,36 @@ class 아이디_중복확인_요청_시 { .andExpect(jsonPath("$.result.nickname").value("닉네임은 비워둘 수 없습니다.")); } - // 허용 종류 : 영문(소문자) , 숫자, 언더바(_), 점(.) @ParameterizedTest - @ValueSource(strings = {"clokey clokey", "CLOKEY", "클로키", "clokey-user", "clokey,,user^^"}) + @NullAndEmptySource + @ValueSource(strings = {" "}) + void 닉네임이_비어있으면_예외가_발생한다(String nickname) throws Exception { + // given + DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); + + // when + ResultActions perform = + mockMvc.perform( + post("/users/check-duplicate-nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect( + jsonPath("$.result.nickname") + .value(containsString("닉네임은 비워둘 수 없습니다."))); + } + + // 허용 종류 : 영어 소문자, 숫자, 한글, 언더바(_), 점(.) + @ParameterizedTest + @ValueSource(strings = {"clokey clokey", "CLOKEY", "clokey-user", "clokey,,user^^"}) void 닉네임_제약조건을_위배하면_예외가_발생한다(String nickname) throws Exception { // given - DuplicatedIdCheckRequest request = new DuplicatedIdCheckRequest(nickname); + DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); // when ResultActions perform = @@ -218,7 +301,51 @@ class 아이디_중복확인_요청_시 { .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) .andExpect( jsonPath("$.result.nickname") - .value("닉네임은 영어 소문자, 숫자, 언더바(_), 점(.)만 허용됩니다.")); + .value("닉네임은 영어 소문자, 숫자, 한글, 언더바(_), 점(.)만 허용됩니다.")); + } + + @Test + void 닉네임이_20자를_초과하면_예외가_발생한다() throws Exception { + // given + DuplicatedNicknameCheckRequest request = + new DuplicatedNicknameCheckRequest("abcdefghijklmnopqrstu"); + + // when + ResultActions perform = + mockMvc.perform( + post("/users/check-duplicate-nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isBadRequest()) + .andExpect(jsonPath("$.isSuccess").value(false)) + .andExpect(jsonPath("$.code").value("COMMON400")) + .andExpect(jsonPath("$.message").value("잘못된 요청입니다.")) + .andExpect(jsonPath("$.result.nickname").value("닉네임은 20자 이하여야 합니다.")); + } + + @ParameterizedTest + @ValueSource(strings = {"clokey", "홍길동", "clokey.홍길동", "abc_def", "clokey1"}) + void 닉네임_제약조건을_만족하면_중복_여부를_반환한다(String nickname) throws Exception { + // given + DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); + DuplicatedIdCheckResponse response = new DuplicatedIdCheckResponse(false); + given(memberService.checkDuplicateNickname(request)).willReturn(response); + + // when + ResultActions perform = + mockMvc.perform( + post("/users/check-duplicate-nickname") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))); + + // then + perform.andExpect(status().isOk()) + .andExpect(jsonPath("$.isSuccess").value(true)) + .andExpect(jsonPath("$.code").value("COMMON200")) + .andExpect(jsonPath("$.message").value("성공입니다.")) + .andExpect(jsonPath("$.result.duplicated").value(false)); } } diff --git a/clokey-api/src/test/java/org/clokey/domain/member/service/MemberServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/member/service/MemberServiceTest.java index 1a751332..5fdedf9e 100644 --- a/clokey-api/src/test/java/org/clokey/domain/member/service/MemberServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/member/service/MemberServiceTest.java @@ -7,7 +7,7 @@ import java.util.Optional; import org.clokey.IntegrationTest; import org.clokey.TransactionUtil; -import org.clokey.domain.member.dto.request.DuplicatedIdCheckRequest; +import org.clokey.domain.member.dto.request.DuplicatedNicknameCheckRequest; import org.clokey.domain.member.dto.request.ProfileUpdateRequest; import org.clokey.domain.member.dto.response.BlockedMemberResponse; import org.clokey.domain.member.dto.response.FollowMemberResponse; @@ -135,7 +135,7 @@ void setUp() { @ValueSource(strings = {"testNickname1", "distinctId1", "distinctId2"}) void 현재_닉네임_또는_중복되지_않는_닉네임을_입력하면_false를_반환한다(String nickname) { // given - DuplicatedIdCheckRequest request = new DuplicatedIdCheckRequest(nickname); + DuplicatedNicknameCheckRequest request = new DuplicatedNicknameCheckRequest(nickname); // when& then assertThat(memberService.checkDuplicateNickname(request).duplicated()).isFalse(); @@ -144,7 +144,8 @@ void setUp() { @Test void 중복되는_닉네임을_입력한_경우_true를_반환한다() { // given - DuplicatedIdCheckRequest request = new DuplicatedIdCheckRequest("testNickname2"); + DuplicatedNicknameCheckRequest request = + new DuplicatedNicknameCheckRequest("testNickname2"); // when& then assertThat(memberService.checkDuplicateNickname(request).duplicated()).isTrue(); @@ -787,6 +788,27 @@ void setUp() { .isInstanceOf(BaseCustomException.class) .hasMessage(MemberErrorCode.MEMBER_NOT_FOUND.getMessage()); } + + @Test + void 내가_차단한_회원의_정보를_조회하면_예외가_발생한다() { + // when & then + assertThatThrownBy(() -> memberService.getMemberInfo(3L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED.getMessage()); + } + + @Test + void 나를_차단한_회원의_정보를_조회하면_예외가_발생한다() { + // given + Member currentMember = memberRepository.findById(1L).orElseThrow(); + Member targetMember = memberRepository.findById(2L).orElseThrow(); + blockRepository.save(Block.createBlock(targetMember, currentMember)); + + // when & then + assertThatThrownBy(() -> memberService.getMemberInfo(2L)) + .isInstanceOf(BaseCustomException.class) + .hasMessage(MemberErrorCode.BLOCKED_MEMBER_ACCESS_DENIED.getMessage()); + } } @Nested diff --git a/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java index e55323e8..9bfc362d 100644 --- a/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/notification/controller/NotificationControllerTest.java @@ -13,6 +13,7 @@ import org.clokey.domain.notification.dto.response.NotificationListResponse; import org.clokey.domain.notification.dto.response.UnreadNotificationResponse; import org.clokey.domain.notification.service.CodiveNotificationService; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; import org.clokey.response.SliceResponse; @@ -71,16 +72,18 @@ class 알림_목록_조회할_시 { 2L, "https://image.example", "테스트 알림2 내용입니다.", - "2", - RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT, + new NotificationListResponse.NotificationActionResponse( + RedirectType.HISTORY_REDIRECT, "2"), ReadStatus.NOT_READ, LocalDateTime.now()), new NotificationListResponse( 1L, "https://image.example", "테스트 알림1 내용입니다.", - "2", - RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW, + new NotificationListResponse.NotificationActionResponse( + RedirectType.MEMBER_REDIRECT, "2"), ReadStatus.READ, LocalDateTime.now())); @@ -95,7 +98,17 @@ class 알림_목록_조회할_시 { .andExpect(jsonPath("$.code").value("COMMON200")) .andExpect(jsonPath("$.message").value("성공입니다.")) .andExpect(jsonPath("$.result.content[0].notificationId").value("2")) + .andExpect(jsonPath("$.result.content[0].notificationType").value("COMMENT")) + .andExpect( + jsonPath("$.result.content[0].action.redirectType") + .value("HISTORY_REDIRECT")) + .andExpect(jsonPath("$.result.content[0].action.redirectInfo").value("2")) .andExpect(jsonPath("$.result.content[1].notificationId").value("1")) + .andExpect(jsonPath("$.result.content[1].notificationType").value("FOLLOW")) + .andExpect( + jsonPath("$.result.content[1].action.redirectType") + .value("MEMBER_REDIRECT")) + .andExpect(jsonPath("$.result.content[1].action.redirectInfo").value("2")) .andExpect(jsonPath("$.result.isLast").value(true)); } diff --git a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java index f9aef85e..045cfcef 100644 --- a/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/notification/service/NotificationServiceTest.java @@ -20,6 +20,7 @@ import org.clokey.domain.comment.service.CommentService; import org.clokey.domain.history.repository.HistoryRepository; import org.clokey.domain.history.repository.SituationRepository; +import org.clokey.domain.like.event.NewLikeEvent; import org.clokey.domain.member.event.NewFollowerEvent; import org.clokey.domain.member.event.NewPendingFollowerEvent; import org.clokey.domain.member.repository.MemberRepository; @@ -40,6 +41,7 @@ import org.clokey.member.enums.OauthProvider; import org.clokey.member.enums.Visibility; import org.clokey.notification.entity.CodiveNotification; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; import org.clokey.response.SliceResponse; @@ -329,6 +331,51 @@ void setUp() { } } + @Nested + class 기록에_좋아요가_달렸을_때 { + + @BeforeEach + void setUp() { + Member liker = + Member.createMember( + "testEmail1", + "liker", + OauthInfo.createOauthInfo("testOauthId1", OauthProvider.KAKAO)); + liker.updateProfile("liker", "example.com", "한줄소개~", Visibility.PUBLIC); + Member receiver = + Member.createMember( + "testEmail2", + "receiver", + OauthInfo.createOauthInfo("testOauthId2", OauthProvider.KAKAO)); + receiver.updateDeviceToken("test-device-token-for-member2"); + memberRepository.saveAll(List.of(liker, receiver)); + + MemberTerm mockAgreement = Mockito.mock(MemberTerm.class); + given(mockAgreement.isAgreed()).willReturn(true); + + given( + memberTermRepository.findByMemberIdAndTermId( + eq(receiver.getId()), + eq(TermInfo.PUSH_NOTIFICATION_RECEIVE.getId()))) + .willReturn(Optional.of(mockAgreement)); + } + + @Test + void 유효한_요청이면_새_좋아요_알림을_저장한다() { + // given + NewLikeEvent event = new NewLikeEvent(1L, 1L, 2L, 1L, "liker", "example.com"); + + // when + notificationService.sendNewLikeNotification(event); + + // then + Optional notification = notificationRepository.findById(1L); + + assertThat(notification.get().getMember().getId()).isEqualTo(2L); + assertThat(notification.get().getContent()).isEqualTo("liker님이 회원님의 기록을 좋아합니다."); + } + } + @Nested class 안읽은_알림_여부를_확인할_때 { @@ -354,7 +401,8 @@ void setUp() { "테스트 알림1", "http://testImageURl1.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); notification1.updateReadStatus(ReadStatus.READ); CodiveNotification notification2 = @@ -363,7 +411,8 @@ void setUp() { "테스트 알림2", "http://testImageURl2.test", "testNickname", - RedirectType.MEMBER_REDIRECT); + RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW); notification2.updateReadStatus(ReadStatus.READ); CodiveNotification notification3 = @@ -372,14 +421,16 @@ void setUp() { "테스트 알림3", "http://testImageURl3.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); CodiveNotification notification4 = CodiveNotification.createCodiveNotification( member2, "테스트 알림4", "http://testImageURl4.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); notification4.updateReadStatus(ReadStatus.READ); notificationRepository.saveAll( List.of(notification1, notification2, notification3, notification4)); @@ -433,7 +484,8 @@ void setUp() { "테스트 알림1", "http://testImageURl1.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); notification1.updateReadStatus(ReadStatus.READ); CodiveNotification notification2 = @@ -442,7 +494,8 @@ void setUp() { "테스트 알림2", "http://testImageURl2.test", "testNickname", - RedirectType.MEMBER_REDIRECT); + RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW); notification2.updateReadStatus(ReadStatus.READ); CodiveNotification notification3 = @@ -451,14 +504,16 @@ void setUp() { "테스트 알림3", "http://testImageURl3.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); CodiveNotification notification4 = CodiveNotification.createCodiveNotification( member2, "테스트 알림4", "http://testImageURl4.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); notification4.updateReadStatus(ReadStatus.READ); notificationRepository.saveAll( List.of(notification1, notification2, notification3, notification4)); @@ -492,6 +547,7 @@ class 알림_상태를_읽음으로_변경_요청했을_때 { @BeforeEach void setUp() { + Mockito.reset(firebaseMessaging); Member member1 = Member.createMember( "testEmail1", @@ -505,7 +561,8 @@ void setUp() { "테스트 알림1", "http://testImageURl1.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); notificationRepository.save(notification1); } @@ -547,21 +604,24 @@ void setUp() { "테스트 알림1", "http://testImageURl1.test", "1", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); CodiveNotification notification2 = CodiveNotification.createCodiveNotification( member1, "테스트 알림2", "http://testImageURl2.test", "2", - RedirectType.HISTORY_REDIRECT); + RedirectType.HISTORY_REDIRECT, + NotificationType.COMMENT); CodiveNotification notification3 = CodiveNotification.createCodiveNotification( member1, "테스트 알림3", "http://testImageURl3.test", "3", - RedirectType.MEMBER_REDIRECT); + RedirectType.MEMBER_REDIRECT, + NotificationType.FOLLOW); notificationRepository.saveAll(List.of(notification1, notification2, notification3)); } @@ -620,7 +680,8 @@ void setUp() { // then // firebaseMessaging.send() 메서드가 1번 호출되었는지 검증 - Mockito.verify(firebaseMessaging, Mockito.times(1)).send(Mockito.any(Message.class)); + Mockito.verify(firebaseMessaging, Mockito.timeout(1000).times(1)) + .send(Mockito.any(Message.class)); } @Test @@ -642,7 +703,8 @@ void setUp() { notificationService.sendNewTemperatureNotification(request); // then - Mockito.verify(firebaseMessaging, Mockito.times(0)).send(Mockito.any(Message.class)); + Mockito.verify(firebaseMessaging, Mockito.after(200).never()) + .send(Mockito.any(Message.class)); } } } diff --git a/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java b/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java index 0cb28765..be404f0e 100644 --- a/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java +++ b/clokey-common-web/src/main/java/org/clokey/response/BaseResponse.java @@ -4,6 +4,7 @@ import com.fasterxml.jackson.annotation.JsonProperty; import com.fasterxml.jackson.annotation.JsonPropertyOrder; import java.time.LocalDateTime; +import java.time.ZoneId; import org.clokey.code.BaseSuccessCode; import org.clokey.exception.BaseErrorCode; @@ -14,12 +15,15 @@ public record BaseResponse( String message, LocalDateTime timeStamp, @JsonInclude(JsonInclude.Include.NON_NULL) T result) { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + public static BaseResponse onSuccess(BaseSuccessCode code, T result) { return new BaseResponse<>( true, code.getReasonDto().code(), code.getReasonDto().message(), - LocalDateTime.now(), + LocalDateTime.now(KST), result); } @@ -28,11 +32,11 @@ public static BaseResponse onFailure(BaseErrorCode code, T result) { false, code.getErrorReason().code(), code.getErrorReason().message(), - LocalDateTime.now(), + LocalDateTime.now(KST), result); } public static BaseResponse onFailure(String code, String message, T data) { - return new BaseResponse<>(false, code, message, LocalDateTime.now(), data); + return new BaseResponse<>(false, code, message, LocalDateTime.now(KST), data); } } diff --git a/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java b/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java index 2973bb1a..dd07df03 100644 --- a/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java +++ b/clokey-domain/src/main/java/org/clokey/common/config/JpaAuditingConfig.java @@ -1,8 +1,21 @@ package org.clokey.common.config; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.Optional; +import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.data.auditing.DateTimeProvider; import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @Configuration -@EnableJpaAuditing -public class JpaAuditingConfig {} +@EnableJpaAuditing(dateTimeProviderRef = "kstDateTimeProvider") +public class JpaAuditingConfig { + + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + + @Bean + public DateTimeProvider kstDateTimeProvider() { + return () -> Optional.of(LocalDateTime.now(KST)); + } +} diff --git a/clokey-domain/src/main/java/org/clokey/member/entity/Member.java b/clokey-domain/src/main/java/org/clokey/member/entity/Member.java index 0d443a59..6a40ce75 100644 --- a/clokey-domain/src/main/java/org/clokey/member/entity/Member.java +++ b/clokey-domain/src/main/java/org/clokey/member/entity/Member.java @@ -3,6 +3,7 @@ import jakarta.persistence.*; import jakarta.validation.constraints.NotNull; import java.time.LocalDate; +import java.time.ZoneId; import java.util.ArrayList; import java.util.List; import lombok.AccessLevel; @@ -25,6 +26,8 @@ @NoArgsConstructor(access = AccessLevel.PROTECTED) public class Member extends BaseEntity { + private static final ZoneId KST = ZoneId.of("Asia/Seoul"); + @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @@ -140,6 +143,6 @@ public void activate() { public void deactivate() { this.memberStatus = MemberStatus.INACTIVE; - this.inactiveDate = LocalDate.now(); + this.inactiveDate = LocalDate.now(KST); } } diff --git a/clokey-domain/src/main/java/org/clokey/notification/entity/CodiveNotification.java b/clokey-domain/src/main/java/org/clokey/notification/entity/CodiveNotification.java index 42eb76f8..e81b2db5 100644 --- a/clokey-domain/src/main/java/org/clokey/notification/entity/CodiveNotification.java +++ b/clokey-domain/src/main/java/org/clokey/notification/entity/CodiveNotification.java @@ -5,6 +5,7 @@ import lombok.*; import org.clokey.common.model.BaseEntity; import org.clokey.member.entity.Member; +import org.clokey.notification.enums.NotificationType; import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; @@ -30,6 +31,10 @@ public class CodiveNotification extends BaseEntity { @Enumerated(EnumType.STRING) private RedirectType redirectType; + @NotNull + @Enumerated(EnumType.STRING) + private NotificationType notificationType; + @Enumerated(EnumType.STRING) @NotNull private ReadStatus readStatus; @@ -46,12 +51,14 @@ private CodiveNotification( String notificationImageUrl, String redirectInfo, RedirectType redirectType, + NotificationType notificationType, ReadStatus readStatus) { this.member = member; this.content = content; this.notificationImageUrl = notificationImageUrl; this.redirectInfo = redirectInfo; this.redirectType = redirectType; + this.notificationType = notificationType; this.readStatus = readStatus; } @@ -60,13 +67,15 @@ public static CodiveNotification createCodiveNotification( String content, String notificationImageUrl, String redirectInfo, - RedirectType redirectType) { + RedirectType redirectType, + NotificationType notificationType) { return CodiveNotification.builder() .member(member) .content(content) .notificationImageUrl(notificationImageUrl) .redirectInfo(redirectInfo) .redirectType(redirectType) + .notificationType(notificationType) .readStatus(ReadStatus.NOT_READ) .build(); } diff --git a/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java b/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java new file mode 100644 index 00000000..d4aa5937 --- /dev/null +++ b/clokey-domain/src/main/java/org/clokey/notification/enums/NotificationType.java @@ -0,0 +1,10 @@ +package org.clokey.notification.enums; + +public enum NotificationType { + FOLLOW, + FOLLOW_REQUEST, + COMMENT, + REPLY, + LIKE, + TEMPERATURE_DAILY +} diff --git a/clokey-domain/src/main/java/org/clokey/notification/enums/RedirectType.java b/clokey-domain/src/main/java/org/clokey/notification/enums/RedirectType.java index c7553b95..f1e98ba5 100644 --- a/clokey-domain/src/main/java/org/clokey/notification/enums/RedirectType.java +++ b/clokey-domain/src/main/java/org/clokey/notification/enums/RedirectType.java @@ -1,6 +1,7 @@ package org.clokey.notification.enums; public enum RedirectType { + NONE, HISTORY_REDIRECT, MEMBER_REDIRECT } diff --git a/clokey-domain/src/main/resources/db/migration/V5__add_notification_type_to_codive_notification.sql b/clokey-domain/src/main/resources/db/migration/V5__add_notification_type_to_codive_notification.sql new file mode 100644 index 00000000..31da0ecf --- /dev/null +++ b/clokey-domain/src/main/resources/db/migration/V5__add_notification_type_to_codive_notification.sql @@ -0,0 +1,17 @@ +ALTER TABLE codive_notification + ADD COLUMN notification_type VARCHAR(255) NOT NULL DEFAULT 'COMMENT' CHECK ( + notification_type IN ('FOLLOW', 'FOLLOW_REQUEST', 'COMMENT', 'REPLY', 'TEMPERATURE_DAILY') + ); + +UPDATE codive_notification +SET notification_type = + CASE + WHEN redirect_type = 'MEMBER_REDIRECT' THEN 'FOLLOW' + WHEN redirect_type = 'HISTORY_REDIRECT' THEN 'COMMENT' + ELSE 'COMMENT' + END; + +ALTER TABLE codive_notification + MODIFY redirect_type VARCHAR(255) NOT NULL CHECK ( + redirect_type IN ('NONE', 'HISTORY_REDIRECT', 'MEMBER_REDIRECT') + ); diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java index 95382964..341c4100 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java +++ b/clokey-infrastructure/src/main/java/org/clokey/util/WebClientUtil.java @@ -1,10 +1,13 @@ package org.clokey.util; +import java.util.concurrent.TimeUnit; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.clokey.properties.WebClientProperties; import org.springframework.stereotype.Component; import org.springframework.web.reactive.function.client.WebClient; +import org.springframework.web.reactive.function.client.WebClientRequestException; +import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; @Component @@ -16,8 +19,9 @@ public class WebClientUtil { private final WebClientProperties webClientProperties; public Mono postToAiServer(String path, T requestBody, Class responseType) { - WebClient webClient = - webClientBuilder.baseUrl("http://" + webClientProperties.aiServerIp()).build(); + final long startedAtNs = System.nanoTime(); + final String aiServerIp = webClientProperties.aiServerIp(); + WebClient webClient = webClientBuilder.baseUrl("http://" + aiServerIp).build(); return webClient .post() @@ -25,6 +29,54 @@ public Mono postToAiServer(String path, T requestBody, Class respon .bodyValue(requestBody) .retrieve() .bodyToMono(responseType) - .doOnError(error -> log.error("AI 서버 요청 실패: {}", error.getMessage(), error)); + .doOnError( + error -> + logAiCallFailure( + path, + aiServerIp, + responseType.getSimpleName(), + elapsedMillis(startedAtNs), + error)); + } + + private void logAiCallFailure( + String path, String aiServerIp, String responseType, long elapsedMs, Throwable error) { + if (error instanceof WebClientResponseException webClientResponseException) { + log.error( + "[web-client] AI 서버 응답 오류 - path: {}, aiServerIp: {}, responseType: {}, status: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + webClientResponseException.getStatusCode().value(), + elapsedMs, + webClientResponseException.getMessage(), + error); + return; + } + + if (error instanceof WebClientRequestException) { + log.error( + "[web-client] AI 서버 요청/연결 실패 - path: {}, aiServerIp: {}, responseType: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + elapsedMs, + error.getMessage(), + error); + return; + } + + log.error( + "[web-client] AI 서버 예상치 못한 오류 - path: {}, aiServerIp: {}, responseType: {}, elapsedMs: {}, message: {}", + path, + aiServerIp, + responseType, + elapsedMs, + error.getMessage(), + error); + } + + private long elapsedMillis(long startedAtNs) { + return TimeUnit.NANOSECONDS.toMillis(System.nanoTime() - startedAtNs); } } From e9586e787304d61fb71113d939e6b918410dde83 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 4 Mar 2026 02:20:46 -0600 Subject: [PATCH 15/28] =?UTF-8?q?feat:=20=ED=99=98=EA=B2=BD=EB=B3=84=20?= =?UTF-8?q?=EB=8F=84=EB=A9=94=EC=9D=B8=20=EB=B0=98=EC=98=81=20=EB=B0=8F=20?= =?UTF-8?q?=EB=B0=B0=ED=8F=AC=20=EC=9B=8C=ED=81=AC=ED=94=8C=EB=A1=9C?= =?UTF-8?q?=EC=9A=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 26 +++++++++++-------- .github/workflows/prod-cd.yml | 21 ++++++++++++--- .github/workflows/prod-openapi.yml | 5 +--- clokey-api/dev-compose.yml | 15 +++++++---- clokey-api/prod-compose.yml | 14 +++++++--- .../config/security/SecurityConfig.java | 13 +++++----- .../src/main/resources/application-dev.yml | 15 +++++++++-- .../src/main/resources/application-local.yml | 16 ++++++++++-- .../src/main/resources/application-prod.yml | 15 +++++++++-- 9 files changed, 101 insertions(+), 39 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 45aaf382..db153c97 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -1,8 +1,8 @@ name: Clokey-Dev CD on: - pull_request: - branches: [ "develop" ] + push: + branches: [ develop ] jobs: dev-cd: @@ -33,17 +33,19 @@ jobs: - name: Grant gradlew permission run: chmod +x ./gradlew - - name: Log in to DockerHub - uses: docker/login-action@v2 - with: - username: ${{ secrets.DOCKERHUB_USERNAME }} - password: ${{ secrets.DOCKERHUB_TOKEN }} - - name: Set up QEMU uses: docker/setup-qemu-action@v3 - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + with: + install: true + + - name: Log in to DockerHub + uses: docker/login-action@v2 + with: + username: ${{ secrets.DOCKERHUB_USERNAME }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build & Push App Image run: | @@ -79,10 +81,13 @@ jobs: username: ubuntu host: ${{ secrets.OCI_HOST }} key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} - envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + envs: DOCKERHUB_USERNAME,CODIVE_DEV_BASE_URL,CODIVE_PROD_BASE_URL,OCI_MYSQL_HEATWAVE_HOST,OCI_DEV_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_DEV_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=dev-app + + export CODIVE_DEV_BASE_URL=${{ secrets.CODIVE_DEV_BASE_URL }} + export CODIVE_PROD_BASE_URL=${{ secrets.CODIVE_PROD_BASE_URL }} export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} export OCI_DEV_MYSQL_DB_NAME=${{ secrets.OCI_DEV_MYSQL_DB_NAME }} @@ -138,5 +143,4 @@ jobs: docker-compose -f dev-compose.yml up -d echo "Cleaning up dangling Docker images..." - docker image prune -f - + docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index e1e60599..92afdf41 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -71,17 +71,29 @@ jobs: target: /home/ubuntu/ strip_components: 1 + - name: Set OCI private key env (multiline) + run: | + echo "OCI_PRIVATE_KEY<> $GITHUB_ENV + echo "${{ secrets.OCI_PRIVATE_KEY }}" >> $GITHUB_ENV + echo "ENDOFKEY" >> $GITHUB_ENV + + - name: Prepare OCI key base64 for Deploy script + run: echo "OCI_PRIVATE_KEY_B64=$(echo -n "$OCI_PRIVATE_KEY" | base64 -w 0)" >> $GITHUB_ENV + - name: Deploy App uses: appleboy/ssh-action@master with: username: ubuntu host: ${{ secrets.OCI_HOST }} key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} - envs: DOCKERHUB_USERNAME,OCI_MYSQL_HEATWAVE_HOST,OCI_PROD_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_PROD_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY + envs: DOCKERHUB_USERNAME,CODIVE_DEV_BASE_URL,CODIVE_PROD_BASE_URL,OCI_MYSQL_HEATWAVE_HOST,OCI_PROD_MYSQL_DB_NAME,OCI_MYSQL_USERNAME,OCI_MYSQL_PASSWORD,REDIS_PASSWORD,KAKAO_CLIENT_ID,KAKAO_CLIENT_SECRET,APPLE_CLIENT_ID,APPLE_CLIENT_SECRET,JWT_ACCESS_TOKEN_SECRET,JWT_REFRESH_TOKEN_SECRET,JWT_ACCESS_TOKEN_EXPIRATION_TIME,JWT_REFRESH_TOKEN_EXPIRATION_TIME,JWT_ISSUER,OCI_TENANCY_ID,OCI_USER_ID,OCI_FINGERPRINT,OCI_PRIVATE_KEY,OCI_REGION,OCI_PASSPHRASE,OCI_OBJECTSTORAGE_NAMESPACE,OCI_PROD_OBJECTSTORAGE_BUCKET,SWAGGER_USERNAME,SWAGGER_PASSWORD,FIREBASE_SA_JSON_B64,AI_SERVER_IP,CLOTH_INFERENCE_PATH,STYLE_INFERENCE_PATH,HISTORY_CLOTH_DETECT_PATH,MEILISEARCH_ENDPOINT,MEILISEARCH_KEY script: | export DOCKERHUB_USERNAME=${{ secrets.DOCKERHUB_USERNAME }} export DOCKER_TAG=prod-app + export CODIVE_DEV_BASE_URL=${{ secrets.CODIVE_DEV_BASE_URL }} + export CODIVE_PROD_BASE_URL=${{ secrets.CODIVE_PROD_BASE_URL }} + export OCI_MYSQL_HEATWAVE_HOST=${{ secrets.OCI_MYSQL_HEATWAVE_HOST }} export OCI_PROD_MYSQL_DB_NAME=${{ secrets.OCI_PROD_MYSQL_DB_NAME }} export OCI_MYSQL_USERNAME=${{ secrets.OCI_MYSQL_USERNAME }} @@ -125,6 +137,10 @@ jobs: echo "${{ secrets.FIREBASE_SA_JSON_B64 }}" | base64 -d | tee /home/ubuntu/secrets/firebase-sa.json > /dev/null chmod 600 /home/ubuntu/secrets/firebase-sa.json + echo '${{ env.OCI_PRIVATE_KEY_B64 }}' | base64 -d | tee /home/ubuntu/secrets/oci_key.pem > /dev/null + chmod 600 /home/ubuntu/secrets/oci_key.pem + export OCI_PRIVATE_KEY="$(cat /home/ubuntu/secrets/oci_key.pem)" + export FIREBASE_CREDENTIALS_PATH=/home/ubuntu/secrets/firebase-sa.json echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin @@ -133,5 +149,4 @@ jobs: docker compose -f prod-compose.yml up -d echo "Cleaning up dangling Docker images..." - docker image prune -f - + docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-openapi.yml b/.github/workflows/prod-openapi.yml index c2c1a81f..b1e60809 100644 --- a/.github/workflows/prod-openapi.yml +++ b/.github/workflows/prod-openapi.yml @@ -20,6 +20,7 @@ jobs: env: CODIVEAPI_BRANCH: main + PROD_OPENAPI_URL: "${{ secrets.CODIVE_PROD_BASE_URL }}/v3/api-docs" TARGET_PATH: Sources/CodiveAPI/openapi.yaml steps: @@ -29,8 +30,6 @@ jobs: fetch-depth: 1 - name: Wait for production server to be ready - env: - PROD_OPENAPI_URL: ${{ secrets.PROD_OPENAPI_URL }} run: | set -euo pipefail echo "Waiting for production OpenAPI to be ready..." @@ -49,8 +48,6 @@ jobs: - name: Fetch OpenAPI JSON from production - env: - PROD_OPENAPI_URL: ${{ secrets.PROD_OPENAPI_URL }} run: | set -euo pipefail echo "Fetching OpenAPI from: $PROD_OPENAPI_URL" diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 3295f9c4..65c812d3 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -11,7 +11,7 @@ services: ports: - "6379:6379" networks: - - app_network + - dev_app_network app: image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} @@ -23,6 +23,11 @@ services: - "8080:8080" environment: SPRING_PROFILES_ACTIVE: dev + TZ: Asia/Seoul + + # Domain + CODIVE_DEV_BASE_URL: ${CODIVE_DEV_BASE_URL} + CODIVE_PROD_BASE_URL: ${CODIVE_PROD_BASE_URL} # Database (MySQL HeatWave) OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} @@ -68,7 +73,7 @@ services: SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} - # MeiliSearch + # MeilieSearch MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} MEILISEARCH_KEY: ${MEILISEARCH_KEY} @@ -82,9 +87,9 @@ services: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro networks: - - app_network + - dev_app_network networks: - app_network: - name: app_network + dev_app_network: + name: dev_app_network driver: bridge diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index e49db653..db7397b4 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -11,7 +11,7 @@ services: ports: - "6380:6379" networks: - - app_network + - prod_app_network app: image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} @@ -23,6 +23,11 @@ services: - "8081:8080" environment: SPRING_PROFILES_ACTIVE: prod + TZ: Asia/Seoul + + # Domain + CODIVE_DEV_BASE_URL: ${CODIVE_DEV_BASE_URL} + CODIVE_PROD_BASE_URL: ${CODIVE_PROD_BASE_URL} # Database (MySQL HeatWave) OCI_MYSQL_HEATWAVE_HOST: ${OCI_MYSQL_HEATWAVE_HOST} @@ -82,8 +87,9 @@ services: - /home/ubuntu/secrets/firebase-sa.json:/run/secrets/firebase-sa.json:ro networks: - - app_network + - prod_app_network networks: - app_network: - external: true + prod_app_network: + name: prod_app_network + driver: bridge diff --git a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java index 8a595f96..dc693f07 100644 --- a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java +++ b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java @@ -9,7 +9,6 @@ import org.clokey.domain.auth.service.JwtTokenService; import org.clokey.global.security.AppleAwareOAuth2AuthorizationRequestResolver; import org.clokey.global.security.JwtAuthenticationFilter; -import org.clokey.helper.SpringEnvironmentHelper; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -38,7 +37,6 @@ @RequiredArgsConstructor public class SecurityConfig { - private final SpringEnvironmentHelper springEnvironmentHelper; private final CustomOAuth2UserService customOAuth2UserService; private final OidcLoginSuccessHandler oidcLoginSuccessHandler; @@ -48,6 +46,12 @@ public class SecurityConfig { @Value("${swagger.password:default}") private String swaggerPassword; + @Value("${app.base-url.dev}") + private String devBaseUrl; + + @Value("${app.base-url.prod}") + private String prodBaseUrl; + private void defaultFilterChain(HttpSecurity http) throws Exception { http.httpBasic(AbstractHttpConfigurer::disable) .formLogin(AbstractHttpConfigurer::disable) @@ -131,10 +135,7 @@ public SecurityFilterChain apiFilterChain( public CorsConfigurationSource corsConfigurationSource() { CorsConfiguration configuration = new CorsConfiguration(); configuration.setAllowedOriginPatterns( - List.of( - "http://localhost:3000", - "https://dev.clokey.store", - "https://prod.clokey.store")); + List.of("http://localhost:3000", devBaseUrl, prodBaseUrl)); configuration.setAllowedMethods( List.of("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS")); configuration.setAllowedHeaders(List.of("*")); diff --git a/clokey-api/src/main/resources/application-dev.yml b/clokey-api/src/main/resources/application-dev.yml index 67e4a236..4bd52cf2 100644 --- a/clokey-api/src/main/resources/application-dev.yml +++ b/clokey-api/src/main/resources/application-dev.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_DEV_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: validate open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true @@ -37,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.dev}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -46,10 +52,12 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.dev}/login/oauth2/code/apple" authorization-grant-type: authorization_code + client-authentication-method: client_secret_post scope: - openid + - email provider: apple provider: kakao: @@ -108,3 +116,6 @@ firebase: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} \ No newline at end of file diff --git a/clokey-api/src/main/resources/application-local.yml b/clokey-api/src/main/resources/application-local.yml index 5a7bedec..6e6f7194 100644 --- a/clokey-api/src/main/resources/application-local.yml +++ b/clokey-api/src/main/resources/application-local.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${MYSQL_HOST}:${MYSQL_PORT}/${DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: validate open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true @@ -37,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.dev}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -46,10 +52,12 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.dev}/login/oauth2/code/apple" authorization-grant-type: authorization_code + client-authentication-method: client_secret_post scope: - openid + - email provider: apple provider: kakao: @@ -106,6 +114,7 @@ logging: level: org.hibernate.SQL: DEBUG org.hibernate.orm.jdbc.bind: TRACE + org.springframework.security: DEBUG firebase: credentials-path: ${FIREBASE_CREDENTIALS_PATH} @@ -113,3 +122,6 @@ firebase: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} \ No newline at end of file diff --git a/clokey-api/src/main/resources/application-prod.yml b/clokey-api/src/main/resources/application-prod.yml index 13f95ab1..9765b377 100644 --- a/clokey-api/src/main/resources/application-prod.yml +++ b/clokey-api/src/main/resources/application-prod.yml @@ -1,4 +1,6 @@ spring: + jackson: + time-zone: Asia/Seoul datasource: driver-class-name: com.mysql.cj.jdbc.Driver url: jdbc:mysql://${OCI_MYSQL_HEATWAVE_HOST}:${MYSQL_PORT}/${OCI_PROD_MYSQL_DB_NAME}?serverTimezone=Asia/Seoul&characterEncoding=UTF-8 @@ -12,6 +14,10 @@ spring: hibernate: ddl-auto: none open-in-view: false + properties: + hibernate: + jdbc: + time_zone: Asia/Seoul flyway: enabled: true @@ -37,7 +43,7 @@ spring: kakao: client-id: ${KAKAO_CLIENT_ID} client-secret: ${KAKAO_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.prod}/login/oauth2/code/kakao" authorization-grant-type: authorization_code client-authentication-method: client_secret_post scope: @@ -46,10 +52,12 @@ spring: apple: client-id: ${APPLE_CLIENT_ID} client-secret: ${APPLE_CLIENT_SECRET} - redirect-uri: "{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${app.base-url.prod}/login/oauth2/code/apple" authorization-grant-type: authorization_code + client-authentication-method: client_secret_post scope: - openid + - email provider: apple provider: kakao: @@ -94,4 +102,7 @@ external: app: oauth: redirect-scheme: codive + base-url: + dev: ${CODIVE_DEV_BASE_URL} + prod: ${CODIVE_PROD_BASE_URL} From ce89d467578a6efadf256c7c06be94e7aab8accb Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Wed, 4 Mar 2026 20:47:09 -0600 Subject: [PATCH 16/28] =?UTF-8?q?fix:=20recreate=20=EA=B0=95=EC=A0=9C=20?= =?UTF-8?q?=EB=B0=8F=20redis=20=EB=B3=BC=EB=A5=A8=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 4 ++-- .github/workflows/prod-cd.yml | 2 +- clokey-api/dev-compose.yml | 5 +++++ clokey-api/prod-compose.yml | 5 +++++ 4 files changed, 13 insertions(+), 3 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index db153c97..2e170ecb 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -140,7 +140,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker-compose -f dev-compose.yml up -d - + docker-compose -f dev-compose.yml up -d --force-recreate + echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 92afdf41..43dd5f74 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -146,7 +146,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f prod-compose.yml up -d + docker-compose -f prod-compose.yml up -d --force-recreate echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 65c812d3..74e2a549 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -10,6 +10,8 @@ services: command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] ports: - "6379:6379" + volumes: + - redis_dev_data:/data networks: - dev_app_network @@ -93,3 +95,6 @@ networks: dev_app_network: name: dev_app_network driver: bridge + +volumes: + redis_dev_data: diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index db7397b4..614e24e5 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -10,6 +10,8 @@ services: command: ["sh", "-c", "if [ -n \"$$REDIS_PASSWORD\" ]; then exec redis-server --requirepass \"$$REDIS_PASSWORD\"; else exec redis-server; fi"] ports: - "6380:6379" + volumes: + - redis_prod_data:/data networks: - prod_app_network @@ -93,3 +95,6 @@ networks: prod_app_network: name: prod_app_network driver: bridge + +volumes: + redis_prod_data: From f504c5509de05ecb503d20d649dc206b46c5ddcb Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Mon, 8 Jun 2026 23:47:16 +0900 Subject: [PATCH 17/28] =?UTF-8?q?fix:=20docker=20compose=20v2=EB=A1=9C=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 2e170ecb..5daa01ff 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -140,7 +140,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker-compose -f dev-compose.yml up -d --force-recreate + docker compose -f clokey-api/dev-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index f15b8aa2..790cb6c2 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -146,7 +146,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker-compose -f prod-compose.yml up -d --force-recreate + docker compose -f clokey-api/prod-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file From e34207911622e22d02bb9691dcf4595e81a60332 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 9 Jun 2026 01:15:23 +0900 Subject: [PATCH 18/28] =?UTF-8?q?fix:=20strip=5Fcomponents=20=EC=A0=9C?= =?UTF-8?q?=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 1 - .github/workflows/prod-cd.yml | 1 - 2 files changed, 2 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 5daa01ff..be72d00a 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -64,7 +64,6 @@ jobs: key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} source: "clokey-api/dev-compose.yml" target: /home/ubuntu/ - strip_components: 1 - name: Set OCI private key env (multiline) run: | diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 790cb6c2..1312c2ad 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -69,7 +69,6 @@ jobs: key: ${{ secrets.OCI_INSTANCE_SSH_KEY }} source: "clokey-api/prod-compose.yml" target: /home/ubuntu/ - strip_components: 1 - name: Set OCI private key env (multiline) run: | From 6a895334cba96174eaceb361133307ca91e43e01 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 9 Jun 2026 01:44:17 +0900 Subject: [PATCH 19/28] =?UTF-8?q?fix:=20=ED=83=9C=EA=B7=B8=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- clokey-api/dev-compose.yml | 2 +- clokey-api/prod-compose.yml | 2 +- 4 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index be72d00a..8b71fe84 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -53,7 +53,7 @@ jobs: --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:dev-app \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2026:dev-app \ . - name: Copy dev-compose diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index 1312c2ad..b7bc2078 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -58,7 +58,7 @@ jobs: --platform linux/arm64 \ --push \ --file clokey-api/Dockerfile \ - --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2025:prod-app \ + --tag ${{ secrets.DOCKERHUB_USERNAME }}/clokey-docker-2026:prod-app \ . - name: Copy prod-compose diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 3e1c0156..67ef0494 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -16,7 +16,7 @@ services: - dev_app_network app: - image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} + image: ${DOCKERHUB_USERNAME}/clokey-docker-2026:${DOCKER_TAG} container_name: clokey-app-dev pull_policy: always depends_on: diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index e6358b68..a422ba52 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -16,7 +16,7 @@ services: - prod_app_network app: - image: ${DOCKERHUB_USERNAME}/clokey-docker-2025:${DOCKER_TAG} + image: ${DOCKERHUB_USERNAME}/clokey-docker-2026:${DOCKER_TAG} container_name: clokey-app-prod pull_policy: always depends_on: From f8ef996d2e97d089884994927d4f167aa8238f90 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Tue, 9 Jun 2026 13:37:47 +0900 Subject: [PATCH 20/28] =?UTF-8?q?fix:=20AI=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=80=EC=88=98=20=EC=A4=91=EB=B3=B5=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- clokey-api/dev-compose.yml | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index 67ef0494..c2323662 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -77,12 +77,7 @@ services: SWAGGER_USERNAME: ${SWAGGER_USERNAME} SWAGGER_PASSWORD: ${SWAGGER_PASSWORD} - AI_SERVER_IP: ${AI_SERVER_IP} - CLOTH_INFERENCE_PATH: ${CLOTH_INFERENCE_PATH} - STYLE_INFERENCE_PATH: ${STYLE_INFERENCE_PATH} - CLOTH_DETECT_PATH: ${CLOTH_DETECT_PATH} - - # MeilieSearch + # MeiliSearch MEILISEARCH_ENDPOINT: ${MEILISEARCH_ENDPOINT} MEILISEARCH_KEY: ${MEILISEARCH_KEY} From 92648ccd112d9e64a0bea72bb9cb1c4e37d6e073 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Fri, 12 Jun 2026 22:32:52 +0900 Subject: [PATCH 21/28] =?UTF-8?q?fix:=20compose=20=ED=94=84=EB=A1=9C?= =?UTF-8?q?=EC=A0=9D=ED=8A=B8=EB=AA=85=20=EB=B6=84=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/dev-cd.yml | 2 +- .github/workflows/prod-cd.yml | 2 +- clokey-api/dev-compose.yml | 4 ++-- clokey-api/prod-compose.yml | 4 ++-- 4 files changed, 6 insertions(+), 6 deletions(-) diff --git a/.github/workflows/dev-cd.yml b/.github/workflows/dev-cd.yml index 8b71fe84..a9b7705e 100644 --- a/.github/workflows/dev-cd.yml +++ b/.github/workflows/dev-cd.yml @@ -139,7 +139,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/dev-compose.yml up -d + docker compose -p clokey-dev -f clokey-api/dev-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file diff --git a/.github/workflows/prod-cd.yml b/.github/workflows/prod-cd.yml index b7bc2078..7490ce0b 100644 --- a/.github/workflows/prod-cd.yml +++ b/.github/workflows/prod-cd.yml @@ -145,7 +145,7 @@ jobs: echo "${{ secrets.DOCKERHUB_TOKEN }}" | docker login -u "${{ secrets.DOCKERHUB_USERNAME }}" --password-stdin cd /home/ubuntu - docker compose -f clokey-api/prod-compose.yml up -d + docker compose -p clokey-prod -f clokey-api/prod-compose.yml up -d echo "Cleaning up dangling Docker images..." docker image prune -f \ No newline at end of file diff --git a/clokey-api/dev-compose.yml b/clokey-api/dev-compose.yml index c2323662..eb22ccbe 100644 --- a/clokey-api/dev-compose.yml +++ b/clokey-api/dev-compose.yml @@ -1,3 +1,5 @@ +name: clokey-dev + version: "3.8" services: @@ -46,12 +48,10 @@ services: # Kakao OIDC KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} - KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} # Apple OIDC APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} - APPLE_REDIRECT_URI: ${APPLE_REDIRECT_URI} # Firebase FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json diff --git a/clokey-api/prod-compose.yml b/clokey-api/prod-compose.yml index a422ba52..34deda12 100644 --- a/clokey-api/prod-compose.yml +++ b/clokey-api/prod-compose.yml @@ -1,3 +1,5 @@ +name: clokey-prod + version: "3.8" services: @@ -46,12 +48,10 @@ services: # Kakao OIDC KAKAO_CLIENT_ID: ${KAKAO_CLIENT_ID} KAKAO_CLIENT_SECRET: ${KAKAO_CLIENT_SECRET} - KAKAO_REDIRECT_URI: ${KAKAO_REDIRECT_URI} # Apple OIDC APPLE_CLIENT_ID: ${APPLE_CLIENT_ID} APPLE_CLIENT_SECRET: ${APPLE_CLIENT_SECRET} - APPLE_REDIRECT_URI: ${APPLE_REDIRECT_URI} # Firebase FIREBASE_CREDENTIALS_PATH: /run/secrets/firebase-sa.json From 57eeae925819fbd49c2085aa27a4ba59ad7a26d0 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:08:40 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix:=20OCI=20presigned=20URL=EA=B3=BC=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EA=B0=9D=EC=B2=B4=20URL=EC=9D=84=20?= =?UTF-8?q?=EB=B6=84=EB=A6=AC=ED=95=B4=20StorageUtil=EC=9D=84=20=EC=A0=95?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - PAR URL은 업로드용, /n/.../o/... 공개 URL은 저장·조회용으로 생성 - PAR URL 파싱·정규화 및 MD5 기반 presigned URL 생성 로직을 제거 --- .../org/clokey/util/PresignedUrlResult.java | 3 + .../java/org/clokey/util/StorageUtil.java | 86 ++++++++++--------- 2 files changed, 48 insertions(+), 41 deletions(-) create mode 100644 clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java b/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java new file mode 100644 index 00000000..d825e584 --- /dev/null +++ b/clokey-infrastructure/src/main/java/org/clokey/util/PresignedUrlResult.java @@ -0,0 +1,3 @@ +package org.clokey.util; + +public record PresignedUrlResult(String uploadUrl, String objectUrl) {} diff --git a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java index 75e4f4dd..44940b61 100644 --- a/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java +++ b/clokey-infrastructure/src/main/java/org/clokey/util/StorageUtil.java @@ -5,6 +5,9 @@ import com.oracle.bmc.objectstorage.requests.*; import com.oracle.bmc.objectstorage.responses.CreatePreauthenticatedRequestResponse; import com.oracle.bmc.objectstorage.responses.HeadObjectResponse; +import java.net.URLDecoder; +import java.net.URLEncoder; +import java.nio.charset.StandardCharsets; import java.time.Instant; import java.util.Date; import java.util.HashMap; @@ -26,12 +29,14 @@ public class StorageUtil { private final ObjectStorageClient objectStorageClient; private final StorageProperties storageProperties; - // TODO : S3 -> OCI Storage로 이관하면서 MD5 해시를 사용하지 않게 되었습니다. 추후에 삭제할 필요가 있습니다. - public String createPresignedUrl( - ImageType imageType, Long memberId, FileExtension fileExtension, String md5Hash) { + public PresignedUrlResult createPresignedUrl( + ImageType imageType, Long memberId, FileExtension fileExtension) { String imageKey = UUID.randomUUID().toString(); String fileName = createFileName(imageType, memberId, imageKey, fileExtension); + return createPresignedUrlForObject(fileName, imageKey); + } + private PresignedUrlResult createPresignedUrlForObject(String fileName, String imageKey) { CreatePreauthenticatedRequestDetails details = CreatePreauthenticatedRequestDetails.builder() .name("presigned-url-" + imageKey) @@ -50,38 +55,35 @@ public String createPresignedUrl( CreatePreauthenticatedRequestResponse response = objectStorageClient.createPreauthenticatedRequest(request); - String presignedUrl = + String uploadUrl = objectStorageClient.getEndpoint() + response.getPreauthenticatedRequest().getAccessUri(); + String objectUrl = buildPublicObjectUrl(fileName); - return presignedUrl; + return new PresignedUrlResult(uploadUrl, objectUrl); } - public String createPresignedUrlWithoutMd5( - ImageType imageType, Long memberId, FileExtension fileExtension) { - String imageKey = UUID.randomUUID().toString(); - String fileName = createFileName(imageType, memberId, imageKey, fileExtension); - - CreatePreauthenticatedRequestDetails details = - CreatePreauthenticatedRequestDetails.builder() - .name("presigned-url-" + imageKey) - .objectName(fileName) - .accessType(CreatePreauthenticatedRequestDetails.AccessType.ObjectWrite) - .timeExpires(getPresignedUrlExpiration()) - .build(); - - CreatePreauthenticatedRequestRequest request = - CreatePreauthenticatedRequestRequest.builder() - .namespaceName(storageProperties.namespace()) - .bucketName(storageProperties.bucket()) - .createPreauthenticatedRequestDetails(details) - .build(); + public String buildPublicObjectUrl(String objectKey) { + return objectStorageClient.getEndpoint() + + "/n/" + + storageProperties.namespace() + + "/b/" + + storageProperties.bucket() + + "/o/" + + encodeObjectKey(objectKey); + } - CreatePreauthenticatedRequestResponse response = - objectStorageClient.createPreauthenticatedRequest(request); + public String toPublicObjectUrl(String url) { + if (url == null || url.isBlank()) { + return url; + } - return objectStorageClient.getEndpoint() - + response.getPreauthenticatedRequest().getAccessUri(); + try { + return buildPublicObjectUrl(extractObjectKey(url)); + } catch (IllegalArgumentException e) { + log.warn("Failed to normalize object URL, returning original url: {}", url, e); + return url; + } } private String createFileName( @@ -144,21 +146,23 @@ public void deleteByUrl(String url) { } private String extractObjectKey(String url) { - // OCI Object Storage URL 형식에 맞게 파싱 - // 예: https://objectstorage.{region}.oraclecloud.com/n/{namespace}/b/{bucket}/o/{object} - int oIndex = url.indexOf("/o/"); - if (oIndex != -1) { - return url.substring(oIndex + 3); + String path = url; + int queryIndex = path.indexOf('?'); + if (queryIndex != -1) { + path = path.substring(0, queryIndex); } - int comIndex = url.indexOf(".com/"); - if (comIndex != -1) { - String afterCom = url.substring(comIndex + 5); - int firstSlash = afterCom.indexOf("/"); - if (firstSlash != -1) { - return afterCom.substring(firstSlash + 1); - } + + int oIndex = path.indexOf("/o/"); + if (oIndex == -1) { + throw new IllegalArgumentException("Invalid URL format: " + url); } - throw new IllegalArgumentException("Invalid URL format: " + url); + + String encodedKey = path.substring(oIndex + 3); + return URLDecoder.decode(encodedKey, StandardCharsets.UTF_8); + } + + private String encodeObjectKey(String objectKey) { + return URLEncoder.encode(objectKey, StandardCharsets.UTF_8).replace("+", "%20"); } private Date getPresignedUrlExpiration() { From e583078fb3788f1b7391dffc0b0f88617e40b470 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:11:01 +0900 Subject: [PATCH 23/28] =?UTF-8?q?feat:=20presigned=20URL=20API=EC=97=90=20?= =?UTF-8?q?uploadUrl=EA=B3=BC=20objectUrl=EC=9D=84=20=ED=95=A8=EA=BB=98=20?= =?UTF-8?q?=EB=B0=98=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 클라이언트가 PAR URL로 업로드하고 공개 객체 URL을 DB에 저장 - cloth/history presigned URL 응답과 발급 로직을 수정 --- .../ClothImagesPresignedUrlResponse.java | 7 ++-- .../cloth/service/ClothAiServiceImpl.java | 24 ++++++++----- .../HistoryImagesPresignedUrlResponse.java | 7 ++-- .../history/service/HistoryServiceImpl.java | 34 ++++++++++++------- 4 files changed, 45 insertions(+), 27 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java index 95ae7ad9..16cdf286 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/dto/response/ClothImagesPresignedUrlResponse.java @@ -4,8 +4,9 @@ import java.util.List; public record ClothImagesPresignedUrlResponse( - @Schema(description = "생성된 presigned url 리스트") List urls) { - public static ClothImagesPresignedUrlResponse of(List urls) { - return new ClothImagesPresignedUrlResponse(urls); + @Schema(description = "업로드용 presigned url 리스트") List urls, + @Schema(description = "저장/조회용 공개 객체 url 리스트") List objectUrls) { + public static ClothImagesPresignedUrlResponse of(List urls, List objectUrls) { + return new ClothImagesPresignedUrlResponse(urls, objectUrls); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java index 07b434dd..e6221aa3 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothAiServiceImpl.java @@ -36,6 +36,7 @@ import org.clokey.global.util.MemberUtil; import org.clokey.member.entity.Member; import org.clokey.properties.WebClientProperties; +import org.clokey.util.PresignedUrlResult; import org.clokey.util.StorageUtil; import org.clokey.util.WebClientUtil; import org.springframework.stereotype.Service; @@ -61,19 +62,19 @@ public ClothImagesPresignedUrlResponse getClothUploadPresignedUrls( ClothImagesUploadRequest request) { final Member currentMember = memberUtil.getCurrentMember(); - // 중요 : md5 해시로 변조 확인을 하기 때문에 들어온 순서대로 반환해야함!! - List presignedUrls = + List presignedUrlResults = request.payloads().stream() .map( req -> storageUtil.createPresignedUrl( ImageType.CLOTH_IMAGE, currentMember.getId(), - req.fileExtension(), - req.md5Hashes())) + req.fileExtension())) .toList(); - return ClothImagesPresignedUrlResponse.of(presignedUrls); + return ClothImagesPresignedUrlResponse.of( + presignedUrlResults.stream().map(PresignedUrlResult::uploadUrl).toList(), + presignedUrlResults.stream().map(PresignedUrlResult::objectUrl).toList()); } @Override @@ -150,7 +151,7 @@ public ClothInfoExtractResponse extractClothInfo(ClothInfoExtractRequest request for (int i = 0; i < resultItems.size(); i++) { ClothInfoExtractAiResponseDTO.ResultItem resultItem = resultItems.get(i); - String clothImageUrl = resultItem.uploadedUrl(); + String clothImageUrl = storageUtil.toPublicObjectUrl(resultItem.uploadedUrl()); List categories = resultItem.categories(); @@ -346,7 +347,10 @@ public ClothDetectResponse detectClothes(ClothDetectRequest request) { phaseStartedAtNs = System.nanoTime(); List payloads = aiResponse.result().uploadedUrls().stream() - .map(ClothDetectResponse.Payload::new) + .map( + url -> + new ClothDetectResponse.Payload( + storageUtil.toPublicObjectUrl(url))) .toList(); postProcessMs = elapsedMillis(phaseStartedAtNs); @@ -403,8 +407,10 @@ private List createPresignedUrls(Long memberId, int count) { return java.util.stream.IntStream.range(0, count) .mapToObj( i -> - storageUtil.createPresignedUrlWithoutMd5( - ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG)) + storageUtil + .createPresignedUrl( + ImageType.CLOTH_IMAGE, memberId, FileExtension.JPEG) + .uploadUrl()) .toList(); } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java index 132fdab5..4d05382f 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/dto/response/HistoryImagesPresignedUrlResponse.java @@ -4,8 +4,9 @@ import java.util.List; public record HistoryImagesPresignedUrlResponse( - @Schema(description = "생성된 presigned url 리스트") List urls) { - public static HistoryImagesPresignedUrlResponse of(List urls) { - return new HistoryImagesPresignedUrlResponse(urls); + @Schema(description = "업로드용 presigned url 리스트") List urls, + @Schema(description = "저장/조회용 공개 객체 url 리스트") List objectUrls) { + public static HistoryImagesPresignedUrlResponse of(List urls, List objectUrls) { + return new HistoryImagesPresignedUrlResponse(urls, objectUrls); } } diff --git a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java index 5bece370..acb887fe 100644 --- a/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/history/service/HistoryServiceImpl.java @@ -39,6 +39,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.Visibility; import org.clokey.report.enums.TargetType; +import org.clokey.util.PresignedUrlResult; import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; @@ -101,7 +102,8 @@ public HistoryCreateResponse createHistory(HistoryCreateRequest request) { for (HistoryCreateRequest.Payload payload : request.payloads()) { final HistoryImage historyImage = - HistoryImage.createHistoryImage(payload.imageUrl(), history); + HistoryImage.createHistoryImage( + storageUtil.toPublicObjectUrl(payload.imageUrl()), history); images.add(historyImage); if (payload.clothTags() != null && !payload.clothTags().isEmpty()) { @@ -160,18 +162,21 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { existingImages.stream() .collect( Collectors.toMap( - HistoryImage::getImageUrl, + image -> storageUtil.toPublicObjectUrl(image.getImageUrl()), Function.identity(), (left, right) -> left)); Set requestedImageUrls = request.payloads().stream() - .map(HistoryUpdateRequest.Payload::imageUrl) + .map(payload -> storageUtil.toPublicObjectUrl(payload.imageUrl())) .collect(Collectors.toSet()); List imagesToDelete = existingImages.stream() - .filter(image -> !requestedImageUrls.contains(image.getImageUrl())) + .filter( + image -> + !requestedImageUrls.contains( + storageUtil.toPublicObjectUrl(image.getImageUrl()))) .toList(); if (!imagesToDelete.isEmpty()) { @@ -182,7 +187,10 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { Set keptImageIds = existingImages.stream() - .filter(image -> requestedImageUrls.contains(image.getImageUrl())) + .filter( + image -> + requestedImageUrls.contains( + storageUtil.toPublicObjectUrl(image.getImageUrl()))) .map(HistoryImage::getId) .collect(Collectors.toSet()); @@ -194,12 +202,13 @@ public void updateHistory(Long historyId, HistoryUpdateRequest request) { List clothTags = new ArrayList<>(); for (HistoryUpdateRequest.Payload payload : request.payloads()) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(payload.imageUrl()); final HistoryImage historyImage = existingImageMap.getOrDefault( - payload.imageUrl(), - HistoryImage.createHistoryImage(payload.imageUrl(), history)); + normalizedImageUrl, + HistoryImage.createHistoryImage(normalizedImageUrl, history)); - if (!existingImageMap.containsKey(payload.imageUrl())) { + if (!existingImageMap.containsKey(normalizedImageUrl)) { newImages.add(historyImage); } @@ -388,18 +397,19 @@ public HistoryImagesPresignedUrlResponse getHistoryUploadPresignedUrls( HistoryImagesUploadRequest request) { final Member currentMember = memberUtil.getCurrentMember(); - List presignedUrls = + List presignedUrlResults = request.payloads().stream() .map( payload -> storageUtil.createPresignedUrl( ImageType.HISTORY_IMAGE, currentMember.getId(), - payload.fileExtension(), - payload.md5Hashes())) + payload.fileExtension())) .toList(); - return HistoryImagesPresignedUrlResponse.of(presignedUrls); + return HistoryImagesPresignedUrlResponse.of( + presignedUrlResults.stream().map(PresignedUrlResult::uploadUrl).toList(), + presignedUrlResults.stream().map(PresignedUrlResult::objectUrl).toList()); } private HistoryImage getHistoryImageById(Long historyImageId) { From 8c3fab0e185a8f1f5108ab02015f25c98518a6b2 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:11:59 +0900 Subject: [PATCH 24/28] =?UTF-8?q?fix:=20=EC=9D=B4=EB=AF=B8=EC=A7=80=20URL?= =?UTF-8?q?=20=EC=A0=80=EC=9E=A5=20=EC=8B=9C=20PAR=20URL=EC=9D=84=20?= =?UTF-8?q?=EA=B3=B5=EA=B0=9C=20=EA=B0=9D=EC=B2=B4=20URL=EB=A1=9C=20?= =?UTF-8?q?=EC=A0=95=EA=B7=9C=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - cloth, history, member, coordinate 및 AI 업로드 결과에서 만료되는 PAR URL 대신 조회 가능한 OCI 공개 URL이 DB에 저장되도록 한다. --- .../domain/cloth/service/ClothServiceImpl.java | 9 +++++---- .../coordinate/service/CoordinateServiceImpl.java | 12 ++++++++---- .../domain/member/service/MemberServiceImpl.java | 6 +++++- 3 files changed, 18 insertions(+), 9 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java index 329c19f2..7a489b1e 100644 --- a/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/cloth/service/ClothServiceImpl.java @@ -37,6 +37,7 @@ public class ClothServiceImpl implements ClothService { private final MemberUtil memberUtil; + private final StorageUtil storageUtil; private final ClothRepository clothRepository; private final CategoryRepository categoryRepository; @@ -44,7 +45,6 @@ public class ClothServiceImpl implements ClothService { private final ApplicationEventPublisher eventPublisher; private final CoordinateClothRepository coordinateClothRepository; - private final StorageUtil storageUtil; @Override @Transactional @@ -65,7 +65,7 @@ public ClothCreateResponse createClothes(ClothCreateRequests request) { cr -> { Category category = categoryMap.get(cr.categoryId()); return Cloth.createCloth( - cr.clothImageUrl(), + storageUtil.toPublicObjectUrl(cr.clothImageUrl()), cr.clothUrl(), cr.name(), cr.brand(), @@ -140,7 +140,8 @@ public void updateCloth(Long clothId, ClothUpdateRequest request) { validateChildCategory(category); // 사진이 바뀌는 경우 기존 imageUrl을 기반으로 S3에서 삭제합니다. - if (!cloth.getClothImageUrl().equals(request.clothImageUrl())) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(request.clothImageUrl()); + if (!storageUtil.toPublicObjectUrl(cloth.getClothImageUrl()).equals(normalizedImageUrl)) { eventPublisher.publishEvent(ImageDeleteEvent.of(cloth.getClothImageUrl())); } @@ -148,7 +149,7 @@ public void updateCloth(Long clothId, ClothUpdateRequest request) { boolean categoryChanged = !cloth.getCategory().getId().equals(category.getId()); cloth.updateCloth( - request.clothImageUrl(), + normalizedImageUrl, request.clothUrl(), request.name(), request.brand(), diff --git a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java index 7b829051..b2de8431 100644 --- a/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/coordinate/service/CoordinateServiceImpl.java @@ -36,6 +36,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.enums.Visibility; import org.clokey.response.SliceResponse; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Slice; import org.springframework.data.redis.core.RedisTemplate; @@ -61,6 +62,7 @@ public class CoordinateServiceImpl implements CoordinateService { private final RedisTemplate redisTemplate; private final ApplicationEventPublisher eventPublisher; + private final StorageUtil storageUtil; @Override @Transactional @@ -97,7 +99,8 @@ public CoordinateCreateResponse createDailyCoordinate(DailyCoordinateCreateReque validateDailyCoordinateExist(currentMember.getId(), LocalDate.now(KST)); Coordinate coordinate = - Coordinate.createDailyCoordinate(request.coordinateImageUrl(), currentMember); + Coordinate.createDailyCoordinate( + storageUtil.toPublicObjectUrl(request.coordinateImageUrl()), currentMember); coordinateRepository.save(coordinate); List coordinateClothes = @@ -160,7 +163,7 @@ public CoordinateCreateResponse createCoordinateManual(CoordinateManualCreateReq Coordinate.createCoordinateManual( request.name(), request.memo(), - request.coordinateImageUrl(), + storageUtil.toPublicObjectUrl(request.coordinateImageUrl()), currentMember, lookBook); coordinateRepository.save(coordinate); @@ -245,10 +248,11 @@ public void updateCoordinate(Long coordinateId, CoordinateUpdateRequest request) validateAllClothesOwnership(currentMember, clothes); /** Coordinate 업데이트 로직 */ - if (!Objects.equals(coordinate.getImageUrl(), request.coordinateImageUrl())) { + String normalizedImageUrl = storageUtil.toPublicObjectUrl(request.coordinateImageUrl()); + if (!storageUtil.toPublicObjectUrl(coordinate.getImageUrl()).equals(normalizedImageUrl)) { eventPublisher.publishEvent(ImageDeleteEvent.of(coordinate.getImageUrl())); } - coordinate.updateCoordinate(request.name(), request.memo(), request.coordinateImageUrl()); + coordinate.updateCoordinate(request.name(), request.memo(), normalizedImageUrl); /** CoordinateCloth 업데이트 로직 */ List coordinateCloths = coordinate.getCoordinateClothes(); diff --git a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java index 27825525..72d5d8a6 100644 --- a/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/member/service/MemberServiceImpl.java @@ -25,6 +25,7 @@ import org.clokey.member.enums.MemberStatus; import org.clokey.member.enums.Visibility; import org.clokey.response.SliceResponse; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -42,6 +43,7 @@ public class MemberServiceImpl implements MemberService { private final BlockRepository blockRepository; private final ApplicationEventPublisher eventPublisher; + private final StorageUtil storageUtil; @Override @Transactional @@ -53,8 +55,10 @@ public void updateProfile(ProfileUpdateRequest request) { // s3 삭제 로직 구현 이후에 반영 필요 -> 배경 및 프로필 이미지를 없애버리는 경우 + String normalizedProfileImageUrl = storageUtil.toPublicObjectUrl(request.profileImageUrl()); + currentMember.updateProfile( - request.nickname(), request.profileImageUrl(), request.bio(), request.visibility()); + request.nickname(), normalizedProfileImageUrl, request.bio(), request.visibility()); // Member 동기화 eventPublisher.publishEvent( From 8a9ed36855223e7a1353533273f1c5a8293e2768 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:12:32 +0900 Subject: [PATCH 25/28] =?UTF-8?q?fix:=20=EA=B8=B0=EC=98=A8=20=EC=95=8C?= =?UTF-8?q?=EB=A6=BC=20=EC=95=84=EC=9D=B4=EC=BD=98=20URL=EC=9D=84=20?= =?UTF-8?q?=ED=99=98=EA=B2=BD=EB=B3=84=20bucket=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=EC=83=9D=EC=84=B1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/CodiveNotificationServiceImpl.java | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index c303e6e0..d9c167ae 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -34,6 +34,7 @@ import org.clokey.notification.enums.ReadStatus; import org.clokey.notification.enums.RedirectType; import org.clokey.response.SliceResponse; +import org.clokey.util.StorageUtil; import org.springframework.context.ApplicationEventPublisher; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; @@ -57,6 +58,7 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private final ApplicationEventPublisher eventPublisher; private final MemberUtil memberUtil; + private final StorageUtil storageUtil; private static final String NEW_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장을 팔로우하기 시작했습니다."; private static final String NEW_PENDING_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장에 팔로우를 요청했습니다."; @@ -66,7 +68,8 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private static final String TODAY_TEMPERATURE_NOTIFICATION = "오늘의 기온은 %d도 입니다!\n날씨에 맞는 오늘의 옷차림이 기다리고 있어요👀"; - private static final String TODAY_TEMPERATURE_IMAGE_URL = "https://example.com/temperature.png"; + private static final String TEMPERATURE_ICON_OBJECT_KEY = + "icon/temperature/temperature_icon.jpeg"; @Override @Transactional @@ -259,11 +262,11 @@ public void sendNewTemperatureNotification(TemperatureNotificationRequest reques String.format(TODAY_TEMPERATURE_NOTIFICATION, Math.round(request.temperature())); if (isAbleToSendNotification(receiver)) { + String temperatureImageUrl = + storageUtil.buildPublicObjectUrl(TEMPERATURE_ICON_OBJECT_KEY); + Notification notification = - Notification.builder() - .setBody(content) - .setImage(TODAY_TEMPERATURE_IMAGE_URL) - .build(); + Notification.builder().setBody(content).setImage(temperatureImageUrl).build(); Message message = Message.builder() .setToken(receiver.getDeviceToken()) @@ -274,7 +277,7 @@ public void sendNewTemperatureNotification(TemperatureNotificationRequest reques CodiveNotification.createCodiveNotification( receiver, content, - TODAY_TEMPERATURE_IMAGE_URL, + temperatureImageUrl, "", RedirectType.NONE, NotificationType.TEMPERATURE_DAILY); From 9d5fdf0c4b5d0aa563cec6a07ab9b753e71d0cfe Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:13:02 +0900 Subject: [PATCH 26/28] =?UTF-8?q?test:=20OCI=20Storage=20mock=20=EB=B0=8F?= =?UTF-8?q?=20presigned=20URL=20=EB=B3=80=EA=B2=BD=EC=97=90=20=EB=A7=9E?= =?UTF-8?q?=EA=B2=8C=20=ED=85=8C=EC=8A=A4=ED=8A=B8=EB=A5=BC=20=EC=88=98?= =?UTF-8?q?=EC=A0=95=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - objectUrls 응답 필드와 createPresignedUrl 시그니처 변경을 반영하고 테스트용 ObjectStorageClient endpoint stub을 추가한다. --- .../java/org/clokey/TestObjectStorageConfig.java | 5 ++++- .../domain/cloth/controller/ClothControllerTest.java | 3 ++- .../domain/cloth/service/ClothServiceTest.java | 12 ++++++------ .../history/controller/HistoryControllerTest.java | 4 +++- .../history/service/HistoryServiceImplTest.java | 12 ++++++------ 5 files changed, 21 insertions(+), 15 deletions(-) diff --git a/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java index 7e189fdd..5ef4ecd7 100644 --- a/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java +++ b/clokey-api/src/test/java/org/clokey/TestObjectStorageConfig.java @@ -11,6 +11,9 @@ public class TestObjectStorageConfig { @Bean @ConditionalOnMissingBean(ObjectStorageClient.class) public ObjectStorageClient objectStorageClient() { - return org.mockito.Mockito.mock(ObjectStorageClient.class); + ObjectStorageClient client = org.mockito.Mockito.mock(ObjectStorageClient.class); + org.mockito.Mockito.when(client.getEndpoint()) + .thenReturn("https://objectstorage.test.oraclecloud.com"); + return client; } } diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java index dc8f2eb1..80eef0fd 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/controller/ClothControllerTest.java @@ -59,7 +59,8 @@ class 옷_업로드_presigned_url_발급_요청_시 { ClothImagesPresignedUrlResponse response = new ClothImagesPresignedUrlResponse( - List.of("testPresignedUrl1", "testPresignedUrl2")); + List.of("testPresignedUrl1", "testPresignedUrl2"), + List.of("testObjectUrl1", "testObjectUrl2")); given(clothAiService.getClothUploadPresignedUrls(request)).willReturn(response); diff --git a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java index dbf2edcd..165cd93c 100644 --- a/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/cloth/service/ClothServiceTest.java @@ -48,6 +48,7 @@ import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; import org.clokey.response.SliceResponse; +import org.clokey.util.PresignedUrlResult; import org.clokey.util.StorageUtil; import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.BeforeEach; @@ -109,18 +110,17 @@ void setUp() { FileExtension.JPEG, "testMd5Hash1"), new ClothImagesUploadRequest.Payload( FileExtension.JPEG, "testMd5Hash2"))); - given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) - .willReturn("testUrl1"); - - given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) - .willReturn("testUrl2"); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")) + .willReturn(new PresignedUrlResult("testUploadUrl2", "testObjectUrl2")); // when ClothImagesPresignedUrlResponse response = clothAiService.getClothUploadPresignedUrls(request); // then - assertThat(response.urls()).containsExactly("testUrl1", "testUrl2"); + assertThat(response.urls()).containsExactly("testUploadUrl1", "testUploadUrl2"); + assertThat(response.objectUrls()).containsExactly("testObjectUrl1", "testObjectUrl2"); } } diff --git a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java index d623429c..00ef4de1 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/controller/HistoryControllerTest.java @@ -59,7 +59,9 @@ class 옷_업로드_presigned_url_발급_요청_시 { FileExtension.PNG, "testMd5Hash2"))); HistoryImagesPresignedUrlResponse response = - new HistoryImagesPresignedUrlResponse(List.of("testUrl1", "testUrl2")); + new HistoryImagesPresignedUrlResponse( + List.of("testUrl1", "testUrl2"), + List.of("testObjectUrl1", "testObjectUrl2")); given(historyService.getHistoryUploadPresignedUrls(request)).willReturn(response); diff --git a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java index c3889978..9b1e6bad 100644 --- a/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java +++ b/clokey-api/src/test/java/org/clokey/domain/history/service/HistoryServiceImplTest.java @@ -3,7 +3,6 @@ import static org.assertj.core.api.Assertions.*; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyLong; -import static org.mockito.ArgumentMatchers.eq; import static org.mockito.BDDMockito.given; import java.time.LocalDate; @@ -48,6 +47,7 @@ import org.clokey.member.entity.Member; import org.clokey.member.entity.OauthInfo; import org.clokey.member.enums.OauthProvider; +import org.clokey.util.PresignedUrlResult; import org.clokey.util.StorageUtil; import org.junit.jupiter.api.*; import org.springframework.beans.factory.annotation.Autowired; @@ -105,17 +105,17 @@ void setUp() { new HistoryImagesUploadRequest.Payload( FileExtension.PNG, "testMd5Hash2"))); - given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash1"))) - .willReturn("testUrl1"); - given(storageUtil.createPresignedUrl(any(), anyLong(), any(), eq("testMd5Hash2"))) - .willReturn("testUrl2"); + given(storageUtil.createPresignedUrl(any(), anyLong(), any())) + .willReturn(new PresignedUrlResult("testUploadUrl1", "testObjectUrl1")) + .willReturn(new PresignedUrlResult("testUploadUrl2", "testObjectUrl2")); // when HistoryImagesPresignedUrlResponse response = historyService.getHistoryUploadPresignedUrls(request); // then - assertThat(response.urls()).containsExactly("testUrl1", "testUrl2"); + assertThat(response.urls()).containsExactly("testUploadUrl1", "testUploadUrl2"); + assertThat(response.objectUrls()).containsExactly("testObjectUrl1", "testObjectUrl2"); } } From 7fce725b0d7dac6ab7934f3200a9562a131465aa Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 17:57:21 +0900 Subject: [PATCH 27/28] =?UTF-8?q?fix=20:=20CodiveNotificationServiceImpl?= =?UTF-8?q?=EC=97=90=EC=84=9C=20StorageUtil=20=ED=95=84=EB=93=9C=20?= =?UTF-8?q?=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../notification/service/CodiveNotificationServiceImpl.java | 1 - 1 file changed, 1 deletion(-) diff --git a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java index 114836f7..8c97ff72 100644 --- a/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java +++ b/clokey-api/src/main/java/org/clokey/domain/notification/service/CodiveNotificationServiceImpl.java @@ -50,7 +50,6 @@ public class CodiveNotificationServiceImpl implements CodiveNotificationService private final ApplicationEventPublisher eventPublisher; private final MemberUtil memberUtil; - private final StorageUtil storageUtil; private static final String NEW_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장을 팔로우하기 시작했습니다."; private static final String NEW_PENDING_FOLLOWER_NOTIFICATION = "%s님이 회원님의 옷장에 팔로우를 요청했습니다."; From 94ac4aad3af4150fa99d4bfaddb5e206aed79bd9 Mon Sep 17 00:00:00 2001 From: Hogeun Lee Date: Sun, 14 Jun 2026 18:00:16 +0900 Subject: [PATCH 28/28] fix : spotless check --- .../java/org/clokey/global/config/security/SecurityConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java index e27726f6..b1dee313 100644 --- a/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java +++ b/clokey-api/src/main/java/org/clokey/global/config/security/SecurityConfig.java @@ -9,8 +9,8 @@ import org.clokey.domain.auth.service.JwtTokenService; import org.clokey.global.security.AppleAwareOAuth2AuthorizationRequestResolver; import org.clokey.global.security.JwtAuthenticationFilter; -import org.springframework.beans.factory.annotation.Value; import org.clokey.global.security.SwaggerBasicAuthenticationFilter; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.web.builders.HttpSecurity;