From 0448598834512b39e211175896b9e6414ddd72ef Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 28 Apr 2025 14:31:32 +0900 Subject: [PATCH 01/15] feat: add get userInfo API --- .../kr/mayb/controller/AuthController.java | 15 +++++--- .../java/kr/mayb/service/AuthService.java | 34 +++++++++++-------- 2 files changed, 31 insertions(+), 18 deletions(-) diff --git a/src/main/java/kr/mayb/controller/AuthController.java b/src/main/java/kr/mayb/controller/AuthController.java index f06102e..f1d8acc 100644 --- a/src/main/java/kr/mayb/controller/AuthController.java +++ b/src/main/java/kr/mayb/controller/AuthController.java @@ -6,18 +6,17 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import kr.mayb.dto.AuthDto; +import kr.mayb.dto.MemberDto; import kr.mayb.dto.MemberSignupRequest; import kr.mayb.security.DenyAll; import kr.mayb.security.PermitAll; +import kr.mayb.security.PermitAuthenticated; import kr.mayb.service.AuthService; import kr.mayb.util.response.ApiResponse; import kr.mayb.util.response.Responses; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.DeleteMapping; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.bind.annotation.*; @Tag(name = "Auth", description = "인증 관련 API") @DenyAll @@ -27,6 +26,14 @@ public class AuthController { private final AuthService authService; + @Operation(summary = "유저정보") + @PermitAuthenticated + @GetMapping("/auth/me") + public ResponseEntity> me() { + MemberDto response = authService.getInfo(); + return Responses.ok(response); + } + @Operation(summary = "회원가입") @PermitAll @PostMapping("/auth/members") diff --git a/src/main/java/kr/mayb/service/AuthService.java b/src/main/java/kr/mayb/service/AuthService.java index cf734d5..3994eca 100644 --- a/src/main/java/kr/mayb/service/AuthService.java +++ b/src/main/java/kr/mayb/service/AuthService.java @@ -10,6 +10,7 @@ import kr.mayb.security.AESGCMEncoder; import kr.mayb.security.TokenDto; import kr.mayb.security.jwt.TokenHelper; +import kr.mayb.util.ContextUtils; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; import org.springframework.security.authentication.AuthenticationServiceException; @@ -27,6 +28,12 @@ public class AuthService { private final AESGCMEncoder aesgcmEncoder; private final PasswordEncoder passwordEncoder; + public MemberDto getInfo() { + return ContextUtils.getCurrentMember() + .orElseThrow(() -> new UsernameNotFoundException("Member not found")); + } + + @Transactional public AuthDto registerMember(MemberSignupRequest request) { if (isSignedUp(request.email())) { throw new BadRequestException("Already signed up email"); @@ -55,20 +62,6 @@ public AuthDto login(String email, String password) { return login(member); } - @Transactional - private AuthDto login(Member member) { - String contact = aesgcmEncoder.decrypt(member.getContact()); - MemberDto memberDto = MemberDto.of(member, contact); - TokenDto accessToken = tokenHelper.generateAccessToken(member); - TokenDto refreshToken = tokenHelper.generateRefreshToken(member); - - return new AuthDto(memberDto, accessToken, refreshToken); - } - - private boolean isSignedUp(String email) { - return memberService.existsByEmail(email); - } - @Transactional public AuthDto refresh(String refreshToken) { Claims claims = tokenHelper.getRefreshClaims(refreshToken); @@ -83,4 +76,17 @@ public AuthDto refresh(String refreshToken) { public void logout(String refreshToken) { tokenHelper.removeRefreshToken(refreshToken); } + + private boolean isSignedUp(String email) { + return memberService.existsByEmail(email); + } + + private AuthDto login(Member member) { + String contact = aesgcmEncoder.decrypt(member.getContact()); + MemberDto memberDto = MemberDto.of(member, contact); + TokenDto accessToken = tokenHelper.generateAccessToken(member); + TokenDto refreshToken = tokenHelper.generateRefreshToken(member); + + return new AuthDto(memberDto, accessToken, refreshToken); + } } From 026688a08f1250fdf52d48d3ff511a00292e9aa7 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 28 Apr 2025 17:02:33 +0900 Subject: [PATCH 02/15] build: change gcp account --- .github/workflows/deploy-feature.yml | 4 ++-- .github/workflows/remove-feature.yml | 4 ++-- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index 7f1dd3a..ef06a53 100644 --- a/.github/workflows/deploy-feature.yml +++ b/.github/workflows/deploy-feature.yml @@ -5,7 +5,7 @@ on: name: "Deploy feature to Cloud Run" env: - PROJECT_ID: mayb-api + PROJECT_ID: mayb-api-458206 GAR_LOCATION: asia-northeast3 REPOSITORY: mayb-repo IMAGE_NAME: mayb-api @@ -28,7 +28,7 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY }} + credentials_json: ${{ secrets.GCP_SA_KEY2 }} - uses: 'google-github-actions/setup-gcloud@v2' with: diff --git a/.github/workflows/remove-feature.yml b/.github/workflows/remove-feature.yml index 0cc1878..3dadf4b 100644 --- a/.github/workflows/remove-feature.yml +++ b/.github/workflows/remove-feature.yml @@ -5,7 +5,7 @@ on: name: "Remove feature from Cloud Run" env: - PROJECT_ID: mayb-api + PROJECT_ID: mayb-api-458206 REGION: asia-northeast1 jobs: @@ -24,7 +24,7 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY }} + credentials_json: ${{ secrets.GCP_SA_KEY2 }} - uses: 'google-github-actions/setup-gcloud@v1' with: From 8d9fe4642302a6b297ea8f344b76d819589ffc80 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 28 Apr 2025 17:23:31 +0900 Subject: [PATCH 03/15] build: modify GCP_SA_KEY secrets name --- .github/workflows/deploy-feature.yml | 2 +- .github/workflows/remove-feature.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index ef06a53..1a4175c 100644 --- a/.github/workflows/deploy-feature.yml +++ b/.github/workflows/deploy-feature.yml @@ -28,7 +28,7 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY2 }} + credentials_json: ${{ secrets.GCP_SA_KEY }} - uses: 'google-github-actions/setup-gcloud@v2' with: diff --git a/.github/workflows/remove-feature.yml b/.github/workflows/remove-feature.yml index 3dadf4b..b389eb5 100644 --- a/.github/workflows/remove-feature.yml +++ b/.github/workflows/remove-feature.yml @@ -24,7 +24,7 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY2 }} + credentials_json: ${{ secrets.GCP_SA_KEY }} - uses: 'google-github-actions/setup-gcloud@v1' with: From b933fa9f2445cc1a57bfc2cfc6fbd849bc02c16b Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 28 Apr 2025 18:37:55 +0900 Subject: [PATCH 04/15] build: add Mayb project sa GCP KEY --- .github/workflows/deploy-feature.yml | 6 +++++- .github/workflows/remove-feature.yml | 2 +- 2 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index 1a4175c..cfa8c92 100644 --- a/.github/workflows/deploy-feature.yml +++ b/.github/workflows/deploy-feature.yml @@ -28,11 +28,12 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY }} + credentials_json: ${{ secrets.GCP_DEPLOY_SA_KEY }} - uses: 'google-github-actions/setup-gcloud@v2' with: project_id: '${{ env.PROJECT_ID }}' + - name: 'Set variables' run: |- branch_name=$(echo "${{ github.head_ref }}" | tr '[:upper:]' '[:lower:]' | sed -E 's/[^a-z0-9]+/-/g' | sed -E 's/^-+|-+$//g') @@ -40,6 +41,7 @@ jobs: image_tag="${branch_name}-${pr_num}" echo "SERVICE_NAME=${{ vars.FEATURE_CLOUD_RUN_PREFIX }}${image_tag}" >> $GITHUB_ENV echo "IMAGE=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV + echo "${{ secrets.GCP_MAYB_SA_KEY }}" > gcp-key.json - uses: 'actions/setup-java@v3' with: @@ -50,6 +52,8 @@ jobs: run: chmod +x ./gradlew - name: Execute Gradle build run: ./gradlew build + env: + GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json - name: 'Docker auth' run: |- diff --git a/.github/workflows/remove-feature.yml b/.github/workflows/remove-feature.yml index b389eb5..1847ae7 100644 --- a/.github/workflows/remove-feature.yml +++ b/.github/workflows/remove-feature.yml @@ -24,7 +24,7 @@ jobs: - uses: 'google-github-actions/auth@v2' with: - credentials_json: ${{ secrets.GCP_SA_KEY }} + credentials_json: ${{ secrets.GCP_DEPLOY_SA_KEY }} - uses: 'google-github-actions/setup-gcloud@v1' with: From dcbe0543aab86cee7407cc9b47918807335629c7 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Mon, 28 Apr 2025 20:14:14 +0900 Subject: [PATCH 05/15] feat: add user profile image update API --- build.gradle | 5 ++ src/main/java/kr/mayb/config/GcsConfig.java | 15 ++++++ .../kr/mayb/controller/MemberController.java | 34 +++++++++++++ src/main/java/kr/mayb/data/model/Member.java | 9 ++-- src/main/java/kr/mayb/dto/MemberDto.java | 3 ++ .../java/kr/mayb/enums/GcsFolderPath.java | 23 +++++++++ .../java/kr/mayb/facade/MemberFacade.java | 27 +++++++++++ src/main/java/kr/mayb/service/GcsService.java | 48 +++++++++++++++++++ .../java/kr/mayb/service/MemberService.java | 17 +++++++ src/main/resources/application-common.yaml | 9 +++- 10 files changed, 186 insertions(+), 4 deletions(-) create mode 100644 src/main/java/kr/mayb/config/GcsConfig.java create mode 100644 src/main/java/kr/mayb/controller/MemberController.java create mode 100644 src/main/java/kr/mayb/enums/GcsFolderPath.java create mode 100644 src/main/java/kr/mayb/facade/MemberFacade.java create mode 100644 src/main/java/kr/mayb/service/GcsService.java diff --git a/build.gradle b/build.gradle index 4a75221..452a587 100644 --- a/build.gradle +++ b/build.gradle @@ -29,11 +29,16 @@ repositories { dependencies { implementation 'org.springframework.boot:spring-boot-starter-web' + implementation 'org.springframework.boot:spring-boot-starter-actuator' testImplementation 'org.springframework.boot:spring-boot-starter-test' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-validation' developmentOnly 'org.springframework.boot:spring-boot-devtools' + // GCP + implementation platform('com.google.cloud:libraries-bom:26.59.0') + implementation 'com.google.cloud:google-cloud-storage' + // Security implementation 'org.springframework.boot:spring-boot-starter-security' testImplementation 'org.springframework.security:spring-security-test' diff --git a/src/main/java/kr/mayb/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java new file mode 100644 index 0000000..2b17618 --- /dev/null +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -0,0 +1,15 @@ +package kr.mayb.config; + +import com.google.cloud.storage.Storage; +import com.google.cloud.storage.StorageOptions; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class GcsConfig { + + @Bean + public Storage storage() { + return StorageOptions.getDefaultInstance().getService(); + } +} diff --git a/src/main/java/kr/mayb/controller/MemberController.java b/src/main/java/kr/mayb/controller/MemberController.java new file mode 100644 index 0000000..b0d89a4 --- /dev/null +++ b/src/main/java/kr/mayb/controller/MemberController.java @@ -0,0 +1,34 @@ +package kr.mayb.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import kr.mayb.dto.MemberDto; +import kr.mayb.facade.MemberFacade; +import kr.mayb.security.DenyAll; +import kr.mayb.security.PermitAuthenticated; +import kr.mayb.util.response.ApiResponse; +import kr.mayb.util.response.Responses; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; + +@Tag(name = "Member", description = "유저 관련 API") +@DenyAll +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberFacade memberFacade; + + @Operation(summary = "유저 프로필 이미지 업데이트") + @PermitAuthenticated + @PostMapping("/members/profile") + public ResponseEntity> updateProfile(@RequestParam("profile") MultipartFile file) { + MemberDto response = memberFacade.updateProfile(file); + return Responses.ok(response); + } + +} diff --git a/src/main/java/kr/mayb/data/model/Member.java b/src/main/java/kr/mayb/data/model/Member.java index 814d75c..72e072a 100644 --- a/src/main/java/kr/mayb/data/model/Member.java +++ b/src/main/java/kr/mayb/data/model/Member.java @@ -3,16 +3,15 @@ import com.fasterxml.jackson.annotation.JsonIgnore; import jakarta.persistence.*; import kr.mayb.enums.AccountStatus; +import kr.mayb.enums.Gender; import lombok.Getter; import lombok.Setter; +import org.hibernate.annotations.BatchSize; import java.time.LocalDate; import java.util.ArrayList; import java.util.List; -import kr.mayb.enums.Gender; -import org.hibernate.annotations.BatchSize; - @Getter @Setter @Table(schema = "mayb") @@ -32,6 +31,9 @@ public class Member extends BaseEntity{ @Column private String name; + @Column + private String profileUrl; + @Column @Enumerated(EnumType.STRING) private Gender gender; @@ -51,6 +53,7 @@ public class Member extends BaseEntity{ @Column private String idealType; + @JsonIgnore @BatchSize(size = 10) @ManyToMany(fetch = FetchType.LAZY) diff --git a/src/main/java/kr/mayb/dto/MemberDto.java b/src/main/java/kr/mayb/dto/MemberDto.java index 8fb6aeb..9a94206 100644 --- a/src/main/java/kr/mayb/dto/MemberDto.java +++ b/src/main/java/kr/mayb/dto/MemberDto.java @@ -18,6 +18,8 @@ public class MemberDto { private String name; + private String profileUrl; + private Gender gender; private LocalDate birthdate; @@ -37,6 +39,7 @@ public static MemberDto of(Member member, String contact) { member.getId(), member.getEmail(), member.getName(), + member.getProfileUrl(), member.getGender(), member.getBirthdate(), member.getOccupation(), diff --git a/src/main/java/kr/mayb/enums/GcsFolderPath.java b/src/main/java/kr/mayb/enums/GcsFolderPath.java new file mode 100644 index 0000000..056f9b0 --- /dev/null +++ b/src/main/java/kr/mayb/enums/GcsFolderPath.java @@ -0,0 +1,23 @@ +package kr.mayb.enums; + +import kr.mayb.error.BadRequestException; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum GcsFolderPath { + PROFILE("profile/"), + PRODUCT_PROFILE("product_profile/"), + PRODUCT_DETAIL("product_detail/"), + ; + + private final String value; + + public static String getValue(GcsFolderPath type) { + return switch (type) { + case PROFILE -> PROFILE.value; + case PRODUCT_PROFILE -> PRODUCT_PROFILE.value; + case PRODUCT_DETAIL -> PRODUCT_DETAIL.value; + default -> throw new BadRequestException("Invalid GcsFolderType" + type); + }; + } +} diff --git a/src/main/java/kr/mayb/facade/MemberFacade.java b/src/main/java/kr/mayb/facade/MemberFacade.java new file mode 100644 index 0000000..d44defa --- /dev/null +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -0,0 +1,27 @@ +package kr.mayb.facade; + +import kr.mayb.data.model.Member; +import kr.mayb.dto.MemberDto; +import kr.mayb.enums.GcsFolderPath; +import kr.mayb.service.GcsService; +import kr.mayb.service.MemberService; +import kr.mayb.util.ContextUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +@RequiredArgsConstructor +public class MemberFacade { + + private final GcsService gcsService; + private final MemberService memberService; + + public MemberDto updateProfile(MultipartFile file) { + MemberDto member = ContextUtils.loadMember(); + String profileUrl = gcsService.uploadFile(file, GcsFolderPath.PROFILE); + + Member updated = memberService.updateProfile(member.getMemberId(), profileUrl); + return memberService.convertToMemberDto(updated); + } +} diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java new file mode 100644 index 0000000..87486e9 --- /dev/null +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -0,0 +1,48 @@ +package kr.mayb.service; + +import com.google.cloud.storage.BlobId; +import com.google.cloud.storage.BlobInfo; +import com.google.cloud.storage.Storage; +import kr.mayb.enums.GcsFolderPath; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class GcsService { + + private final Storage storage; + + @Value("${spring.cloud.gcp.storage.bucket}") + private String bucketName; + + public String uploadFile(MultipartFile file, GcsFolderPath type) { + try { + String originalFilename = file.getOriginalFilename(); + String contentType = file.getContentType(); + + String blobName = generateUniqueFileName(originalFilename); + String fullBlobName = GcsFolderPath.getValue(type) + blobName; + + BlobId blobId = BlobId.of(bucketName, fullBlobName); + BlobInfo blobInfo = BlobInfo.newBuilder(blobId) + .setContentType(contentType) + .build(); + + storage.create(blobInfo, file.getBytes()); + + return String.format("https://storage.googleapis.com/%s/%s", bucketName, fullBlobName); + } catch (Exception e) { + throw new IllegalArgumentException("Failed to upload file to GCS: " + e.getMessage()); + } + } + + private String generateUniqueFileName(String originalFilename) { + String uuid = UUID.randomUUID().toString(); + return uuid + "-" + originalFilename; + } +} diff --git a/src/main/java/kr/mayb/service/MemberService.java b/src/main/java/kr/mayb/service/MemberService.java index 75f51cf..7f857d4 100644 --- a/src/main/java/kr/mayb/service/MemberService.java +++ b/src/main/java/kr/mayb/service/MemberService.java @@ -5,8 +5,10 @@ import kr.mayb.data.model.Member; import kr.mayb.data.repository.AuthorityRepository; import kr.mayb.data.repository.MemberRepository; +import kr.mayb.dto.MemberDto; import kr.mayb.enums.AccountStatus; import kr.mayb.enums.AuthorityName; +import kr.mayb.error.ResourceNotFoundException; import kr.mayb.security.AESGCMEncoder; import lombok.RequiredArgsConstructor; import org.apache.commons.lang3.StringUtils; @@ -52,4 +54,19 @@ public Optional findByEmail(String email) { public Optional findMember(long memberId) { return memberRepository.findById(memberId); } + + @Transactional + public Member updateProfile(long memberId, String profileUrl) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found" + memberId)); + + member.setProfileUrl(profileUrl); + + return memberRepository.save(member); + } + + public MemberDto convertToMemberDto(Member member) { + String contact = aesgcmEncoder.decrypt(member.getContact()); + return MemberDto.of(member, contact); + } } diff --git a/src/main/resources/application-common.yaml b/src/main/resources/application-common.yaml index efd6b90..3647490 100644 --- a/src/main/resources/application-common.yaml +++ b/src/main/resources/application-common.yaml @@ -9,4 +9,11 @@ logging.level: server.compression.enabled: true spring.mvc.pathmatch.matching-strategy: ant-path-matcher -spring.sleuth.sampler.probability: 0.1 \ No newline at end of file +spring.sleuth.sampler.probability: 0.1 + +spring: + cloud: + gcp: + storage: + project-id: mayb-api-458206 + bucket: mayb-bucket \ No newline at end of file From 0ac98a9a86695f5b21f362c3129d0152b506dd70 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 29 Apr 2025 16:35:13 +0900 Subject: [PATCH 06/15] feat: add update member profile image API --- build.gradle | 6 ++ .../java/kr/mayb/config/CommonConfig.java | 18 ++++++ .../kr/mayb/controller/MemberController.java | 4 +- .../kr/mayb/error/ExternalApiException.java | 14 ++++ .../mayb/error/ImageConversionException.java | 11 ++++ .../java/kr/mayb/facade/MemberFacade.java | 11 ++-- src/main/java/kr/mayb/service/GcsService.java | 39 +++++------- .../java/kr/mayb/service/ImageService.java | 37 +++++++++++ .../java/kr/mayb/service/MemberService.java | 7 +- .../java/kr/mayb/util/ImgCompressUtils.java | 35 ++++++++++ .../{app => }/MaybApiApplicationTests.java | 2 +- .../kr/mayb/util/ImgCompressUtilsTest.java | 60 ++++++++++++++++++ src/test/resources/test-image.jpg | Bin 0 -> 76640 bytes 13 files changed, 209 insertions(+), 35 deletions(-) create mode 100644 src/main/java/kr/mayb/error/ExternalApiException.java create mode 100644 src/main/java/kr/mayb/error/ImageConversionException.java create mode 100644 src/main/java/kr/mayb/service/ImageService.java create mode 100644 src/main/java/kr/mayb/util/ImgCompressUtils.java rename src/test/java/kr/mayb/{app => }/MaybApiApplicationTests.java (89%) create mode 100644 src/test/java/kr/mayb/util/ImgCompressUtilsTest.java create mode 100644 src/test/resources/test-image.jpg diff --git a/build.gradle b/build.gradle index 452a587..0f3a68d 100644 --- a/build.gradle +++ b/build.gradle @@ -57,6 +57,12 @@ dependencies { // Utils implementation 'org.apache.commons:commons-lang3' + // WebP + // https://mvnrepository.com/artifact/com.sksamuel.scrimage/scrimage-core + implementation "com.sksamuel.scrimage:scrimage-core:4.3.1" + // https://mvnrepository.com/artifact/com.sksamuel.scrimage/scrimage-webp + implementation "com.sksamuel.scrimage:scrimage-webp:4.3.1" + // swagger implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.2.0' diff --git a/src/main/java/kr/mayb/config/CommonConfig.java b/src/main/java/kr/mayb/config/CommonConfig.java index a153a79..0e988d0 100644 --- a/src/main/java/kr/mayb/config/CommonConfig.java +++ b/src/main/java/kr/mayb/config/CommonConfig.java @@ -3,9 +3,12 @@ import org.springframework.boot.web.client.RestTemplateBuilder; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; import org.springframework.http.converter.FormHttpMessageConverter; import org.springframework.http.converter.StringHttpMessageConverter; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; +import org.springframework.security.task.DelegatingSecurityContextAsyncTaskExecutor; import org.springframework.web.client.RestTemplate; import java.nio.charset.StandardCharsets; @@ -24,4 +27,19 @@ public RestTemplate restTemplate(RestTemplateBuilder builder) { .readTimeout(Duration.ofSeconds(3)) .build(); } + + @Primary + @Bean(name = "mayb-taskExecutor") + public ThreadPoolTaskExecutor maybThreadPoolTaskExecutor() { + ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); + executor.setCorePoolSize(5); + executor.setMaxPoolSize(100); + executor.setQueueCapacity(20); + return executor; + } + + @Bean + public DelegatingSecurityContextAsyncTaskExecutor maybAsyncTaskExecutor(ThreadPoolTaskExecutor delegate) { + return new DelegatingSecurityContextAsyncTaskExecutor(delegate); + } } diff --git a/src/main/java/kr/mayb/controller/MemberController.java b/src/main/java/kr/mayb/controller/MemberController.java index b0d89a4..1e8f612 100644 --- a/src/main/java/kr/mayb/controller/MemberController.java +++ b/src/main/java/kr/mayb/controller/MemberController.java @@ -10,7 +10,7 @@ import kr.mayb.util.response.Responses; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -25,7 +25,7 @@ public class MemberController { @Operation(summary = "유저 프로필 이미지 업데이트") @PermitAuthenticated - @PostMapping("/members/profile") + @PutMapping("/members/profile") public ResponseEntity> updateProfile(@RequestParam("profile") MultipartFile file) { MemberDto response = memberFacade.updateProfile(file); return Responses.ok(response); diff --git a/src/main/java/kr/mayb/error/ExternalApiException.java b/src/main/java/kr/mayb/error/ExternalApiException.java new file mode 100644 index 0000000..17fae61 --- /dev/null +++ b/src/main/java/kr/mayb/error/ExternalApiException.java @@ -0,0 +1,14 @@ +package kr.mayb.error; + +import org.springframework.web.bind.annotation.ResponseStatus; + +@ResponseStatus +public class ExternalApiException extends RuntimeException { + public ExternalApiException() { + this("External API Exception"); + } + + public ExternalApiException(String message) { + super(message); + } +} diff --git a/src/main/java/kr/mayb/error/ImageConversionException.java b/src/main/java/kr/mayb/error/ImageConversionException.java new file mode 100644 index 0000000..0700436 --- /dev/null +++ b/src/main/java/kr/mayb/error/ImageConversionException.java @@ -0,0 +1,11 @@ +package kr.mayb.error; + +public class ImageConversionException extends RuntimeException { + public ImageConversionException() { + this("Fail to compress img"); + } + + public ImageConversionException(String message) { + super(message); + } +} diff --git a/src/main/java/kr/mayb/facade/MemberFacade.java b/src/main/java/kr/mayb/facade/MemberFacade.java index d44defa..47c969c 100644 --- a/src/main/java/kr/mayb/facade/MemberFacade.java +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -1,9 +1,8 @@ package kr.mayb.facade; -import kr.mayb.data.model.Member; import kr.mayb.dto.MemberDto; import kr.mayb.enums.GcsFolderPath; -import kr.mayb.service.GcsService; +import kr.mayb.service.ImageService; import kr.mayb.service.MemberService; import kr.mayb.util.ContextUtils; import lombok.RequiredArgsConstructor; @@ -14,14 +13,14 @@ @RequiredArgsConstructor public class MemberFacade { - private final GcsService gcsService; + + private final ImageService imageService; private final MemberService memberService; public MemberDto updateProfile(MultipartFile file) { MemberDto member = ContextUtils.loadMember(); - String profileUrl = gcsService.uploadFile(file, GcsFolderPath.PROFILE); + String profileUrl = imageService.upload(file, GcsFolderPath.PROFILE); - Member updated = memberService.updateProfile(member.getMemberId(), profileUrl); - return memberService.convertToMemberDto(updated); + return memberService.updateProfile(member.getMemberId(), profileUrl); } } diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index 87486e9..09465dd 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -3,46 +3,39 @@ import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; -import kr.mayb.enums.GcsFolderPath; +import kr.mayb.error.ExternalApiException; +import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; -import java.util.UUID; - @Service @RequiredArgsConstructor public class GcsService { + private static final String WEBP_EXTENSION = ".webp"; + private final Storage storage; @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - public String uploadFile(MultipartFile file, GcsFolderPath type) { - try { - String originalFilename = file.getOriginalFilename(); - String contentType = file.getContentType(); - - String blobName = generateUniqueFileName(originalFilename); - String fullBlobName = GcsFolderPath.getValue(type) + blobName; + @Async("mayb-taskExecutor") + public void upload(MultipartFile file, String fullBlobName) { + BlobId blobId = BlobId.of(bucketName, fullBlobName); + BlobInfo info = BlobInfo.newBuilder(blobId) + .setContentType(WEBP_EXTENSION) + .build(); - BlobId blobId = BlobId.of(bucketName, fullBlobName); - BlobInfo blobInfo = BlobInfo.newBuilder(blobId) - .setContentType(contentType) - .build(); - - storage.create(blobInfo, file.getBytes()); + try { + // Convert to .webp for compression + byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); - return String.format("https://storage.googleapis.com/%s/%s", bucketName, fullBlobName); + storage.create(info, converted); } catch (Exception e) { - throw new IllegalArgumentException("Failed to upload file to GCS: " + e.getMessage()); + throw new ExternalApiException("Failed to upload file to GCS: " + e.getMessage()); } } - - private String generateUniqueFileName(String originalFilename) { - String uuid = UUID.randomUUID().toString(); - return uuid + "-" + originalFilename; - } } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java new file mode 100644 index 0000000..c50b008 --- /dev/null +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -0,0 +1,37 @@ +package kr.mayb.service; + +import kr.mayb.enums.GcsFolderPath; +import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private static final String WEBP_EXTENSION = ".webp"; + + private final GcsService gcsService; + + @Value("${spring.cloud.gcp.storage.bucket}") + private String bucketName; + + public String upload(MultipartFile file, GcsFolderPath type) { + String fileName = file.getName(); + String uuidName = generateUniqueFileName(fileName + WEBP_EXTENSION); + String fullBlobName = GcsFolderPath.getValue(type) + uuidName; + + // Save in GCS bucket async + gcsService.upload(file, fullBlobName); + + return String.format("https://storage.googleapis.com/%s/%s", bucketName, fullBlobName); + } + + private String generateUniqueFileName(String originalFilename) { + String uuid = UUID.randomUUID().toString(); + return uuid + "-" + originalFilename; + } +} diff --git a/src/main/java/kr/mayb/service/MemberService.java b/src/main/java/kr/mayb/service/MemberService.java index 7f857d4..d3c567a 100644 --- a/src/main/java/kr/mayb/service/MemberService.java +++ b/src/main/java/kr/mayb/service/MemberService.java @@ -56,16 +56,17 @@ public Optional findMember(long memberId) { } @Transactional - public Member updateProfile(long memberId, String profileUrl) { + public MemberDto updateProfile(long memberId, String profileUrl) { Member member = memberRepository.findById(memberId) .orElseThrow(() -> new ResourceNotFoundException("Member not found" + memberId)); member.setProfileUrl(profileUrl); - return memberRepository.save(member); + Member saved = memberRepository.save(member); + return convertToMemberDto(saved); } - public MemberDto convertToMemberDto(Member member) { + private MemberDto convertToMemberDto(Member member) { String contact = aesgcmEncoder.decrypt(member.getContact()); return MemberDto.of(member, contact); } diff --git a/src/main/java/kr/mayb/util/ImgCompressUtils.java b/src/main/java/kr/mayb/util/ImgCompressUtils.java new file mode 100644 index 0000000..4356512 --- /dev/null +++ b/src/main/java/kr/mayb/util/ImgCompressUtils.java @@ -0,0 +1,35 @@ +package kr.mayb.util; + +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; +import kr.mayb.error.BadRequestException; +import kr.mayb.error.ImageConversionException; + +import java.io.IOException; + +public class ImgCompressUtils { + + public static byte[] convertToWebp(byte[] imageBytes) { + try { + return ImmutableImage.loader() + .fromBytes(imageBytes) + .bytes(WebpWriter.DEFAULT); + } catch (IOException e) { + throw new ImageConversionException("Fail to compress img: " + e.getMessage()); + } catch (Exception e) { + throw new BadRequestException("Fail to compress img"); + } + } + + public static byte[] convertToWebpLossless(byte[] imageBytes) { + try { + return ImmutableImage.loader() + .fromBytes(imageBytes) + .bytes(WebpWriter.DEFAULT.withLossless()); + } catch (IOException e) { + throw new ImageConversionException("Fail to compress img: " + e.getMessage()); + } catch (Exception e) { + throw new BadRequestException("Fail to compress img"); + } + } +} diff --git a/src/test/java/kr/mayb/app/MaybApiApplicationTests.java b/src/test/java/kr/mayb/MaybApiApplicationTests.java similarity index 89% rename from src/test/java/kr/mayb/app/MaybApiApplicationTests.java rename to src/test/java/kr/mayb/MaybApiApplicationTests.java index 6264e55..97d18a3 100644 --- a/src/test/java/kr/mayb/app/MaybApiApplicationTests.java +++ b/src/test/java/kr/mayb/MaybApiApplicationTests.java @@ -1,4 +1,4 @@ -package kr.mayb.app; +package kr.mayb; import org.junit.jupiter.api.Test; import org.springframework.boot.test.context.SpringBootTest; diff --git a/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java b/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java new file mode 100644 index 0000000..0ef1e42 --- /dev/null +++ b/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java @@ -0,0 +1,60 @@ +package kr.mayb.util; + +import org.junit.jupiter.api.Test; + +import java.io.File; +import java.io.IOException; +import java.net.URISyntaxException; +import java.nio.file.Files; +import java.nio.file.Path; + +import static org.junit.jupiter.api.Assertions.assertTrue; + +public class ImgCompressUtilsTest { + + @Test + void convertToWebpTest() throws IOException, URISyntaxException { + // given + Path path = Path.of(getClass().getClassLoader().getResource("test-image.jpg").toURI()); + File testFile = path.toFile(); + + assertTrue(testFile.exists(), "파일이 존재해야함."); + + byte[] bytes = Files.readAllBytes(path); + + // when + byte[] converted = ImgCompressUtils.convertToWebp(bytes); + + // then + double originalFileSizeKB = testFile.length() / 1024.0; + double convertedFileSizeKB = converted.length / 1024.0; + double compressionRatio = (convertedFileSizeKB / originalFileSizeKB) * 100; + double compressionRate = 100 - compressionRatio; + + System.out.printf("Original: %.2f KB, Converted: %.2f KB, Compression: %.2f%%%n", + originalFileSizeKB, convertedFileSizeKB, compressionRate); + } + + @Test + void convertToWebpWithLosslessTest() throws IOException, URISyntaxException { + // given + Path path = Path.of(getClass().getClassLoader().getResource("test-image.jpg").toURI()); + File testFile = path.toFile(); + + assertTrue(testFile.exists(), "파일이 존재해야함."); + + byte[] bytes = Files.readAllBytes(path); + + // when + byte[] converted = ImgCompressUtils.convertToWebpLossless(bytes); + + // then + double originalFileSizeKB = testFile.length() / 1024.0; + double convertedFileSizeKB = converted.length / 1024.0; + double compressionRatio = (convertedFileSizeKB / originalFileSizeKB) * 100; + double compressionRate = 100 - compressionRatio; + + System.out.printf("Original: %.2f KB, Converted: %.2f KB, Compression: %.2f%%%n", + originalFileSizeKB, convertedFileSizeKB, compressionRate); + } +} diff --git a/src/test/resources/test-image.jpg b/src/test/resources/test-image.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3dc9b526b471559a3625803be0d55c52056da52a GIT binary patch literal 76640 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 es6doE)Tu&OV^ Date: Tue, 29 Apr 2025 19:09:33 +0900 Subject: [PATCH 07/15] build: fix gcp sa key env --- .github/workflows/deploy-feature.yml | 2 +- .../{GcsFolderPath.java => GcsPath.java} | 6 ++--- .../java/kr/mayb/facade/MemberFacade.java | 4 ++-- .../java/kr/mayb/service/ImageService.java | 24 ++++++++++++------- src/main/resources/application-local.yaml | 1 - 5 files changed, 21 insertions(+), 16 deletions(-) rename src/main/java/kr/mayb/enums/{GcsFolderPath.java => GcsPath.java} (83%) diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index cfa8c92..1ef296d 100644 --- a/.github/workflows/deploy-feature.yml +++ b/.github/workflows/deploy-feature.yml @@ -41,7 +41,7 @@ jobs: image_tag="${branch_name}-${pr_num}" echo "SERVICE_NAME=${{ vars.FEATURE_CLOUD_RUN_PREFIX }}${image_tag}" >> $GITHUB_ENV echo "IMAGE=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV - echo "${{ secrets.GCP_MAYB_SA_KEY }}" > gcp-key.json + echo "${{ secrets.GCP_MAYB_SA_KEY }}" | base64 -d > gcp-key.json - uses: 'actions/setup-java@v3' with: diff --git a/src/main/java/kr/mayb/enums/GcsFolderPath.java b/src/main/java/kr/mayb/enums/GcsPath.java similarity index 83% rename from src/main/java/kr/mayb/enums/GcsFolderPath.java rename to src/main/java/kr/mayb/enums/GcsPath.java index 056f9b0..bd0d656 100644 --- a/src/main/java/kr/mayb/enums/GcsFolderPath.java +++ b/src/main/java/kr/mayb/enums/GcsPath.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public enum GcsFolderPath { +public enum GcsPath { PROFILE("profile/"), PRODUCT_PROFILE("product_profile/"), PRODUCT_DETAIL("product_detail/"), @@ -12,12 +12,12 @@ public enum GcsFolderPath { private final String value; - public static String getValue(GcsFolderPath type) { + public static String getValue(GcsPath type) { return switch (type) { case PROFILE -> PROFILE.value; case PRODUCT_PROFILE -> PRODUCT_PROFILE.value; case PRODUCT_DETAIL -> PRODUCT_DETAIL.value; - default -> throw new BadRequestException("Invalid GcsFolderType" + type); + default -> throw new BadRequestException("Invalid GcsPathType" + type); }; } } diff --git a/src/main/java/kr/mayb/facade/MemberFacade.java b/src/main/java/kr/mayb/facade/MemberFacade.java index 47c969c..a7c5eb5 100644 --- a/src/main/java/kr/mayb/facade/MemberFacade.java +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -1,7 +1,7 @@ package kr.mayb.facade; import kr.mayb.dto.MemberDto; -import kr.mayb.enums.GcsFolderPath; +import kr.mayb.enums.GcsPath; import kr.mayb.service.ImageService; import kr.mayb.service.MemberService; import kr.mayb.util.ContextUtils; @@ -19,7 +19,7 @@ public class MemberFacade { public MemberDto updateProfile(MultipartFile file) { MemberDto member = ContextUtils.loadMember(); - String profileUrl = imageService.upload(file, GcsFolderPath.PROFILE); + String profileUrl = imageService.upload(file, GcsPath.PROFILE); return memberService.updateProfile(member.getMemberId(), profileUrl); } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index c50b008..9e8f30d 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -1,11 +1,13 @@ package kr.mayb.service; -import kr.mayb.enums.GcsFolderPath; +import kr.mayb.enums.GcsPath; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.time.ZoneOffset; import java.util.UUID; @Service @@ -13,25 +15,29 @@ public class ImageService { private static final String WEBP_EXTENSION = ".webp"; + public static final String GCS_IMAGE_URL_PREFIX = "https://storage.googleapis.com/%s/%s"; private final GcsService gcsService; @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - public String upload(MultipartFile file, GcsFolderPath type) { - String fileName = file.getName(); - String uuidName = generateUniqueFileName(fileName + WEBP_EXTENSION); - String fullBlobName = GcsFolderPath.getValue(type) + uuidName; + public String upload(MultipartFile file, GcsPath type) { + String uuidName = generateUniqueFileName(); + String fullBlobName = GcsPath.getValue(type) + uuidName; // Save in GCS bucket async gcsService.upload(file, fullBlobName); - return String.format("https://storage.googleapis.com/%s/%s", bucketName, fullBlobName); + return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); } - private String generateUniqueFileName(String originalFilename) { - String uuid = UUID.randomUUID().toString(); - return uuid + "-" + originalFilename; + private String generateUniqueFileName() { + return new StringBuilder() + .append(UUID.randomUUID()) + .append("-") + .append(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .append(WEBP_EXTENSION) + .toString(); } } diff --git a/src/main/resources/application-local.yaml b/src/main/resources/application-local.yaml index 8a6f1fe..1590ba8 100644 --- a/src/main/resources/application-local.yaml +++ b/src/main/resources/application-local.yaml @@ -13,7 +13,6 @@ spring: database: default hibernate.ddl-auto: create-drop - hibernate.use-new-id-generator-mappings: true properties: hibernate: globally_quoted_identifiers: true From e0b2b41c32c2211e91e715efb00fa693672330cf Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 29 Apr 2025 19:39:19 +0900 Subject: [PATCH 08/15] fix: gcp key env test --- src/main/java/kr/mayb/config/GcsConfig.java | 5 ++++- src/main/java/kr/mayb/service/GcsService.java | 9 ++++----- src/main/java/kr/mayb/service/ImageService.java | 7 ++++--- 3 files changed, 12 insertions(+), 9 deletions(-) diff --git a/src/main/java/kr/mayb/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java index 2b17618..3bdc459 100644 --- a/src/main/java/kr/mayb/config/GcsConfig.java +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -10,6 +10,9 @@ public class GcsConfig { @Bean public Storage storage() { - return StorageOptions.getDefaultInstance().getService(); + + StorageOptions defaultInstance = StorageOptions.getDefaultInstance(); + System.out.printf("gcp_key: " + defaultInstance.getCredentials()); + return defaultInstance.getService(); } } diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index 09465dd..ee117f6 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -1,5 +1,6 @@ package kr.mayb.service; +import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; @@ -7,7 +8,6 @@ import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -22,8 +22,8 @@ public class GcsService { @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - @Async("mayb-taskExecutor") - public void upload(MultipartFile file, String fullBlobName) { + // @Async("mayb-taskExecutor") + public Blob upload(MultipartFile file, String fullBlobName) { BlobId blobId = BlobId.of(bucketName, fullBlobName); BlobInfo info = BlobInfo.newBuilder(blobId) .setContentType(WEBP_EXTENSION) @@ -32,8 +32,7 @@ public void upload(MultipartFile file, String fullBlobName) { try { // Convert to .webp for compression byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); - - storage.create(info, converted); + return storage.create(info, converted); } catch (Exception e) { throw new ExternalApiException("Failed to upload file to GCS: " + e.getMessage()); } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 9e8f30d..3774bd4 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -1,5 +1,6 @@ package kr.mayb.service; +import com.google.cloud.storage.Blob; import kr.mayb.enums.GcsPath; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -27,9 +28,9 @@ public String upload(MultipartFile file, GcsPath type) { String fullBlobName = GcsPath.getValue(type) + uuidName; // Save in GCS bucket async - gcsService.upload(file, fullBlobName); - - return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); + Blob uploaded = gcsService.upload(file, fullBlobName); + return uploaded.getSelfLink(); +// return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); } private String generateUniqueFileName() { From 6121812953aa762ece924b99ccb9f259da4bf25c Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 29 Apr 2025 19:54:30 +0900 Subject: [PATCH 09/15] fix: fix image upload to gcs --- src/main/java/kr/mayb/config/GcsConfig.java | 2 -- src/main/java/kr/mayb/service/GcsService.java | 8 ++++---- src/main/java/kr/mayb/service/ImageService.java | 7 +++---- 3 files changed, 7 insertions(+), 10 deletions(-) diff --git a/src/main/java/kr/mayb/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java index 3bdc459..3f82239 100644 --- a/src/main/java/kr/mayb/config/GcsConfig.java +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -10,9 +10,7 @@ public class GcsConfig { @Bean public Storage storage() { - StorageOptions defaultInstance = StorageOptions.getDefaultInstance(); - System.out.printf("gcp_key: " + defaultInstance.getCredentials()); return defaultInstance.getService(); } } diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index ee117f6..401fd01 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; @@ -8,6 +7,7 @@ import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -22,8 +22,8 @@ public class GcsService { @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - // @Async("mayb-taskExecutor") - public Blob upload(MultipartFile file, String fullBlobName) { + @Async("mayb-taskExecutor") + public void upload(MultipartFile file, String fullBlobName) { BlobId blobId = BlobId.of(bucketName, fullBlobName); BlobInfo info = BlobInfo.newBuilder(blobId) .setContentType(WEBP_EXTENSION) @@ -32,7 +32,7 @@ public Blob upload(MultipartFile file, String fullBlobName) { try { // Convert to .webp for compression byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); - return storage.create(info, converted); + storage.create(info, converted); } catch (Exception e) { throw new ExternalApiException("Failed to upload file to GCS: " + e.getMessage()); } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 3774bd4..9e8f30d 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -1,6 +1,5 @@ package kr.mayb.service; -import com.google.cloud.storage.Blob; import kr.mayb.enums.GcsPath; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; @@ -28,9 +27,9 @@ public String upload(MultipartFile file, GcsPath type) { String fullBlobName = GcsPath.getValue(type) + uuidName; // Save in GCS bucket async - Blob uploaded = gcsService.upload(file, fullBlobName); - return uploaded.getSelfLink(); -// return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); + gcsService.upload(file, fullBlobName); + + return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); } private String generateUniqueFileName() { From 571bbda8c63cc28cb7527d12d5108c75bff0ac61 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Tue, 29 Apr 2025 21:14:33 +0900 Subject: [PATCH 10/15] fix: fix image upload to gcs --- src/main/java/kr/mayb/config/GcsConfig.java | 3 +++ 1 file changed, 3 insertions(+) diff --git a/src/main/java/kr/mayb/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java index 3f82239..f9710ce 100644 --- a/src/main/java/kr/mayb/config/GcsConfig.java +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -2,15 +2,18 @@ import com.google.cloud.storage.Storage; import com.google.cloud.storage.StorageOptions; +import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +@Slf4j @Configuration public class GcsConfig { @Bean public Storage storage() { StorageOptions defaultInstance = StorageOptions.getDefaultInstance(); + log.warn("-------------GCP Credential: " + defaultInstance.getCredentials().toString()); return defaultInstance.getService(); } } From fcc0f3599fc56d19108b97d9a153a9d9e393e1e3 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 30 Apr 2025 00:18:34 +0900 Subject: [PATCH 11/15] fix: fix gcp service account --- .github/workflows/deploy-feature.yml | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index 1ef296d..4913a69 100644 --- a/.github/workflows/deploy-feature.yml +++ b/.github/workflows/deploy-feature.yml @@ -41,19 +41,17 @@ jobs: image_tag="${branch_name}-${pr_num}" echo "SERVICE_NAME=${{ vars.FEATURE_CLOUD_RUN_PREFIX }}${image_tag}" >> $GITHUB_ENV echo "IMAGE=${{ env.GAR_LOCATION }}-docker.pkg.dev/${{ env.PROJECT_ID }}/${{ env.REPOSITORY }}/${{ env.IMAGE_NAME }}:${{ github.event.pull_request.head.sha }}" >> $GITHUB_ENV - echo "${{ secrets.GCP_MAYB_SA_KEY }}" | base64 -d > gcp-key.json - uses: 'actions/setup-java@v3' with: distribution: temurin java-version: 21 + - uses: gradle/gradle-build-action@v2 - name: Make gradlew executable run: chmod +x ./gradlew - name: Execute Gradle build run: ./gradlew build - env: - GOOGLE_APPLICATION_CREDENTIALS: ${{ github.workspace }}/gcp-key.json - name: 'Docker auth' run: |- From 01371f40da8dd01408e8a8d0b384913f376283b7 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 30 Apr 2025 00:39:36 +0900 Subject: [PATCH 12/15] fix: fix gcp service account --- src/main/java/kr/mayb/service/GcsService.java | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index 401fd01..3f11f17 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -7,7 +7,6 @@ import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; -import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -22,7 +21,7 @@ public class GcsService { @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - @Async("mayb-taskExecutor") + // @Async("mayb-taskExecutor") public void upload(MultipartFile file, String fullBlobName) { BlobId blobId = BlobId.of(bucketName, fullBlobName); BlobInfo info = BlobInfo.newBuilder(blobId) From c4624b919865dc9ee6d113b06d51dff93080f0a3 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 30 Apr 2025 19:14:13 +0900 Subject: [PATCH 13/15] refactor: remove async logic to upload to gcs --- src/main/java/kr/mayb/config/GcsConfig.java | 2 -- .../kr/mayb/controller/MemberController.java | 1 - src/main/java/kr/mayb/service/GcsService.java | 22 +++++++++---------- .../java/kr/mayb/service/ImageService.java | 18 +++++++-------- .../java/kr/mayb/util/ImgCompressUtils.java | 8 +++---- src/main/resources/application-common.yaml | 7 +++++- 6 files changed, 29 insertions(+), 29 deletions(-) diff --git a/src/main/java/kr/mayb/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java index f9710ce..d9b0402 100644 --- a/src/main/java/kr/mayb/config/GcsConfig.java +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -9,11 +9,9 @@ @Slf4j @Configuration public class GcsConfig { - @Bean public Storage storage() { StorageOptions defaultInstance = StorageOptions.getDefaultInstance(); - log.warn("-------------GCP Credential: " + defaultInstance.getCredentials().toString()); return defaultInstance.getService(); } } diff --git a/src/main/java/kr/mayb/controller/MemberController.java b/src/main/java/kr/mayb/controller/MemberController.java index 1e8f612..a803434 100644 --- a/src/main/java/kr/mayb/controller/MemberController.java +++ b/src/main/java/kr/mayb/controller/MemberController.java @@ -30,5 +30,4 @@ public ResponseEntity> updateProfile(@RequestParam("profi MemberDto response = memberFacade.updateProfile(file); return Responses.ok(response); } - } diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index 3f11f17..6739fe5 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -1,39 +1,37 @@ package kr.mayb.service; +import com.google.cloud.storage.Blob; import com.google.cloud.storage.BlobId; import com.google.cloud.storage.BlobInfo; import com.google.cloud.storage.Storage; -import kr.mayb.error.ExternalApiException; -import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; -import org.springframework.web.multipart.MultipartFile; +import org.springframework.web.util.UriComponentsBuilder; @Service @RequiredArgsConstructor public class GcsService { private static final String WEBP_EXTENSION = ".webp"; + private static final String GCS_HOST_URL = "https://storage.googleapis.com"; private final Storage storage; @Value("${spring.cloud.gcp.storage.bucket}") private String bucketName; - // @Async("mayb-taskExecutor") - public void upload(MultipartFile file, String fullBlobName) { + public String upload(byte[] file, String fullBlobName) { BlobId blobId = BlobId.of(bucketName, fullBlobName); BlobInfo info = BlobInfo.newBuilder(blobId) .setContentType(WEBP_EXTENSION) .build(); - try { - // Convert to .webp for compression - byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); - storage.create(info, converted); - } catch (Exception e) { - throw new ExternalApiException("Failed to upload file to GCS: " + e.getMessage()); - } + Blob uploaded = storage.create(info, file); + + return UriComponentsBuilder + .fromUriString(GCS_HOST_URL) + .pathSegment(uploaded.getBucket(), uploaded.getName()) + .toUriString(); } } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 9e8f30d..3173205 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -1,8 +1,9 @@ package kr.mayb.service; import kr.mayb.enums.GcsPath; +import kr.mayb.error.BadRequestException; +import kr.mayb.util.ImgCompressUtils; import lombok.RequiredArgsConstructor; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -15,21 +16,20 @@ public class ImageService { private static final String WEBP_EXTENSION = ".webp"; - public static final String GCS_IMAGE_URL_PREFIX = "https://storage.googleapis.com/%s/%s"; private final GcsService gcsService; - @Value("${spring.cloud.gcp.storage.bucket}") - private String bucketName; - public String upload(MultipartFile file, GcsPath type) { String uuidName = generateUniqueFileName(); String fullBlobName = GcsPath.getValue(type) + uuidName; - // Save in GCS bucket async - gcsService.upload(file, fullBlobName); - - return String.format(GCS_IMAGE_URL_PREFIX, bucketName, fullBlobName); + try { + // Convert to .webp for compression + byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); + return gcsService.upload(converted, fullBlobName); + } catch (Exception e) { + throw new BadRequestException("Failed to upload image: " + file.getOriginalFilename()); + } } private String generateUniqueFileName() { diff --git a/src/main/java/kr/mayb/util/ImgCompressUtils.java b/src/main/java/kr/mayb/util/ImgCompressUtils.java index 4356512..61dc6cc 100644 --- a/src/main/java/kr/mayb/util/ImgCompressUtils.java +++ b/src/main/java/kr/mayb/util/ImgCompressUtils.java @@ -15,9 +15,9 @@ public static byte[] convertToWebp(byte[] imageBytes) { .fromBytes(imageBytes) .bytes(WebpWriter.DEFAULT); } catch (IOException e) { - throw new ImageConversionException("Fail to compress img: " + e.getMessage()); + throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); } catch (Exception e) { - throw new BadRequestException("Fail to compress img"); + throw new BadRequestException("Fail to convert to webp"); } } @@ -27,9 +27,9 @@ public static byte[] convertToWebpLossless(byte[] imageBytes) { .fromBytes(imageBytes) .bytes(WebpWriter.DEFAULT.withLossless()); } catch (IOException e) { - throw new ImageConversionException("Fail to compress img: " + e.getMessage()); + throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); } catch (Exception e) { - throw new BadRequestException("Fail to compress img"); + throw new BadRequestException("Fail to convert to webp"); } } } diff --git a/src/main/resources/application-common.yaml b/src/main/resources/application-common.yaml index 3647490..fad7aae 100644 --- a/src/main/resources/application-common.yaml +++ b/src/main/resources/application-common.yaml @@ -16,4 +16,9 @@ spring: gcp: storage: project-id: mayb-api-458206 - bucket: mayb-bucket \ No newline at end of file + bucket: mayb-bucket + + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB From 70b1465747b997e89021a7fd59015908c13ba2d9 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 30 Apr 2025 19:56:44 +0900 Subject: [PATCH 14/15] feat: add memberUpdate API --- .../kr/mayb/controller/MemberController.java | 11 ++++++ .../java/kr/mayb/dto/MemberUpdateRequest.java | 13 +++++++ .../java/kr/mayb/facade/MemberFacade.java | 6 +++ .../java/kr/mayb/service/MemberService.java | 39 +++++++++++++------ 4 files changed, 58 insertions(+), 11 deletions(-) create mode 100644 src/main/java/kr/mayb/dto/MemberUpdateRequest.java diff --git a/src/main/java/kr/mayb/controller/MemberController.java b/src/main/java/kr/mayb/controller/MemberController.java index a803434..8bdd0f4 100644 --- a/src/main/java/kr/mayb/controller/MemberController.java +++ b/src/main/java/kr/mayb/controller/MemberController.java @@ -2,7 +2,9 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; import kr.mayb.dto.MemberDto; +import kr.mayb.dto.MemberUpdateRequest; import kr.mayb.facade.MemberFacade; import kr.mayb.security.DenyAll; import kr.mayb.security.PermitAuthenticated; @@ -11,6 +13,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; @@ -30,4 +33,12 @@ public ResponseEntity> updateProfile(@RequestParam("profi MemberDto response = memberFacade.updateProfile(file); return Responses.ok(response); } + + @Operation(summary = "유저 정보 업데이트") + @PermitAuthenticated + @PutMapping("/members/me") + public ResponseEntity> updateMember(@RequestBody @Valid MemberUpdateRequest request) { + MemberDto response = memberFacade.updateMember(request); + return Responses.ok(response); + } } diff --git a/src/main/java/kr/mayb/dto/MemberUpdateRequest.java b/src/main/java/kr/mayb/dto/MemberUpdateRequest.java new file mode 100644 index 0000000..1440692 --- /dev/null +++ b/src/main/java/kr/mayb/dto/MemberUpdateRequest.java @@ -0,0 +1,13 @@ +package kr.mayb.dto; + +import jakarta.validation.constraints.NotBlank; + +public record MemberUpdateRequest( + @NotBlank + String name, + + String introduction, + + String idealType +) { +} diff --git a/src/main/java/kr/mayb/facade/MemberFacade.java b/src/main/java/kr/mayb/facade/MemberFacade.java index a7c5eb5..3b55713 100644 --- a/src/main/java/kr/mayb/facade/MemberFacade.java +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -1,6 +1,7 @@ package kr.mayb.facade; import kr.mayb.dto.MemberDto; +import kr.mayb.dto.MemberUpdateRequest; import kr.mayb.enums.GcsPath; import kr.mayb.service.ImageService; import kr.mayb.service.MemberService; @@ -23,4 +24,9 @@ public MemberDto updateProfile(MultipartFile file) { return memberService.updateProfile(member.getMemberId(), profileUrl); } + + public MemberDto updateMember(MemberUpdateRequest request) { + MemberDto member = ContextUtils.loadMember(); + return memberService.updateMember(member.getMemberId(), request); + } } diff --git a/src/main/java/kr/mayb/service/MemberService.java b/src/main/java/kr/mayb/service/MemberService.java index d3c567a..f1b58b0 100644 --- a/src/main/java/kr/mayb/service/MemberService.java +++ b/src/main/java/kr/mayb/service/MemberService.java @@ -6,6 +6,7 @@ import kr.mayb.data.repository.AuthorityRepository; import kr.mayb.data.repository.MemberRepository; import kr.mayb.dto.MemberDto; +import kr.mayb.dto.MemberUpdateRequest; import kr.mayb.enums.AccountStatus; import kr.mayb.enums.AuthorityName; import kr.mayb.error.ResourceNotFoundException; @@ -30,6 +31,19 @@ public boolean existsByEmail(String email) { return memberRepository.existsByEmail(email); } + public Optional findByEmail(String email) { + return memberRepository.findByEmail(email); + } + + public Optional findMember(long memberId) { + return memberRepository.findById(memberId); + } + + public Member getMember(long memberId) { + return memberRepository.findById(memberId) + .orElseThrow(() -> new ResourceNotFoundException("Member not found: " + memberId)); + } + @Transactional public Member saveMember(Member member) { if (StringUtils.isNotBlank(member.getPassword())) { @@ -47,23 +61,26 @@ public Member saveMember(Member member) { return memberRepository.save(member); } - public Optional findByEmail(String email) { - return memberRepository.findByEmail(email); - } + @Transactional + public MemberDto updateProfile(long memberId, String profileUrl) { + Member member = getMember(memberId); - public Optional findMember(long memberId) { - return memberRepository.findById(memberId); + member.setProfileUrl(profileUrl); + + Member updated = memberRepository.save(member); + return convertToMemberDto(updated); } @Transactional - public MemberDto updateProfile(long memberId, String profileUrl) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new ResourceNotFoundException("Member not found" + memberId)); + public MemberDto updateMember(long memberId, MemberUpdateRequest request) { + Member member = getMember(memberId); - member.setProfileUrl(profileUrl); + member.setName(request.name()); + member.setIntroduction(request.introduction()); + member.setIdealType(request.idealType()); - Member saved = memberRepository.save(member); - return convertToMemberDto(saved); + Member updated = memberRepository.save(member); + return convertToMemberDto(updated); } private MemberDto convertToMemberDto(Member member) { From 33029f01a2bde2b8bd2b83a81bb46d90b23fd2a0 Mon Sep 17 00:00:00 2001 From: 0AndWild Date: Wed, 30 Apr 2025 21:27:32 +0900 Subject: [PATCH 15/15] feat: add delete old profile image and validate imageType logic --- .../{GcsPath.java => GcsBucketPath.java} | 8 +-- .../java/kr/mayb/facade/MemberFacade.java | 10 ++- src/main/java/kr/mayb/service/GcsService.java | 17 +++-- .../java/kr/mayb/service/ImageService.java | 20 ++++-- src/main/java/kr/mayb/util/ImageUtils.java | 70 +++++++++++++++++++ .../java/kr/mayb/util/ImgCompressUtils.java | 35 ---------- .../kr/mayb/util/ImgCompressUtilsTest.java | 4 +- 7 files changed, 112 insertions(+), 52 deletions(-) rename src/main/java/kr/mayb/enums/{GcsPath.java => GcsBucketPath.java} (78%) create mode 100644 src/main/java/kr/mayb/util/ImageUtils.java delete mode 100644 src/main/java/kr/mayb/util/ImgCompressUtils.java diff --git a/src/main/java/kr/mayb/enums/GcsPath.java b/src/main/java/kr/mayb/enums/GcsBucketPath.java similarity index 78% rename from src/main/java/kr/mayb/enums/GcsPath.java rename to src/main/java/kr/mayb/enums/GcsBucketPath.java index bd0d656..10993d9 100644 --- a/src/main/java/kr/mayb/enums/GcsPath.java +++ b/src/main/java/kr/mayb/enums/GcsBucketPath.java @@ -4,7 +4,7 @@ import lombok.RequiredArgsConstructor; @RequiredArgsConstructor -public enum GcsPath { +public enum GcsBucketPath { PROFILE("profile/"), PRODUCT_PROFILE("product_profile/"), PRODUCT_DETAIL("product_detail/"), @@ -12,12 +12,12 @@ public enum GcsPath { private final String value; - public static String getValue(GcsPath type) { - return switch (type) { + public static String getPath(GcsBucketPath pathType) { + return switch (pathType) { case PROFILE -> PROFILE.value; case PRODUCT_PROFILE -> PRODUCT_PROFILE.value; case PRODUCT_DETAIL -> PRODUCT_DETAIL.value; - default -> throw new BadRequestException("Invalid GcsPathType" + type); + default -> throw new BadRequestException("Invalid GcsPathType" + pathType); }; } } diff --git a/src/main/java/kr/mayb/facade/MemberFacade.java b/src/main/java/kr/mayb/facade/MemberFacade.java index 3b55713..f0805af 100644 --- a/src/main/java/kr/mayb/facade/MemberFacade.java +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -1,8 +1,9 @@ package kr.mayb.facade; +import io.micrometer.common.util.StringUtils; import kr.mayb.dto.MemberDto; import kr.mayb.dto.MemberUpdateRequest; -import kr.mayb.enums.GcsPath; +import kr.mayb.enums.GcsBucketPath; import kr.mayb.service.ImageService; import kr.mayb.service.MemberService; import kr.mayb.util.ContextUtils; @@ -20,7 +21,12 @@ public class MemberFacade { public MemberDto updateProfile(MultipartFile file) { MemberDto member = ContextUtils.loadMember(); - String profileUrl = imageService.upload(file, GcsPath.PROFILE); + String profileUrl = imageService.upload(file, GcsBucketPath.PROFILE); + + if (StringUtils.isNotBlank(member.getProfileUrl())) { + // Delete the old profile image if it exists + imageService.delete(member.getProfileUrl(), GcsBucketPath.PROFILE); + } return memberService.updateProfile(member.getMemberId(), profileUrl); } diff --git a/src/main/java/kr/mayb/service/GcsService.java b/src/main/java/kr/mayb/service/GcsService.java index 6739fe5..ef9dd8a 100644 --- a/src/main/java/kr/mayb/service/GcsService.java +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -14,7 +14,7 @@ public class GcsService { private static final String WEBP_EXTENSION = ".webp"; - private static final String GCS_HOST_URL = "https://storage.googleapis.com"; + private static final String GCS_HOST_URL = "https://storage.googleapis.com/"; private final Storage storage; @@ -29,9 +29,18 @@ public String upload(byte[] file, String fullBlobName) { Blob uploaded = storage.create(info, file); - return UriComponentsBuilder - .fromUriString(GCS_HOST_URL) - .pathSegment(uploaded.getBucket(), uploaded.getName()) + return UriComponentsBuilder.fromUriString(GCS_HOST_URL) + .pathSegment(uploaded.getBucket()) + .path(uploaded.getName()) .toUriString(); } + + public void delete(String fullBlobName) { + BlobId blobId = BlobId.of(bucketName, fullBlobName); + Blob blob = storage.get(blobId); + + if (blob != null) { + blob.delete(); + } + } } diff --git a/src/main/java/kr/mayb/service/ImageService.java b/src/main/java/kr/mayb/service/ImageService.java index 3173205..5f6936b 100644 --- a/src/main/java/kr/mayb/service/ImageService.java +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -1,8 +1,8 @@ package kr.mayb.service; -import kr.mayb.enums.GcsPath; +import kr.mayb.enums.GcsBucketPath; import kr.mayb.error.BadRequestException; -import kr.mayb.util.ImgCompressUtils; +import kr.mayb.util.ImageUtils; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; @@ -19,13 +19,16 @@ public class ImageService { private final GcsService gcsService; - public String upload(MultipartFile file, GcsPath type) { + public String upload(MultipartFile file, GcsBucketPath pathType) { + ImageUtils.validateImageFile(file); + String uuidName = generateUniqueFileName(); - String fullBlobName = GcsPath.getValue(type) + uuidName; + String fullBlobName = GcsBucketPath.getPath(pathType) + uuidName; try { // Convert to .webp for compression - byte[] converted = ImgCompressUtils.convertToWebp(file.getBytes()); + byte[] converted = ImageUtils.convertToWebp(file.getBytes()); + return gcsService.upload(converted, fullBlobName); } catch (Exception e) { throw new BadRequestException("Failed to upload image: " + file.getOriginalFilename()); @@ -40,4 +43,11 @@ private String generateUniqueFileName() { .append(WEBP_EXTENSION) .toString(); } + + public void delete(String profileUrl, GcsBucketPath pathType) { + String uuidName = profileUrl.substring(profileUrl.lastIndexOf("/") + 1); + String fullBlobName = GcsBucketPath.getPath(pathType) + uuidName; + + gcsService.delete(fullBlobName); + } } diff --git a/src/main/java/kr/mayb/util/ImageUtils.java b/src/main/java/kr/mayb/util/ImageUtils.java new file mode 100644 index 0000000..e04fa37 --- /dev/null +++ b/src/main/java/kr/mayb/util/ImageUtils.java @@ -0,0 +1,70 @@ +package kr.mayb.util; + +import com.sksamuel.scrimage.ImmutableImage; +import com.sksamuel.scrimage.webp.WebpWriter; +import kr.mayb.error.BadRequestException; +import kr.mayb.error.ImageConversionException; +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import org.springframework.web.multipart.MultipartFile; + +import java.io.IOException; +import java.util.Arrays; +import java.util.Optional; + +public class ImageUtils { + + public static byte[] convertToWebp(byte[] imageBytes) { + try { + return ImmutableImage.loader() + .fromBytes(imageBytes) + .bytes(WebpWriter.DEFAULT); + } catch (IOException e) { + throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); + } catch (Exception e) { + throw new BadRequestException("Fail to convert to webp"); + } + } + + public static byte[] convertToWebpLossless(byte[] imageBytes) { + try { + return ImmutableImage.loader() + .fromBytes(imageBytes) + .bytes(WebpWriter.DEFAULT.withLossless()); + } catch (IOException e) { + throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); + } catch (Exception e) { + throw new BadRequestException("Fail to convert to webp"); + } + } + + public static void validateImageFile(MultipartFile file) { + if (file.isEmpty()) { + throw new BadRequestException("File is empty"); + } + + String contentType = Optional.ofNullable(file.getContentType()) + .orElseThrow(() -> new BadRequestException("Content type is null")); + + ImageType.validateContentType(contentType); + } + + @Getter + @RequiredArgsConstructor + private enum ImageType { + JPEG("image/jpeg"), + PNG("image/png"), + WEBP("image/webp"); + + private final String contentType; + + public static void validateContentType(String contentType) { + boolean isValid = Arrays.stream(values()) + .anyMatch(type -> type.getContentType().equalsIgnoreCase(contentType)); + + if (!isValid) { + throw new BadRequestException("Not supported imageType(only jpeg, png, webp can be uploaded): " + contentType); + } + } + } +} diff --git a/src/main/java/kr/mayb/util/ImgCompressUtils.java b/src/main/java/kr/mayb/util/ImgCompressUtils.java deleted file mode 100644 index 61dc6cc..0000000 --- a/src/main/java/kr/mayb/util/ImgCompressUtils.java +++ /dev/null @@ -1,35 +0,0 @@ -package kr.mayb.util; - -import com.sksamuel.scrimage.ImmutableImage; -import com.sksamuel.scrimage.webp.WebpWriter; -import kr.mayb.error.BadRequestException; -import kr.mayb.error.ImageConversionException; - -import java.io.IOException; - -public class ImgCompressUtils { - - public static byte[] convertToWebp(byte[] imageBytes) { - try { - return ImmutableImage.loader() - .fromBytes(imageBytes) - .bytes(WebpWriter.DEFAULT); - } catch (IOException e) { - throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); - } catch (Exception e) { - throw new BadRequestException("Fail to convert to webp"); - } - } - - public static byte[] convertToWebpLossless(byte[] imageBytes) { - try { - return ImmutableImage.loader() - .fromBytes(imageBytes) - .bytes(WebpWriter.DEFAULT.withLossless()); - } catch (IOException e) { - throw new ImageConversionException("Fail to convert to webp: " + e.getMessage()); - } catch (Exception e) { - throw new BadRequestException("Fail to convert to webp"); - } - } -} diff --git a/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java b/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java index 0ef1e42..8031f11 100644 --- a/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java +++ b/src/test/java/kr/mayb/util/ImgCompressUtilsTest.java @@ -23,7 +23,7 @@ void convertToWebpTest() throws IOException, URISyntaxException { byte[] bytes = Files.readAllBytes(path); // when - byte[] converted = ImgCompressUtils.convertToWebp(bytes); + byte[] converted = ImageUtils.convertToWebp(bytes); // then double originalFileSizeKB = testFile.length() / 1024.0; @@ -46,7 +46,7 @@ void convertToWebpWithLosslessTest() throws IOException, URISyntaxException { byte[] bytes = Files.readAllBytes(path); // when - byte[] converted = ImgCompressUtils.convertToWebpLossless(bytes); + byte[] converted = ImageUtils.convertToWebpLossless(bytes); // then double originalFileSizeKB = testFile.length() / 1024.0;