diff --git a/build.gradle b/build.gradle index 6933a01..6174dd5 100644 --- a/build.gradle +++ b/build.gradle @@ -80,6 +80,9 @@ dependencies { testImplementation 'org.springframework.boot:spring-boot-starter-test' testImplementation 'org.springframework.security:spring-security-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + + //이미지 타입 검사 라이브러리 + implementation 'org.apache.tika:tika-core:2.5.0' } jar.enabled = false diff --git a/src/docs/asciidoc/index.adoc b/src/docs/asciidoc/index.adoc index 04c51c9..b47a01a 100644 --- a/src/docs/asciidoc/index.adoc +++ b/src/docs/asciidoc/index.adoc @@ -102,6 +102,23 @@ Content-Type: application/json --- +== 이미지 파일 요청 + +원본 이미지 url 예시 : https://{cdn-url}/users/default-image.jpg + +썸네일 이미지 url 예시 : https://{cdn-url}/users/default-image.jpg?w=100&h=100 + +==== 썸네일 이미지 요청 Query Parameter + +[cols="4,4,4,4,4", options="header"] +|=== +| param | description | type | required | 비고 +| w | 가로 크기 | int | true | +| h | 세로 크기 | int | true | +| f | 반환 이미지 format | string | false (default : jpg) | jpg, png, jpeg +|=== + + = **회원** include::user-api.adoc[] diff --git a/src/docs/asciidoc/user-api.adoc b/src/docs/asciidoc/user-api.adoc index b8200e4..14eca72 100644 --- a/src/docs/asciidoc/user-api.adoc +++ b/src/docs/asciidoc/user-api.adoc @@ -121,4 +121,44 @@ include::{snippetsDir}/socialUserSignUp/1/response-fields.adoc[] 실패1. include::{snippetsDir}/socialUserSignUp/2/http-response.adoc[] 실패 2 -include::{snippetsDir}/socialUserSignUp/3/http-response.adoc[] \ No newline at end of file +include::{snippetsDir}/socialUserSignUp/3/http-response.adoc[] + + +=== **7. 사용자 정보 간단 조회 api** + +사용자 정보 수정 시 노출되는 정보를 제공 + +==== Request +include::{snippetsDir}/userSimpleInfo/1/http-request.adoc[] + +==== 성공 Response +include::{snippetsDir}/userSimpleInfo/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/userSimpleInfo/1/response-fields.adoc[] + + +=== **8. 사용자 정보 수정 api** + +사용자 정보를 수정합니다. 수정이 필요한 항목만 수정을 요청해 주세요. + +==== Request +include::{snippetsDir}/userInfoUpdate/1/curl-request.adoc[] + +==== Request Parts +include::{snippetsDir}/userInfoUpdate/1/request-parts.adoc[] + +==== Request Parts : **data** - Detail Fields +include::{snippetsDir}/userInfoUpdate/1/request-part-data-fields.adoc[] + +==== 성공 Response +include::{snippetsDir}/userInfoUpdate/1/http-response.adoc[] + +==== Response Body Fields +include::{snippetsDir}/userInfoUpdate/1/response-fields.adoc[] + +==== 실패 Response +실패1. +include::{snippetsDir}/userInfoUpdate/2/http-response.adoc[] +실패 2 +include::{snippetsDir}/userInfoUpdate/3/http-response.adoc[] \ No newline at end of file diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/controller/GetUserSimpleInfoController.java b/src/main/java/com/ftm/server/adapter/in/web/user/controller/GetUserSimpleInfoController.java new file mode 100644 index 0000000..1eb8dd2 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/controller/GetUserSimpleInfoController.java @@ -0,0 +1,33 @@ +package com.ftm.server.adapter.in.web.user.controller; + +import com.ftm.server.adapter.in.web.user.dto.response.GetUserSimpleInfoResponse; +import com.ftm.server.application.port.in.user.GetUserSimpleInfoUseCase; +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class GetUserSimpleInfoController { + private final GetUserSimpleInfoUseCase getUserSimpleInfoUseCase; + + @GetMapping("/api/users/info/simple") + public ResponseEntity> getUserInfo( + @AuthenticationPrincipal UserPrincipal userPrincipal) { + UserWithImageVo userWithImageVo = + getUserSimpleInfoUseCase.execute(FindByUserIdQuery.of(userPrincipal.getId())); + return ResponseEntity.status(HttpStatus.OK) + .body( + ApiResponse.success( + SuccessResponseCode.OK, + GetUserSimpleInfoResponse.from(userWithImageVo))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/controller/UpdateUserInfoController.java b/src/main/java/com/ftm/server/adapter/in/web/user/controller/UpdateUserInfoController.java new file mode 100644 index 0000000..995df07 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/controller/UpdateUserInfoController.java @@ -0,0 +1,42 @@ +package com.ftm.server.adapter.in.web.user.controller; + +import com.ftm.server.adapter.in.web.user.dto.request.UpdateUserInfoRequest; +import com.ftm.server.adapter.in.web.user.dto.response.UpdateUserInfoResponse; +import com.ftm.server.application.command.user.UpdateUserCommand; +import com.ftm.server.application.port.in.user.UpdateUserInfoUseCase; +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.common.response.ApiResponse; +import com.ftm.server.common.response.enums.SuccessResponseCode; +import com.ftm.server.infrastructure.security.UserPrincipal; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PatchMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@RestController +@RequiredArgsConstructor +@Slf4j +public class UpdateUserInfoController { + + private final UpdateUserInfoUseCase updateUserInfoUseCase; + + @PatchMapping("api/users/info") + public ResponseEntity> updateUserInfo( + @RequestPart(value = "data") UpdateUserInfoRequest request, + @RequestPart(value = "imageFile", required = false) MultipartFile imageFile, + @AuthenticationPrincipal UserPrincipal userPrincipal) { + UpdateUserCommand updateUserCommand = + UpdateUserCommand.from(userPrincipal.getId(), request, imageFile); + UserWithImageVo userWithImageVo = updateUserInfoUseCase.execute(updateUserCommand); + return ResponseEntity.status(HttpStatus.OK) + .body( + ApiResponse.success( + SuccessResponseCode.OK, + UpdateUserInfoResponse.from(userWithImageVo))); + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/UpdateUserInfoRequest.java b/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/UpdateUserInfoRequest.java new file mode 100644 index 0000000..1358485 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/dto/request/UpdateUserInfoRequest.java @@ -0,0 +1,13 @@ +package com.ftm.server.adapter.in.web.user.dto.request; + +import com.ftm.server.domain.enums.AgeGroup; +import com.ftm.server.domain.enums.HashTag; +import lombok.Data; + +@Data +public class UpdateUserInfoRequest { + private final String nickname; + private final AgeGroup age; + private final HashTag[] hashtags; + private final String imageAction; +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/GetUserSimpleInfoResponse.java b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/GetUserSimpleInfoResponse.java new file mode 100644 index 0000000..ed49eb9 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/GetUserSimpleInfoResponse.java @@ -0,0 +1,60 @@ +package com.ftm.server.adapter.in.web.user.dto.response; + +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.common.consts.PropertiesHolder; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import com.ftm.server.domain.enums.AgeGroup; +import com.ftm.server.domain.enums.HashTag; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.Data; + +@Data +public class GetUserSimpleInfoResponse { + private final Long userId; + private final String userNickname; + private final String imageUrl; + private final AgeInfo ageInfo; + private final List hashTagInfo; + + public static GetUserSimpleInfoResponse from(UserWithImageVo userWithImageVo) { + User user = userWithImageVo.getUser(); + UserImage userImage = userWithImageVo.getUserImage(); + + String imageUrl = PropertiesHolder.CDN_PATH + "/" + userImage.getObjectKey(); + + HashTag[] userHashTagWithArray = user.getFavoriteHashtags(); + List userHashTag = + userHashTagWithArray == null || userHashTagWithArray.length == 0 + ? new ArrayList<>() + : Arrays.stream(user.getFavoriteHashtags()).toList(); + List hashTagInfos = new ArrayList<>(); + for (HashTag hashTag : HashTag.values()) { + if (userHashTag.contains(hashTag)) { + hashTagInfos.add(HashTagInfo.from(hashTag, true)); + } else { + hashTagInfos.add(HashTagInfo.from(hashTag, false)); + } + } + return new GetUserSimpleInfoResponse( + user.getId(), + user.getNickname(), + imageUrl, + AgeInfo.from(user.getAgeGroup()), + hashTagInfos); + } + + private record AgeInfo(String value, String description) { + private static AgeInfo from(AgeGroup ageGroup) { + return new AgeInfo(ageGroup.name(), ageGroup.getValue()); + } + } + + private record HashTagInfo(String value, String description, Boolean isSelected) { + private static HashTagInfo from(HashTag hashTag, Boolean isSelected) { + return new HashTagInfo(hashTag.name(), hashTag.getValue(), isSelected); + } + } +} diff --git a/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/UpdateUserInfoResponse.java b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/UpdateUserInfoResponse.java new file mode 100644 index 0000000..b0ece65 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/in/web/user/dto/response/UpdateUserInfoResponse.java @@ -0,0 +1,59 @@ +package com.ftm.server.adapter.in.web.user.dto.response; + +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.common.consts.PropertiesHolder; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import com.ftm.server.domain.enums.AgeGroup; +import com.ftm.server.domain.enums.HashTag; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import lombok.Data; + +@Data +public class UpdateUserInfoResponse { + private final Long userId; + private final String userNickname; + private final String imageUrl; + private final AgeInfo ageInfo; + private final List hashTagInfo; + + public static UpdateUserInfoResponse from(UserWithImageVo userWithImageVo) { + User user = userWithImageVo.getUser(); + UserImage userImage = userWithImageVo.getUserImage(); + + String imageUrl = PropertiesHolder.CDN_PATH + "/" + userImage.getObjectKey(); + HashTag[] userHashTagWithArray = user.getFavoriteHashtags(); + List userHashTag = + userHashTagWithArray == null || userHashTagWithArray.length == 0 + ? new ArrayList<>() + : Arrays.stream(user.getFavoriteHashtags()).toList(); + List hashTagInfos = new ArrayList<>(); + for (HashTag hashTag : HashTag.values()) { + if (userHashTag.contains(hashTag)) { + hashTagInfos.add(HashTagInfo.from(hashTag, true)); + } else { + hashTagInfos.add(HashTagInfo.from(hashTag, false)); + } + } + return new UpdateUserInfoResponse( + user.getId(), + user.getNickname(), + imageUrl, + AgeInfo.from(user.getAgeGroup()), + hashTagInfos); + } + + private record AgeInfo(String value, String description) { + private static AgeInfo from(AgeGroup ageGroup) { + return new AgeInfo(ageGroup.name(), ageGroup.getValue()); + } + } + + private record HashTagInfo(String value, String description, Boolean isSelected) { + private static HashTagInfo from(HashTag hashTag, Boolean isSelected) { + return new HashTagInfo(hashTag.name(), hashTag.getValue(), isSelected); + } + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapterForAuthForAuth.java b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java similarity index 69% rename from src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapterForAuthForAuth.java rename to src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java index 775770e..2515435 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapterForAuthForAuth.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/adapter/user/UserDomainPersistenceAdapter.java @@ -4,6 +4,7 @@ import com.ftm.server.adapter.out.persistence.mapper.UserImageMapper; import com.ftm.server.adapter.out.persistence.mapper.UserMapper; import com.ftm.server.adapter.out.persistence.model.EmailVerificationLogsJpaEntity; +import com.ftm.server.adapter.out.persistence.model.GroomingLevelJpaEntity; import com.ftm.server.adapter.out.persistence.model.UserImageJpaEntity; import com.ftm.server.adapter.out.persistence.model.UserJpaEntity; import com.ftm.server.adapter.out.persistence.repository.EmailVerificationLogsRepository; @@ -14,6 +15,7 @@ import com.ftm.server.application.port.out.persistence.user.*; import com.ftm.server.application.query.*; import com.ftm.server.common.annotation.Adapter; +import com.ftm.server.common.exception.CustomException; import com.ftm.server.domain.entity.EmailVerificationLogs; import com.ftm.server.domain.entity.User; import com.ftm.server.domain.entity.UserImage; @@ -24,13 +26,17 @@ @Adapter @RequiredArgsConstructor @Slf4j -public class UserDomainPersistenceAdapterForAuthForAuth +public class UserDomainPersistenceAdapter implements LoadEmailVerificationLogPort, SaveEmailVerificationLogPort, UpdateEmailVerificationLogPort, CheckUserPort, SaveUserPort, - SaveUserImagePort { + SaveUserImagePort, + LoadUserPort, + LoadUserImagePort, + UpdateUserInfoPort, + UpdateUserImagePort { // repository private final EmailVerificationLogsRepository emailVerificationLogsRepository; @@ -104,4 +110,50 @@ public User saveSocialUser(User user) { UserJpaEntity savedUser = userRepository.save(userJpaEntity); return userMapper.toDomainEntity(savedUser); } + + @Override + public User loadUserById(FindByUserIdQuery query) { + UserJpaEntity userJpaEntity = + userRepository + .findById(query.getUserId()) + .orElseThrow(() -> CustomException.USER_NOT_FOUND); + return userMapper.toDomainEntity(userJpaEntity); + } + + @Override + public UserImage loadUserImageByUserId(FindByUserIdQuery query) { + UserImageJpaEntity userImageJpaEntity = + userImageRepository.findByUserId(query.getUserId()).orElse(null); + if (userImageJpaEntity != null) { + return userImageMapper.toDomainEntity(userImageJpaEntity); + } + return null; + } + + @Override + public void updateUserInfo(User user) { + UserJpaEntity savedUser = + userRepository + .findById(user.getId()) + .orElseThrow(() -> CustomException.USER_NOT_FOUND); + GroomingLevelJpaEntity groomingLevelJpaEntity = + user.getGroomingLevelId() == null + ? null + : groomingLevelRepository.findById(user.getGroomingLevelId()).orElse(null); + + savedUser.updateFromDomainEntity(user, groomingLevelJpaEntity); + + userRepository.save(savedUser); + } + + @Override + public void updateUserImage(UserImage userImage) { + UserImageJpaEntity userImageJpaEntity = + userImageRepository.findByUserId(userImage.getUserId()).orElse(null); + if (userImageJpaEntity == null) { + log.error("[USER_IMAGE_NOT_FOUND] : 사용자의 이미지 data를 찾을 수 없음."); + } + + userImageJpaEntity.updateFromDomainEntity(userImage); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/UserImageJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/UserImageJpaEntity.java index d024d2e..bc4ac0c 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/UserImageJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/UserImageJpaEntity.java @@ -39,4 +39,8 @@ public static UserImageJpaEntity from(UserImage userImage, UserJpaEntity userJpa public static UserImageJpaEntity createUserImage(UserJpaEntity user) { return UserImageJpaEntity.builder().user(user).objectKey("users/default-image.png").build(); } + + public void updateFromDomainEntity(UserImage userImage) { + this.objectKey = userImage.getObjectKey(); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/persistence/model/UserJpaEntity.java b/src/main/java/com/ftm/server/adapter/out/persistence/model/UserJpaEntity.java index e7cf243..ddd689c 100644 --- a/src/main/java/com/ftm/server/adapter/out/persistence/model/UserJpaEntity.java +++ b/src/main/java/com/ftm/server/adapter/out/persistence/model/UserJpaEntity.java @@ -130,4 +130,20 @@ public void updateGroomingScore(User user) { public void updateGroomingLevel(GroomingLevelJpaEntity groomingLevelJpaEntity) { this.groomingLevel = groomingLevelJpaEntity; } + + public void updateFromDomainEntity(User user, GroomingLevelJpaEntity groomingLevelJpaEntity) { + + this.email = user.getEmail(); + this.password = user.getPassword(); + this.nickname = user.getNickname(); + this.ageGroup = user.getAgeGroup(); + this.socialProvider = user.getSocialProvider(); + this.socialId = user.getSocialId(); + this.groomingScore = user.getGroomingScore(); + this.groomingLevel = groomingLevelJpaEntity; + this.role = user.getRole(); + this.favoriteHashtags = user.getFavoriteHashtags(); + this.isDeleted = user.getIsDeleted(); + this.deletedAt = user.getDeletedAt(); + } } diff --git a/src/main/java/com/ftm/server/adapter/out/s3/.gitkeep b/src/main/java/com/ftm/server/adapter/out/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java new file mode 100644 index 0000000..7b86ab6 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageDeleteAdapter.java @@ -0,0 +1,27 @@ +package com.ftm.server.adapter.out.s3; + +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.common.annotation.Adapter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.DeleteObjectRequest; + +@Adapter +@RequiredArgsConstructor +public class S3ImageDeleteAdapter implements S3ImageDeletePort { + + private final S3Client s3Client; + + @Value("${aws.s3.bucket-name}") + private String bucket; + + @Override + public void deleteImage(String objectKey) { + if (objectKey == null) return; + DeleteObjectRequest deleteRequest = + DeleteObjectRequest.builder().bucket(bucket).key(objectKey).build(); + + s3Client.deleteObject(deleteRequest); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java new file mode 100644 index 0000000..dcd9892 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3ImageUploadAdapter.java @@ -0,0 +1,91 @@ +package com.ftm.server.adapter.out.s3; + +import com.ftm.server.application.port.out.s3.S3ImageUploadPort; +import com.ftm.server.application.port.out.tika.TikaFileTypeDetectionPort; +import com.ftm.server.common.annotation.Adapter; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import java.io.IOException; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; +import software.amazon.awssdk.awscore.exception.AwsServiceException; +import software.amazon.awssdk.core.exception.SdkClientException; +import software.amazon.awssdk.core.sync.RequestBody; +import software.amazon.awssdk.services.s3.S3Client; +import software.amazon.awssdk.services.s3.model.PutObjectRequest; + +@RequiredArgsConstructor +@Adapter +@Slf4j +public class S3ImageUploadAdapter implements S3ImageUploadPort { + + private final S3Client s3Client; + + private final TikaFileTypeDetectionPort tikaFileTypeDetectionPort; + + @Value("${aws.s3.bucket-name}") + private String bucket; + + @Override + public String updateImage(MultipartFile imageFile, String dirName) { + + // 이미지 검증 : 사이즈, 타입, empty 여부 + String type = null; + try { + type = tikaFileTypeDetectionPort.detectFileType(imageFile); // 이미지 타입 추출 + } catch (IOException e) { + throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); + } + if (imageFile == null + || imageFile.isEmpty() + || (float) imageFile.getSize() / (1024.0 * 1024.0) > (float) 10 + || !type.startsWith("image/")) { + throw new CustomException(ErrorResponseCode.INVALID_IMAGE_FORMAT); + } + + String originalFileName = imageFile.getOriginalFilename(); + String fileName = dirName + "/" + UUID.randomUUID() + "_" + originalFileName; + + PutObjectRequest putObjectRequest = + PutObjectRequest.builder() + .bucket(bucket) + .key(fileName) + .contentType(imageFile.getContentType()) + .build(); + + int maxRetries = 3; // 최대 재시도 횟수 + int attempt = 0; + + // 이미지 업로드 요청 실패 시 최대 3회까지 재시도 + while (true) { + try { + s3Client.putObject( + putObjectRequest, + RequestBody.fromInputStream( + imageFile.getInputStream(), imageFile.getSize())); + return fileName; + } catch (AwsServiceException | SdkClientException | IOException e) { + attempt++; + log.warn( + "[warn] Failed to upload image attempt {}/{}. Error: {}", + attempt, + maxRetries, + e.getMessage()); + + if (attempt >= maxRetries) { + // 재시도 끝까지 실패하면 예외 던지기 + throw new CustomException(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE); + } + try { + Thread.sleep(1000L * attempt); // 1초, 2초, 3초 점진적 딜레이 + } catch (InterruptedException ie) { + Thread.currentThread().interrupt(); // 인터럽트 발생 시 즉시 종료 + throw new CustomException(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE); + } + } + } + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java b/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java new file mode 100644 index 0000000..bea6a5e --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/s3/S3UserImageUploadAdapter.java @@ -0,0 +1,23 @@ +package com.ftm.server.adapter.out.s3; + +import com.ftm.server.application.port.out.s3.S3ImageUploadPort; +import com.ftm.server.application.port.out.s3.S3UserImageUploadPort; +import com.ftm.server.common.annotation.Adapter; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.web.multipart.MultipartFile; + +@Adapter +@RequiredArgsConstructor +public class S3UserImageUploadAdapter implements S3UserImageUploadPort { + + @Value("${aws.s3.path.user}") + private String path; + + private final S3ImageUploadPort s3ImageUploadPort; + + @Override + public String uploadImage(MultipartFile imageFile) { + return s3ImageUploadPort.updateImage(imageFile, path); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/tika/TikaFileDetectionAdapter.java b/src/main/java/com/ftm/server/adapter/out/tika/TikaFileDetectionAdapter.java new file mode 100644 index 0000000..9b3eca3 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/tika/TikaFileDetectionAdapter.java @@ -0,0 +1,16 @@ +package com.ftm.server.adapter.out.tika; + +import com.ftm.server.application.port.out.tika.TikaFileTypeDetectionPort; +import com.ftm.server.common.annotation.Adapter; +import java.io.IOException; +import org.apache.tika.Tika; +import org.springframework.web.multipart.MultipartFile; + +@Adapter +public class TikaFileDetectionAdapter implements TikaFileTypeDetectionPort { + @Override + public String detectFileType(MultipartFile file) throws IOException { // 이미지 type 추출 + Tika tika = new Tika(); + return tika.detect(file.getInputStream()); + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/transaction/AfterCommitExecutorAdapter.java b/src/main/java/com/ftm/server/adapter/out/transaction/AfterCommitExecutorAdapter.java new file mode 100644 index 0000000..43339e6 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/transaction/AfterCommitExecutorAdapter.java @@ -0,0 +1,22 @@ +package com.ftm.server.adapter.out.transaction; + +import com.ftm.server.application.port.out.transcation.AfterCommitExecutorPort; +import com.ftm.server.common.annotation.Adapter; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Adapter +public class AfterCommitExecutorAdapter implements AfterCommitExecutorPort { + @Override + public void doAfterCommit(Runnable task) { // transaction commit 이후에 task를 실행 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCommit() { + task.run(); + } + }); + } + } +} diff --git a/src/main/java/com/ftm/server/adapter/out/transaction/AfterRollbackExecutorAdapter.java b/src/main/java/com/ftm/server/adapter/out/transaction/AfterRollbackExecutorAdapter.java new file mode 100644 index 0000000..cea0af3 --- /dev/null +++ b/src/main/java/com/ftm/server/adapter/out/transaction/AfterRollbackExecutorAdapter.java @@ -0,0 +1,24 @@ +package com.ftm.server.adapter.out.transaction; + +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.common.annotation.Adapter; +import org.springframework.transaction.support.TransactionSynchronization; +import org.springframework.transaction.support.TransactionSynchronizationManager; + +@Adapter +public class AfterRollbackExecutorAdapter implements AfterRollbackExecutorPort { + @Override + public void doAfterRollback(Runnable task) { // transaction rollback 이후에 task 실행 + if (TransactionSynchronizationManager.isSynchronizationActive()) { + TransactionSynchronizationManager.registerSynchronization( + new TransactionSynchronization() { + @Override + public void afterCompletion(int status) { + if (status == TransactionSynchronization.STATUS_ROLLED_BACK) { + task.run(); + } + } + }); + } + } +} diff --git a/src/main/java/com/ftm/server/application/command/user/UpdateUserCommand.java b/src/main/java/com/ftm/server/application/command/user/UpdateUserCommand.java new file mode 100644 index 0000000..cced455 --- /dev/null +++ b/src/main/java/com/ftm/server/application/command/user/UpdateUserCommand.java @@ -0,0 +1,28 @@ +package com.ftm.server.application.command.user; + +import com.ftm.server.adapter.in.web.user.dto.request.UpdateUserInfoRequest; +import com.ftm.server.domain.enums.AgeGroup; +import com.ftm.server.domain.enums.HashTag; +import lombok.Data; +import org.springframework.web.multipart.MultipartFile; + +@Data +public class UpdateUserCommand { + private final Long userId; + private final String nickname; + private final AgeGroup ageGroup; + private final HashTag[] hashTags; + private final String imageAction; + private final MultipartFile profileImage; + + public static UpdateUserCommand from( + Long userId, UpdateUserInfoRequest request, MultipartFile imageFile) { + return new UpdateUserCommand( + userId, + request.getNickname(), + request.getAge(), + request.getHashtags(), + request.getImageAction(), + imageFile); + } +} diff --git a/src/main/java/com/ftm/server/application/port/in/user/GetUserSimpleInfoUseCase.java b/src/main/java/com/ftm/server/application/port/in/user/GetUserSimpleInfoUseCase.java new file mode 100644 index 0000000..aa64380 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/user/GetUserSimpleInfoUseCase.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.in.user; + +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.vo.user.UserWithImageVo; + +public interface GetUserSimpleInfoUseCase { + UserWithImageVo execute(FindByUserIdQuery query); +} diff --git a/src/main/java/com/ftm/server/application/port/in/user/UpdateUserInfoUseCase.java b/src/main/java/com/ftm/server/application/port/in/user/UpdateUserInfoUseCase.java new file mode 100644 index 0000000..e1fb9e1 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/in/user/UpdateUserInfoUseCase.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.in.user; + +import com.ftm.server.application.command.user.UpdateUserCommand; +import com.ftm.server.application.vo.user.UserWithImageVo; +import jakarta.transaction.Transactional; + +public interface UpdateUserInfoUseCase { + @Transactional + UserWithImageVo execute(UpdateUserCommand updateUserCommand); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserImagePort.java new file mode 100644 index 0000000..3485918 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserImagePort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.persistence.user; + +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.domain.entity.UserImage; + +public interface LoadUserImagePort { + UserImage loadUserImageByUserId(FindByUserIdQuery query); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserPort.java new file mode 100644 index 0000000..4a43236 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/LoadUserPort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.persistence.user; + +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.domain.entity.User; + +public interface LoadUserPort { + User loadUserById(FindByUserIdQuery query); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserImagePort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserImagePort.java new file mode 100644 index 0000000..c4aee8b --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserImagePort.java @@ -0,0 +1,7 @@ +package com.ftm.server.application.port.out.persistence.user; + +import com.ftm.server.domain.entity.UserImage; + +public interface UpdateUserImagePort { + void updateUserImage(UserImage userImage); +} diff --git a/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserInfoPort.java b/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserInfoPort.java new file mode 100644 index 0000000..bf4da1b --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/persistence/user/UpdateUserInfoPort.java @@ -0,0 +1,9 @@ +package com.ftm.server.application.port.out.persistence.user; + +import com.ftm.server.common.annotation.Port; +import com.ftm.server.domain.entity.User; + +@Port +public interface UpdateUserInfoPort { + void updateUserInfo(User user); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/.gitkeep b/src/main/java/com/ftm/server/application/port/out/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java new file mode 100644 index 0000000..bb373f0 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageDeletePort.java @@ -0,0 +1,5 @@ +package com.ftm.server.application.port.out.s3; + +public interface S3ImageDeletePort { + void deleteImage(String objectKey); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java new file mode 100644 index 0000000..cdb380c --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3ImageUploadPort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.s3; + +import org.springframework.web.multipart.MultipartFile; + +public interface S3ImageUploadPort { + + String updateImage(MultipartFile imageFile, String dirName); +} diff --git a/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java b/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java new file mode 100644 index 0000000..e98395f --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/s3/S3UserImageUploadPort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.s3; + +import org.springframework.web.multipart.MultipartFile; + +public interface S3UserImageUploadPort { + + public String uploadImage(MultipartFile imageFile); +} diff --git a/src/main/java/com/ftm/server/application/port/out/tika/TikaFileTypeDetectionPort.java b/src/main/java/com/ftm/server/application/port/out/tika/TikaFileTypeDetectionPort.java new file mode 100644 index 0000000..ec763f1 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/tika/TikaFileTypeDetectionPort.java @@ -0,0 +1,10 @@ +package com.ftm.server.application.port.out.tika; + +import com.ftm.server.common.annotation.Port; +import java.io.IOException; +import org.springframework.web.multipart.MultipartFile; + +@Port +public interface TikaFileTypeDetectionPort { + String detectFileType(MultipartFile file) throws IOException; +} diff --git a/src/main/java/com/ftm/server/application/port/out/transcation/AfterCommitExecutorPort.java b/src/main/java/com/ftm/server/application/port/out/transcation/AfterCommitExecutorPort.java new file mode 100644 index 0000000..f358355 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/transcation/AfterCommitExecutorPort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.transcation; + +import com.ftm.server.common.annotation.Port; + +@Port +public interface AfterCommitExecutorPort { + void doAfterCommit(Runnable task); +} diff --git a/src/main/java/com/ftm/server/application/port/out/transcation/AfterRollbackExecutorPort.java b/src/main/java/com/ftm/server/application/port/out/transcation/AfterRollbackExecutorPort.java new file mode 100644 index 0000000..a0840a5 --- /dev/null +++ b/src/main/java/com/ftm/server/application/port/out/transcation/AfterRollbackExecutorPort.java @@ -0,0 +1,8 @@ +package com.ftm.server.application.port.out.transcation; + +import com.ftm.server.common.annotation.Port; + +@Port +public interface AfterRollbackExecutorPort { + void doAfterRollback(Runnable task); +} diff --git a/src/main/java/com/ftm/server/application/service/user/GetUserSimpleInfoService.java b/src/main/java/com/ftm/server/application/service/user/GetUserSimpleInfoService.java new file mode 100644 index 0000000..a4501b0 --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/user/GetUserSimpleInfoService.java @@ -0,0 +1,28 @@ +package com.ftm.server.application.service.user; + +import com.ftm.server.application.port.in.user.GetUserSimpleInfoUseCase; +import com.ftm.server.application.port.out.persistence.user.LoadUserImagePort; +import com.ftm.server.application.port.out.persistence.user.LoadUserPort; +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class GetUserSimpleInfoService implements GetUserSimpleInfoUseCase { + + private final LoadUserPort loadUserPort; + private final LoadUserImagePort loadUserImagePort; + + @Override + public UserWithImageVo execute(FindByUserIdQuery query) { + + User user = loadUserPort.loadUserById(query); + UserImage userImage = loadUserImagePort.loadUserImageByUserId(query); + + return UserWithImageVo.of(user, userImage); + } +} diff --git a/src/main/java/com/ftm/server/application/service/user/UpdateUserInfoService.java b/src/main/java/com/ftm/server/application/service/user/UpdateUserInfoService.java new file mode 100644 index 0000000..c60d4ad --- /dev/null +++ b/src/main/java/com/ftm/server/application/service/user/UpdateUserInfoService.java @@ -0,0 +1,95 @@ +package com.ftm.server.application.service.user; + +import com.ftm.server.application.command.user.UpdateUserCommand; +import com.ftm.server.application.port.in.user.UpdateUserInfoUseCase; +import com.ftm.server.application.port.out.persistence.user.LoadUserImagePort; +import com.ftm.server.application.port.out.persistence.user.LoadUserPort; +import com.ftm.server.application.port.out.persistence.user.UpdateUserImagePort; +import com.ftm.server.application.port.out.persistence.user.UpdateUserInfoPort; +import com.ftm.server.application.port.out.s3.S3ImageDeletePort; +import com.ftm.server.application.port.out.s3.S3UserImageUploadPort; +import com.ftm.server.application.port.out.transcation.AfterCommitExecutorPort; +import com.ftm.server.application.port.out.transcation.AfterRollbackExecutorPort; +import com.ftm.server.application.query.FindByUserIdQuery; +import com.ftm.server.application.vo.user.UserWithImageVo; +import com.ftm.server.common.consts.PropertiesHolder; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import jakarta.transaction.Transactional; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +@Slf4j +public class UpdateUserInfoService implements UpdateUserInfoUseCase { + + private final LoadUserImagePort loadUserImagePort; + private final LoadUserPort loadUserPort; + + private final UpdateUserInfoPort updateUserInfoPort; + private final UpdateUserImagePort updateUserImagePort; + + private final S3UserImageUploadPort s3UserImageUploadPort; + private final S3ImageDeletePort s3ImageDeletePort; + + private final AfterCommitExecutorPort afterCommitExecutorPort; + private final AfterRollbackExecutorPort afterRollbackExecutorPort; + + @Override + @Transactional + public UserWithImageVo execute(UpdateUserCommand updateUserCommand) { + Long userId = updateUserCommand.getUserId(); + + User user = loadUserPort.loadUserById(FindByUserIdQuery.of(userId)); + UserImage userImage = loadUserImagePort.loadUserImageByUserId(FindByUserIdQuery.of(userId)); + + if (updateUserCommand.getNickname() != null) { + user.updateUserNickname(updateUserCommand.getNickname()); + } + if (updateUserCommand.getAgeGroup() != null) { + user.updateAge(updateUserCommand.getAgeGroup()); + } + if (updateUserCommand.getHashTags() != null) { + user.updateHashtag(updateUserCommand.getHashTags()); + } + + // 이미지 업로드 및 기존 이미지 삭제 + if (updateUserCommand.getImageAction() != null + && updateUserCommand.getImageAction().equals("UPLOAD") + && updateUserCommand.getProfileImage() != null + && !updateUserCommand.getProfileImage().isEmpty()) { + + String oldUserImage = userImage.getObjectKey(); + String objectKey = + s3UserImageUploadPort.uploadImage(updateUserCommand.getProfileImage()); + userImage.updateUserImage(objectKey); + + // 기존에 존재하던 이미지가 있으면 삭제 + if (!oldUserImage.equals(PropertiesHolder.USER_DEFAULT_IMAGE)) { + afterCommitExecutorPort.doAfterCommit( + () -> s3ImageDeletePort.deleteImage(oldUserImage)); + } + + // transaction rollback 이후 s3에 올라간 이미지 삭제 + afterRollbackExecutorPort.doAfterRollback( + () -> s3ImageDeletePort.deleteImage(objectKey)); + } else if (updateUserCommand.getImageAction() != null + && updateUserCommand.getImageAction().equals("DELETE")) { + + String oldUserImage = userImage.getObjectKey(); + + if (!oldUserImage.equals(PropertiesHolder.USER_DEFAULT_IMAGE)) { + userImage.updateDefaultUserImage(); + afterCommitExecutorPort.doAfterCommit( // transaction commit 이후에 s3에서 이미지 삭제 + () -> s3ImageDeletePort.deleteImage(oldUserImage)); + } + } + + updateUserInfoPort.updateUserInfo(user); + updateUserImagePort.updateUserImage(userImage); + + return UserWithImageVo.of(user, userImage); + } +} diff --git a/src/main/java/com/ftm/server/application/vo/user/UserWithImageVo.java b/src/main/java/com/ftm/server/application/vo/user/UserWithImageVo.java new file mode 100644 index 0000000..c8ce772 --- /dev/null +++ b/src/main/java/com/ftm/server/application/vo/user/UserWithImageVo.java @@ -0,0 +1,15 @@ +package com.ftm.server.application.vo.user; + +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import lombok.Data; + +@Data +public class UserWithImageVo { + private final User user; + private final UserImage userImage; + + public static UserWithImageVo of(User user, UserImage userImage) { + return new UserWithImageVo(user, userImage); + } +} diff --git a/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java b/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java index bc83094..971ffe3 100644 --- a/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java +++ b/src/main/java/com/ftm/server/common/consts/PropertiesHolder.java @@ -10,10 +10,15 @@ public class PropertiesHolder { @Value("${cdn.path.root}") private String cdnPathValue; + @Value("${aws.s3.default.user}") + private String userDefaultImage; + + public static String USER_DEFAULT_IMAGE; public static String CDN_PATH; @PostConstruct public void init() { CDN_PATH = cdnPathValue; + USER_DEFAULT_IMAGE = userDefaultImage; } } diff --git a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java index 340beaa..a5ae57d 100644 --- a/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java +++ b/src/main/java/com/ftm/server/common/response/enums/ErrorResponseCode.java @@ -17,6 +17,8 @@ public enum ErrorResponseCode { HttpStatus.BAD_REQUEST, "E400_005", "유효하지 않은 그루밍 테스트 질문 정보입니다."), INVALID_GROOMING_TEST_ANSWER_ID( HttpStatus.BAD_REQUEST, "E400_006", "유효하지 않은 그루밍 테스트 답변 정보입니다."), + INVALID_IMAGE_FORMAT( + HttpStatus.BAD_REQUEST, "E400_007", "유효하지 않은 이미지입니다. 포맷, 크기(최대 10MB), 존재 유무를 확인해 주세요."), // 401번 NOT_AUTHENTICATED(HttpStatus.UNAUTHORIZED, "E401_001", "인증되지 않은 사용자입니다."), @@ -40,6 +42,8 @@ public enum ErrorResponseCode { // 500번 UNKNOWN_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "E500_001", "알 수 없는 서버 에러가 발생했습니다."), FAIL_TO_SEND_EMAIL(HttpStatus.INTERNAL_SERVER_ERROR, "E500_002", "서버 내부 문제로 메일 전송에 실패했습니다."), + FAIL_TO_UPLOAD_IMAGE( + HttpStatus.INTERNAL_SERVER_ERROR, "E500_003", "알 수 없는 이유로 이미지 업로드에 실패했습니다."), // 502번 (외부 서비스에서 문제 발생) KAKAO_AUTH_TOKEN_EXCHANGE_FAILED(HttpStatus.BAD_GATEWAY, "E502_001", "카카오 인증 토큰 요청에 실패했습니다."), diff --git a/src/main/java/com/ftm/server/domain/entity/User.java b/src/main/java/com/ftm/server/domain/entity/User.java index ed2614a..20e2b80 100644 --- a/src/main/java/com/ftm/server/domain/entity/User.java +++ b/src/main/java/com/ftm/server/domain/entity/User.java @@ -153,4 +153,16 @@ public void updateGroomingInfo(Integer groomingScore, Long groomingLevelId) { this.groomingScore = groomingScore; this.groomingLevelId = groomingLevelId; } + + public void updateUserNickname(String nickname) { + this.nickname = nickname; + } + + public void updateHashtag(HashTag[] hashTags) { + this.favoriteHashtags = hashTags; + } + + public void updateAge(AgeGroup ageGroup) { + this.ageGroup = ageGroup; + } } diff --git a/src/main/java/com/ftm/server/domain/entity/UserImage.java b/src/main/java/com/ftm/server/domain/entity/UserImage.java index 76525c1..75d1004 100644 --- a/src/main/java/com/ftm/server/domain/entity/UserImage.java +++ b/src/main/java/com/ftm/server/domain/entity/UserImage.java @@ -1,5 +1,6 @@ package com.ftm.server.domain.entity; +import com.ftm.server.common.consts.PropertiesHolder; import java.time.LocalDateTime; import lombok.AccessLevel; import lombok.Builder; @@ -12,7 +13,7 @@ public class UserImage extends BaseTime { private Long id; private Long userId; - private String objectKey = "default-image"; + private String objectKey = PropertiesHolder.USER_DEFAULT_IMAGE; @Builder(access = AccessLevel.PRIVATE) private UserImage( @@ -44,6 +45,17 @@ public static UserImage of( } public static UserImage createUserImage(Long userId) { - return UserImage.builder().userId(userId).objectKey("users/default-image.png").build(); + return UserImage.builder() + .userId(userId) + .objectKey(PropertiesHolder.USER_DEFAULT_IMAGE) + .build(); + } + + public void updateDefaultUserImage() { + this.objectKey = PropertiesHolder.USER_DEFAULT_IMAGE; + } + + public void updateUserImage(String objectKey) { + this.objectKey = objectKey; } } diff --git a/src/main/java/com/ftm/server/infrastructure/s3/.gitkeep b/src/main/java/com/ftm/server/infrastructure/s3/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/java/com/ftm/server/infrastructure/s3/S3Config.java b/src/main/java/com/ftm/server/infrastructure/s3/S3Config.java new file mode 100644 index 0000000..f9f58e1 --- /dev/null +++ b/src/main/java/com/ftm/server/infrastructure/s3/S3Config.java @@ -0,0 +1,25 @@ +package com.ftm.server.infrastructure.s3; + +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import software.amazon.awssdk.auth.credentials.DefaultCredentialsProvider; +import software.amazon.awssdk.regions.Region; +import software.amazon.awssdk.services.s3.S3Client; + +@Configuration +public class S3Config { + + @Value("${aws.s3.region}") + private String region; + + @Bean + public S3Client s3Client() { + return S3Client.builder() + .region(Region.of(region)) + .credentialsProvider( + DefaultCredentialsProvider + .create()) // EC2에서는 자동 인식 & window&mac 에서는 환경 변수 설정 필요 + .build(); + } +} diff --git a/src/main/resources/application-storage.yml b/src/main/resources/application-storage.yml index 78a6ce2..9e35abc 100644 --- a/src/main/resources/application-storage.yml +++ b/src/main/resources/application-storage.yml @@ -6,4 +6,8 @@ spring: aws: s3: bucket-name: ${S3_BUCKET_NAME} - region: ${AWS_REGION} \ No newline at end of file + region: ${AWS_REGION} + default: + user : "users/default-image.jpg" + path: + user: "users" \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index fb80e35..353fde2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -8,7 +8,10 @@ spring: - redis - storage - security - + servlet: + multipart: + max-file-size: 10MB + max-request-size: 10MB mail: host: smtp.gmail.com port: 587 diff --git a/src/test/java/com/ftm/server/BaseTest.java b/src/test/java/com/ftm/server/BaseTest.java index 127a629..d347841 100644 --- a/src/test/java/com/ftm/server/BaseTest.java +++ b/src/test/java/com/ftm/server/BaseTest.java @@ -4,7 +4,17 @@ import static org.springframework.restdocs.operation.preprocess.Preprocessors.modifyHeaders; import com.fasterxml.jackson.databind.ObjectMapper; +import com.ftm.server.application.command.user.GeneralUserCreationCommand; +import com.ftm.server.application.port.out.persistence.user.SaveUserImagePort; +import com.ftm.server.application.port.out.persistence.user.SaveUserPort; +import com.ftm.server.domain.entity.User; +import com.ftm.server.domain.entity.UserImage; +import com.ftm.server.domain.enums.AgeGroup; +import com.ftm.server.domain.enums.HashTag; +import com.ftm.server.domain.enums.UserRole; +import com.ftm.server.infrastructure.security.UserPrincipal; import groovy.util.logging.Slf4j; +import java.util.List; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.extension.ExtendWith; import org.springframework.beans.factory.annotation.Autowired; @@ -12,10 +22,16 @@ import org.springframework.boot.test.autoconfigure.restdocs.AutoConfigureRestDocs; import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.mock.web.MockHttpSession; import org.springframework.restdocs.RestDocumentationContextProvider; import org.springframework.restdocs.RestDocumentationExtension; import org.springframework.restdocs.operation.preprocess.HeadersModifyingOperationPreprocessor; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContext; +import org.springframework.security.core.context.SecurityContextHolder; import org.springframework.security.test.web.servlet.setup.SecurityMockMvcConfigurers; +import org.springframework.security.web.context.HttpSessionSecurityContextRepository; import org.springframework.test.context.ActiveProfiles; import org.springframework.test.context.TestPropertySource; import org.springframework.test.web.servlet.MockMvc; @@ -37,6 +53,9 @@ public class BaseTest { protected final ObjectMapper mapper = new ObjectMapper(); + @Autowired private SaveUserPort saveUserPort; + @Autowired private SaveUserImagePort saveUserImagePort; + @BeforeEach void setup(WebApplicationContext context, RestDocumentationContextProvider restDocumentation) { this.mockMvc = @@ -58,4 +77,45 @@ protected HeadersModifyingOperationPreprocessor getModifiedHeader() { .remove("X-Frame-Options") .remove("Vary"); } + + // 사용자 생성 및 저장 + protected User createTestUser(String email, String password) { + User user = + User.createGeneralUser( + GeneralUserCreationCommand.of( + email, + password, + "test 사용자", + AgeGroup.FIFTIES, + List.of(HashTag.PERFUME))); + User testUser = saveUserPort.saveUser(user); + saveUserImagePort.saveUserDefaultImage(UserImage.createUserImage(testUser.getId())); + return testUser; + } + + protected MockHttpSession createUserAndLogin() { + return createUserAndLogin("test@gmail.com", "123456qwe!"); + } + + // test 사용자 생성 후 mock session 생성 + protected MockHttpSession createUserAndLogin(String email, String password) { + + // 사용자 생성 + User user = createTestUser(email, password); + + // session 생성 + SecurityContext context = SecurityContextHolder.createEmptyContext(); + UsernamePasswordAuthenticationToken auth = + new UsernamePasswordAuthenticationToken( + UserPrincipal.of(user), + null, + List.of(new SimpleGrantedAuthority(UserRole.USER.name()))); + context.setAuthentication(auth); + + MockHttpSession session = new MockHttpSession(); + session.setAttribute( + HttpSessionSecurityContextRepository.SPRING_SECURITY_CONTEXT_KEY, context); + + return session; + } } diff --git a/src/test/java/com/ftm/server/user/GetUserSimpleInfoTest.java b/src/test/java/com/ftm/server/user/GetUserSimpleInfoTest.java new file mode 100644 index 0000000..7547c65 --- /dev/null +++ b/src/test/java/com/ftm/server/user/GetUserSimpleInfoTest.java @@ -0,0 +1,99 @@ +package com.ftm.server.user; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.fieldWithPath; +import static org.springframework.restdocs.payload.PayloadDocumentation.responseFields; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import jakarta.transaction.Transactional; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.test.web.servlet.ResultActions; + +public class GetUserSimpleInfoTest extends BaseTest { + + private final List responseFieldDescriptors = + List.of( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("응답 상태"), + fieldWithPath("code").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"), + fieldWithPath("data").type(JsonFieldType.OBJECT).optional().description("data"), + fieldWithPath("data.userId") + .type(JsonFieldType.NUMBER) + .description("사용자 고유 id"), + fieldWithPath("data.userNickname") + .type(JsonFieldType.STRING) + .description("사용자 닉네임"), + fieldWithPath("data.imageUrl") + .type(JsonFieldType.STRING) + .description("사용자 프로필 이미지 url"), + fieldWithPath("data.ageInfo") + .type(JsonFieldType.OBJECT) + .description("사용자 연령대 정보"), + fieldWithPath("data.ageInfo.value") + .type(JsonFieldType.STRING) + .description("연령대 정보 고유값 이름"), + fieldWithPath("data.ageInfo.description") + .type(JsonFieldType.STRING) + .description("연령대 정보 설명"), + fieldWithPath("data.hashTagInfo") + .type(JsonFieldType.ARRAY) + .description( + "관심사 해시태그 목록 정보. 우리 서비스에서 사용되는 모든 해시태그 목록을 나열하되, 그 중 사용자가 관심사로 등록한 것과 등록하지 않은 것으로 구분합니다."), + fieldWithPath("data.hashTagInfo[].value") + .type(JsonFieldType.STRING) + .description("해시태그 고유값 이름"), + fieldWithPath("data.hashTagInfo[].description") + .type(JsonFieldType.STRING) + .description("해시태그 값 한글 설명"), + fieldWithPath("data.hashTagInfo[].isSelected") + .type(JsonFieldType.BOOLEAN) + .description("사용자가 해당 해시태그를 관심사로 등록했는지 여부")); + + private ResultActions getResultActions(MockHttpSession session) throws Exception { + return mockMvc.perform( // api 실행 + RestDocumentationRequestBuilders.get("/api/users/info/simple").session(session)); + } + + // 문서화 반환 함수 + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "userSimpleInfo/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFieldDescriptors), + resource( + ResourceSnippetParameters.builder() + .tag("회원") + .summary("간단한 회원 정보 조회 api") + .description("사용자 정보 수정 시 제공되는 기존 사용자 정보를 조회") + .responseFields(responseFieldDescriptors) + .build())); + } + + @Test + @Transactional + void 사용자정보_간단_조회_성공() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + // when + ResultActions resultActions = getResultActions(session); + + // then + resultActions.andExpect(status().isOk()); + + // documentation + resultActions.andDo(getDocument(1)); + } +} diff --git a/src/test/java/com/ftm/server/user/UpdateUserInfoTest.java b/src/test/java/com/ftm/server/user/UpdateUserInfoTest.java new file mode 100644 index 0000000..6d9f3e6 --- /dev/null +++ b/src/test/java/com/ftm/server/user/UpdateUserInfoTest.java @@ -0,0 +1,240 @@ +package com.ftm.server.user; + +import static com.epages.restdocs.apispec.ResourceDocumentation.resource; +import static org.mockito.Mockito.*; +import static org.springframework.restdocs.mockmvc.MockMvcRestDocumentation.document; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.*; +import static org.springframework.restdocs.operation.preprocess.Preprocessors.prettyPrint; +import static org.springframework.restdocs.payload.PayloadDocumentation.*; +import static org.springframework.restdocs.request.RequestDocumentation.partWithName; +import static org.springframework.restdocs.request.RequestDocumentation.requestParts; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +import com.epages.restdocs.apispec.ResourceSnippetParameters; +import com.ftm.server.BaseTest; +import com.ftm.server.adapter.in.web.user.dto.request.UpdateUserInfoRequest; +import com.ftm.server.application.port.out.s3.S3UserImageUploadPort; +import com.ftm.server.common.exception.CustomException; +import com.ftm.server.common.response.enums.ErrorResponseCode; +import jakarta.transaction.Transactional; +import java.util.List; +import org.junit.jupiter.api.Test; +import org.springframework.http.MediaType; +import org.springframework.mock.web.MockHttpSession; +import org.springframework.mock.web.MockMultipartFile; +import org.springframework.restdocs.mockmvc.RestDocumentationRequestBuilders; +import org.springframework.restdocs.mockmvc.RestDocumentationResultHandler; +import org.springframework.restdocs.payload.FieldDescriptor; +import org.springframework.restdocs.payload.JsonFieldType; +import org.springframework.restdocs.request.RequestPartDescriptor; +import org.springframework.restdocs.snippet.Attributes; +import org.springframework.test.context.bean.override.mockito.MockitoSpyBean; +import org.springframework.test.web.servlet.ResultActions; + +public class UpdateUserInfoTest extends BaseTest { + + @MockitoSpyBean private S3UserImageUploadPort s3UserImageUploadPort; + + private final List responseFieldDescriptors = + List.of( + fieldWithPath("status").type(JsonFieldType.NUMBER).description("응답 상태"), + fieldWithPath("code").type(JsonFieldType.STRING).description("상태 코드"), + fieldWithPath("message").type(JsonFieldType.STRING).description("메시지"), + fieldWithPath("data").type(JsonFieldType.OBJECT).optional().description("data"), + fieldWithPath("data.userId") + .type(JsonFieldType.NUMBER) + .description("사용자 고유 id"), + fieldWithPath("data.userNickname") + .type(JsonFieldType.STRING) + .description("사용자 닉네임"), + fieldWithPath("data.imageUrl") + .type(JsonFieldType.STRING) + .description("사용자 프로필 이미지 url"), + fieldWithPath("data.ageInfo") + .type(JsonFieldType.OBJECT) + .description("사용자 연령대 정보"), + fieldWithPath("data.ageInfo.value") + .type(JsonFieldType.STRING) + .description("연령대 정보 고유값 이름"), + fieldWithPath("data.ageInfo.description") + .type(JsonFieldType.STRING) + .description("연령대 정보 설명"), + fieldWithPath("data.hashTagInfo") + .type(JsonFieldType.ARRAY) + .description("관심사 해시태그 목록 정보"), + fieldWithPath("data.hashTagInfo[].value") + .type(JsonFieldType.STRING) + .description("해시태그 고유값 이름"), + fieldWithPath("data.hashTagInfo[].description") + .type(JsonFieldType.STRING) + .description("해시태그 값 한글 설명"), + fieldWithPath("data.hashTagInfo[].isSelected") + .type(JsonFieldType.BOOLEAN) + .description("사용자가 해당 해시태그를 관심사로 등록했는지 여부")); + + List requestPartDescriptors = + List.of( + partWithName("data") + .description("사용자 변경 정보") + .attributes( + new Attributes.Attribute("content-type", "application/json")), + partWithName("imageFile") + .description("이미지를 등록/갱신하는 경우에만 첨부") + .attributes(new Attributes.Attribute("content-type", "image/*"))); + + List requestPartFieldDescriptors = + List.of( + fieldWithPath("nickname") + .type(JsonFieldType.STRING) + .description("사용자 별명") + .optional(), + fieldWithPath("age") + .type(JsonFieldType.STRING) + .description("연령대 : 사용자 정보 옵션 조회 API에서 제공된 값으로 넣어주세요.") + .optional(), + fieldWithPath("imageAction") + .type(JsonFieldType.STRING) + .description("이미지 처리 방법") + .attributes( + new Attributes.Attribute( + "constraint", + """ + 이미지 처리 방법 + + 1. UPLOAD : 이미지 등록/변경 + + 2. DELETE : 이미지를 삭제함 + """)) + .optional(), + fieldWithPath("hashtags") + .type(JsonFieldType.ARRAY) + .description("관심사 정보 : 사용자 정보 옵션 조회 API에서 제공된 값으로 넣어주세요.") + .optional()); + + private ResultActions getResultActions( + MockHttpSession session, MockMultipartFile image, MockMultipartFile data) + throws Exception { + return mockMvc.perform( // api 실행 + RestDocumentationRequestBuilders.multipart("/api/users/info") + .file(image) + .file(data) + .contentType(MediaType.MULTIPART_FORM_DATA) + .session(session) + .with( + request -> { + request.setMethod("PATCH"); // PATCH로 변경! + return request; + })); + } + + // 문서화 반환 함수 + private RestDocumentationResultHandler getDocument(Integer identifier) { + return document( + "userInfoUpdate/" + identifier, + preprocessRequest(prettyPrint()), + preprocessResponse(prettyPrint(), getModifiedHeader()), + responseFields(responseFieldDescriptors), + requestParts(requestPartDescriptors), + requestPartFields("data", requestPartFieldDescriptors), + resource( + ResourceSnippetParameters.builder() + .tag("회원") + .summary("사용자 정보 수정 api") + .description("사용자 정보를 수정함") + .responseFields(responseFieldDescriptors) + .build())); + } + + @Test + @Transactional + void 사용자정보_수정_성공() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + UpdateUserInfoRequest request = new UpdateUserInfoRequest("거북이", null, null, "UPLOAD"); + MockMultipartFile image = + new MockMultipartFile("imageFile", "test.jpg", "image/jpg", "test".getBytes()); + MockMultipartFile data = + new MockMultipartFile( + "data", + "", + "application/json", + mapper.writeValueAsString(request).getBytes()); + + // s3 실제 호출 대신 mock 대입 + doReturn("users/test.jpg").when(s3UserImageUploadPort).uploadImage(image); + + // when + ResultActions resultActions = getResultActions(session, image, data); + + // then + resultActions.andExpect(status().isOk()); + + // documentation + resultActions.andDo(getDocument(1)); + } + + @Test + @Transactional + void 사용자정보_수정_실패1() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + UpdateUserInfoRequest request = new UpdateUserInfoRequest("거북이", null, null, "UPLOAD"); + MockMultipartFile image = + new MockMultipartFile("imageFile", "test.jpg", "plain/text", "test".getBytes()); + MockMultipartFile data = + new MockMultipartFile( + "data", + "", + "application/json", + mapper.writeValueAsString(request).getBytes()); + + // when + ResultActions resultActions = getResultActions(session, image, data); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.INVALID_IMAGE_FORMAT.getHttpStatus().value())) + .andExpect( + jsonPath("code").value(ErrorResponseCode.INVALID_IMAGE_FORMAT.getCode())); + + // documentation + resultActions.andDo(getDocument(2)); + } + + @Test + @Transactional + void 사용자정보_수정_실패2() throws Exception { + // given + MockHttpSession session = createUserAndLogin(); + + UpdateUserInfoRequest request = new UpdateUserInfoRequest("거북이", null, null, "UPLOAD"); + MockMultipartFile image = + new MockMultipartFile("imageFile", "test.jpg", "image/jpg", "test".getBytes()); + MockMultipartFile data = + new MockMultipartFile( + "data", + "", + "application/json", + mapper.writeValueAsString(request).getBytes()); + + // s3 실제 호출 대신 mock 대입 + doThrow(new CustomException(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE)) + .when(s3UserImageUploadPort) + .uploadImage(image); + + // when + ResultActions resultActions = getResultActions(session, image, data); + + // then + resultActions + .andExpect( + status().is(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE.getHttpStatus().value())) + .andExpect( + jsonPath("code").value(ErrorResponseCode.FAIL_TO_UPLOAD_IMAGE.getCode())); + + // documentation + resultActions.andDo(getDocument(3)); + } +}