diff --git a/.github/workflows/deploy-feature.yml b/.github/workflows/deploy-feature.yml index 7f1dd3a..4913a69 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,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') @@ -45,6 +46,7 @@ jobs: with: distribution: temurin java-version: 21 + - uses: gradle/gradle-build-action@v2 - name: Make gradlew executable run: chmod +x ./gradlew diff --git a/.github/workflows/remove-feature.yml b/.github/workflows/remove-feature.yml index 0cc1878..1847ae7 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_DEPLOY_SA_KEY }} - uses: 'google-github-actions/setup-gcloud@v1' with: diff --git a/build.gradle b/build.gradle index 4a75221..0f3a68d 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' @@ -52,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/config/GcsConfig.java b/src/main/java/kr/mayb/config/GcsConfig.java new file mode 100644 index 0000000..d9b0402 --- /dev/null +++ b/src/main/java/kr/mayb/config/GcsConfig.java @@ -0,0 +1,17 @@ +package kr.mayb.config; + +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(); + return defaultInstance.getService(); + } +} 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/controller/MemberController.java b/src/main/java/kr/mayb/controller/MemberController.java new file mode 100644 index 0000000..8bdd0f4 --- /dev/null +++ b/src/main/java/kr/mayb/controller/MemberController.java @@ -0,0 +1,44 @@ +package kr.mayb.controller; + +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; +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.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; + +@Tag(name = "Member", description = "유저 관련 API") +@DenyAll +@RestController +@RequiredArgsConstructor +public class MemberController { + + private final MemberFacade memberFacade; + + @Operation(summary = "유저 프로필 이미지 업데이트") + @PermitAuthenticated + @PutMapping("/members/profile") + public ResponseEntity> updateProfile(@RequestParam("profile") MultipartFile file) { + 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/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/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/enums/GcsBucketPath.java b/src/main/java/kr/mayb/enums/GcsBucketPath.java new file mode 100644 index 0000000..10993d9 --- /dev/null +++ b/src/main/java/kr/mayb/enums/GcsBucketPath.java @@ -0,0 +1,23 @@ +package kr.mayb.enums; + +import kr.mayb.error.BadRequestException; +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum GcsBucketPath { + PROFILE("profile/"), + PRODUCT_PROFILE("product_profile/"), + PRODUCT_DETAIL("product_detail/"), + ; + + private final String value; + + 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" + pathType); + }; + } +} 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 new file mode 100644 index 0000000..f0805af --- /dev/null +++ b/src/main/java/kr/mayb/facade/MemberFacade.java @@ -0,0 +1,38 @@ +package kr.mayb.facade; + +import io.micrometer.common.util.StringUtils; +import kr.mayb.dto.MemberDto; +import kr.mayb.dto.MemberUpdateRequest; +import kr.mayb.enums.GcsBucketPath; +import kr.mayb.service.ImageService; +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 ImageService imageService; + private final MemberService memberService; + + public MemberDto updateProfile(MultipartFile file) { + MemberDto member = ContextUtils.loadMember(); + 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); + } + + public MemberDto updateMember(MemberUpdateRequest request) { + MemberDto member = ContextUtils.loadMember(); + return memberService.updateMember(member.getMemberId(), request); + } +} 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); + } } 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..ef9dd8a --- /dev/null +++ b/src/main/java/kr/mayb/service/GcsService.java @@ -0,0 +1,46 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +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; + + public String upload(byte[] file, String fullBlobName) { + BlobId blobId = BlobId.of(bucketName, fullBlobName); + BlobInfo info = BlobInfo.newBuilder(blobId) + .setContentType(WEBP_EXTENSION) + .build(); + + Blob uploaded = storage.create(info, file); + + 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 new file mode 100644 index 0000000..5f6936b --- /dev/null +++ b/src/main/java/kr/mayb/service/ImageService.java @@ -0,0 +1,53 @@ +package kr.mayb.service; + +import kr.mayb.enums.GcsBucketPath; +import kr.mayb.error.BadRequestException; +import kr.mayb.util.ImageUtils; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; + +import java.time.LocalDateTime; +import java.time.ZoneOffset; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class ImageService { + + private static final String WEBP_EXTENSION = ".webp"; + + private final GcsService gcsService; + + public String upload(MultipartFile file, GcsBucketPath pathType) { + ImageUtils.validateImageFile(file); + + String uuidName = generateUniqueFileName(); + String fullBlobName = GcsBucketPath.getPath(pathType) + uuidName; + + try { + // Convert to .webp for compression + byte[] converted = ImageUtils.convertToWebp(file.getBytes()); + + return gcsService.upload(converted, fullBlobName); + } catch (Exception e) { + throw new BadRequestException("Failed to upload image: " + file.getOriginalFilename()); + } + } + + private String generateUniqueFileName() { + return new StringBuilder() + .append(UUID.randomUUID()) + .append("-") + .append(LocalDateTime.now().toEpochSecond(ZoneOffset.UTC)) + .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/service/MemberService.java b/src/main/java/kr/mayb/service/MemberService.java index 75f51cf..f1b58b0 100644 --- a/src/main/java/kr/mayb/service/MemberService.java +++ b/src/main/java/kr/mayb/service/MemberService.java @@ -5,8 +5,11 @@ 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.dto.MemberUpdateRequest; 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; @@ -28,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())) { @@ -45,11 +61,30 @@ 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); + + member.setProfileUrl(profileUrl); + + Member updated = memberRepository.save(member); + return convertToMemberDto(updated); } - public Optional findMember(long memberId) { - return memberRepository.findById(memberId); + @Transactional + public MemberDto updateMember(long memberId, MemberUpdateRequest request) { + Member member = getMember(memberId); + + member.setName(request.name()); + member.setIntroduction(request.introduction()); + member.setIdealType(request.idealType()); + + Member updated = memberRepository.save(member); + return convertToMemberDto(updated); + } + + 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/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/resources/application-common.yaml b/src/main/resources/application-common.yaml index efd6b90..fad7aae 100644 --- a/src/main/resources/application-common.yaml +++ b/src/main/resources/application-common.yaml @@ -9,4 +9,16 @@ 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 + + servlet: + multipart: + max-file-size: 50MB + max-request-size: 50MB 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 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..8031f11 --- /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 = ImageUtils.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 = ImageUtils.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 0000000..3dc9b52 Binary files /dev/null and b/src/test/resources/test-image.jpg differ