From d4bfd39da2166fddd1a2dbb996492560bfc2d1ce Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sat, 6 Dec 2025 21:39:43 +0900 Subject: [PATCH 01/17] chore(deploy): refine production deployment script to selectively restart spring service --- .github/workflows/deploy-to-prod-server.yml | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/.github/workflows/deploy-to-prod-server.yml b/.github/workflows/deploy-to-prod-server.yml index 78c0738..25c81bd 100644 --- a/.github/workflows/deploy-to-prod-server.yml +++ b/.github/workflows/deploy-to-prod-server.yml @@ -66,9 +66,10 @@ jobs: script: | cd ${{ secrets.PROD_SERVER_DEPLOY_PATH }} - # 최신 이미지 pull - docker compose -f docker-compose.prod.yml pull + # 최신 이미지 pull (spring만) + docker compose -f docker-compose.prod.yml pull redot-server - # 컨테이너 재시작 - docker compose -f docker-compose.prod.yml down - docker compose -f docker-compose.prod.yml --env-file .env up -d + # spring만 재시작 + docker compose -f docker-compose.prod.yml stop redot-server + docker compose -f docker-compose.prod.yml rm -f redot-server + docker compose -f docker-compose.prod.yml --env-file .env up -d redot-server From 07edfb416ddc933eb37cd2d9ebc1f36e420efa3e Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 12:38:52 +0900 Subject: [PATCH 02/17] refactor(image-upload): implement image upload service and related utilities --- .../site/setting/util/LogoPathGenerator.java | 33 ----------------- .../global/s3/exception/ImageErrorCode.java | 18 ++++++++++ .../s3/exception/ImageUploadException.java | 9 +++++ .../global/s3/service/ImageUploadService.java | 26 ++++++++++++++ .../global/s3/util/ImageDirectory.java | 20 +++++++++++ .../global/s3/util/ImagePathGenerator.java | 35 +++++++++++++++++++ 6 files changed, 108 insertions(+), 33 deletions(-) delete mode 100644 src/main/java/redot/redot_server/domain/site/setting/util/LogoPathGenerator.java create mode 100644 src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java create mode 100644 src/main/java/redot/redot_server/global/s3/exception/ImageUploadException.java create mode 100644 src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java create mode 100644 src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java create mode 100644 src/main/java/redot/redot_server/global/s3/util/ImagePathGenerator.java diff --git a/src/main/java/redot/redot_server/domain/site/setting/util/LogoPathGenerator.java b/src/main/java/redot/redot_server/domain/site/setting/util/LogoPathGenerator.java deleted file mode 100644 index 7f27584..0000000 --- a/src/main/java/redot/redot_server/domain/site/setting/util/LogoPathGenerator.java +++ /dev/null @@ -1,33 +0,0 @@ -package redot.redot_server.domain.site.setting.util; - -import java.util.UUID; -import redot.redot_server.domain.site.setting.exception.SiteSettingErrorCode; -import redot.redot_server.domain.site.setting.exception.SiteSettingException; - -public class LogoPathGenerator { - - public static String generateLogoPath(Long redotAppId, String originalFilename) { - String uuid = UUID.randomUUID().toString(); - String extension = extractExtension(originalFilename); - - return String.format("app/%d/logo/%s%s", redotAppId, uuid, extension); - } - - private static String extractExtension(String fileName) { - if (fileName == null || fileName.isBlank()) { - throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_NAME_REQUIRED); - } - - int lastDot = fileName.lastIndexOf('.'); - if (lastDot == -1 || lastDot == fileName.length() - 1) { - throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_EXTENSION_REQUIRED); - } - - String ext = fileName.substring(lastDot); - - if (!ext.matches("^\\.[a-zA-Z0-9]+$")) { - throw new SiteSettingException(SiteSettingErrorCode.INVALID_FILE_EXTENSION_FORMAT); - } - return ext; - } -} diff --git a/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java new file mode 100644 index 0000000..cf7fa9e --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java @@ -0,0 +1,18 @@ +package redot.redot_server.global.s3.exception; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; +import redot.redot_server.global.exception.ErrorCode; + +@Getter +@RequiredArgsConstructor +public enum ImageErrorCode implements ErrorCode { + IMAGE_FILE_REQUIRED(400, 5200, "업로드할 이미지를 선택해주세요."), + INVALID_IMAGE_FILE_NAME(400, 5201, "이미지 파일명이 올바르지 않습니다."), + INVALID_IMAGE_EXTENSION(400, 5202, "지원하지 않는 이미지 확장자입니다."), + INVALID_IMAGE_OWNER(400, 5203, "이미지를 저장할 대상을 찾을 수 없습니다."); + + private final int statusCode; + private final int exceptionCode; + private final String message; +} diff --git a/src/main/java/redot/redot_server/global/s3/exception/ImageUploadException.java b/src/main/java/redot/redot_server/global/s3/exception/ImageUploadException.java new file mode 100644 index 0000000..a804790 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/exception/ImageUploadException.java @@ -0,0 +1,9 @@ +package redot.redot_server.global.s3.exception; + +import redot.redot_server.global.exception.BaseException; + +public class ImageUploadException extends BaseException { + public ImageUploadException(ImageErrorCode errorCode) { + super(errorCode); + } +} diff --git a/src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java b/src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java new file mode 100644 index 0000000..876313e --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java @@ -0,0 +1,26 @@ +package redot.redot_server.global.s3.service; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.web.multipart.MultipartFile; +import redot.redot_server.global.s3.exception.ImageErrorCode; +import redot.redot_server.global.s3.exception.ImageUploadException; +import redot.redot_server.global.s3.util.ImageDirectory; +import redot.redot_server.global.s3.util.ImagePathGenerator; +import redot.redot_server.global.s3.util.S3Manager; + +@Service +@RequiredArgsConstructor +public class ImageUploadService { + + private final S3Manager s3Manager; + + public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageUploadException(ImageErrorCode.IMAGE_FILE_REQUIRED); + } + + String path = ImagePathGenerator.generate(directory, ownerId, file.getOriginalFilename()); + return s3Manager.uploadFile(file, path); + } +} diff --git a/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java b/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java new file mode 100644 index 0000000..b9de7e1 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java @@ -0,0 +1,20 @@ +package redot.redot_server.global.s3.util; + +import java.util.function.Function; + +public enum ImageDirectory { + ADMIN_PROFILE(id -> String.format("admin/profile/%d", id)), + CMS_MEMBER_PROFILE(id -> String.format("cms/member/profile/%d", id)), + REDOT_MEMBER_PROFILE(id -> String.format("redot/member/profile/%d", id)), + APP_LOGO(id -> String.format("app/%d/logo", id)); + + private final Function pathResolver; + + ImageDirectory(Function pathResolver) { + this.pathResolver = pathResolver; + } + + public String resolve(Long ownerId) { + return pathResolver.apply(ownerId); + } +} diff --git a/src/main/java/redot/redot_server/global/s3/util/ImagePathGenerator.java b/src/main/java/redot/redot_server/global/s3/util/ImagePathGenerator.java new file mode 100644 index 0000000..9b4e940 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/util/ImagePathGenerator.java @@ -0,0 +1,35 @@ +package redot.redot_server.global.s3.util; + +import java.util.UUID; +import org.springframework.util.StringUtils; +import redot.redot_server.global.s3.exception.ImageErrorCode; +import redot.redot_server.global.s3.exception.ImageUploadException; + +public final class ImagePathGenerator { + + private ImagePathGenerator() { + } + + public static String generate(ImageDirectory directory, Long ownerId, String originalFilename) { + if (ownerId == null) { + throw new ImageUploadException(ImageErrorCode.INVALID_IMAGE_OWNER); + } + + String extension = extractExtension(originalFilename); + String basePath = directory.resolve(ownerId); + return String.format("%s/%s%s", basePath, UUID.randomUUID(), extension); + } + + private static String extractExtension(String originalFilename) { + if (!StringUtils.hasText(originalFilename)) { + throw new ImageUploadException(ImageErrorCode.INVALID_IMAGE_FILE_NAME); + } + + int lastDot = originalFilename.lastIndexOf('.'); + if (lastDot == -1 || lastDot == originalFilename.length() - 1) { + throw new ImageUploadException(ImageErrorCode.INVALID_IMAGE_EXTENSION); + } + + return originalFilename.substring(lastDot); + } +} From 512f2f8e23601f78da605c19fc24689681ba8c54 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 12:39:24 +0900 Subject: [PATCH 03/17] feat(profile-image): add profile image upload functionality for admin, redot member and cms member --- .../admin/controller/AdminController.java | 12 ++++++++ .../domain/admin/service/AdminService.java | 18 +++++++++-- .../controller/CMSMemberController.java | 19 +++++++++++- .../cms/member/service/CMSMemberService.java | 20 +++++++++++-- .../controller/RedotMemberController.java | 30 +++++++++++++++++++ .../member/service/RedotMemberService.java | 16 ++++++++++ 6 files changed, 109 insertions(+), 6 deletions(-) create mode 100644 src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java index 9baeb41..958ba23 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.http.HttpHeaders; @@ -15,12 +16,15 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import redot.redot_server.domain.admin.dto.request.AdminCreateRequest; import redot.redot_server.domain.admin.dto.request.AdminResetPasswordRequest; import redot.redot_server.domain.admin.dto.response.AdminResponse; import redot.redot_server.domain.admin.dto.request.AdminUpdateRequest; import redot.redot_server.domain.admin.service.AdminService; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; import redot.redot_server.global.util.dto.response.PageResponse; import redot.redot_server.global.jwt.cookie.TokenCookieFactory; import redot.redot_server.global.jwt.token.TokenType; @@ -78,4 +82,12 @@ public ResponseEntity deleteCurrentAdmin(HttpServletRequest request, @Auth .header(HttpHeaders.SET_COOKIE, deleteRefresh.toString()) .build(); } + + @PostMapping("/{adminId}/profile-image") + public ResponseEntity uploadProfileImage( + @PathVariable Long adminId, + @RequestPart("image") @NotNull MultipartFile image + ) { + return ResponseEntity.ok(adminService.uploadProfileImage(adminId, image)); + } } diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java index 408e83f..794ad2e 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java @@ -9,14 +9,18 @@ import org.springframework.transaction.annotation.Transactional; import redot.redot_server.domain.admin.dto.request.AdminCreateRequest; import redot.redot_server.domain.admin.dto.request.AdminResetPasswordRequest; -import redot.redot_server.domain.admin.dto.response.AdminResponse; +import org.springframework.web.multipart.MultipartFile; import redot.redot_server.domain.admin.dto.request.AdminUpdateRequest; +import redot.redot_server.domain.admin.dto.response.AdminResponse; import redot.redot_server.domain.admin.entity.Admin; import redot.redot_server.domain.admin.repository.AdminRepository; import redot.redot_server.domain.auth.exception.AuthErrorCode; import redot.redot_server.domain.auth.exception.AuthException; -import redot.redot_server.global.util.dto.response.PageResponse; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.EmailUtils; +import redot.redot_server.global.util.dto.response.PageResponse; @Service @RequiredArgsConstructor @@ -24,6 +28,7 @@ public class AdminService { private final AdminRepository adminRepository; private final PasswordEncoder passwordEncoder; + private final ImageUploadService imageUploadService; @Transactional public AdminResponse createAdmin(AdminCreateRequest request) { @@ -101,4 +106,13 @@ public void deleteCurrentAdmin(Long id) { Admin admin = adminRepository.findById(id).orElseThrow(() -> new AuthException(AuthErrorCode.ADMIN_NOT_FOUND)); admin.delete(); } + + @Transactional + public UploadedImageUrlResponse uploadProfileImage(Long adminId, MultipartFile imageFile) { + adminRepository.findById(adminId) + .orElseThrow(() -> new AuthException(AuthErrorCode.ADMIN_NOT_FOUND)); + + String imageUrl = imageUploadService.upload(ImageDirectory.ADMIN_PROFILE, adminId, imageFile); + return new UploadedImageUrlResponse(imageUrl); + } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java b/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java index fdc4aeb..800d977 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java +++ b/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.validation.Valid; +import jakarta.validation.constraints.NotNull; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; @@ -18,15 +19,22 @@ import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; import redot.redot_server.domain.cms.member.dto.request.CMSMemberCreateRequest; -import redot.redot_server.domain.cms.member.dto.response.CMSMemberResponse; import redot.redot_server.domain.cms.member.dto.request.CMSMemberRoleRequest; import redot.redot_server.domain.cms.member.dto.request.CMSMemberSearchCondition; import redot.redot_server.domain.cms.member.dto.request.CMSMemberUpdateRequest; +import redot.redot_server.domain.cms.member.dto.response.CMSMemberResponse; import redot.redot_server.domain.cms.member.exception.CMSMemberErrorCode; import redot.redot_server.domain.cms.member.exception.CMSMemberException; import redot.redot_server.domain.cms.member.service.CMSMemberService; +import redot.redot_server.global.jwt.cookie.TokenCookieFactory; +import redot.redot_server.global.jwt.token.TokenType; +import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.security.principal.JwtPrincipal; import redot.redot_server.global.util.dto.response.PageResponse; import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; import redot.redot_server.global.jwt.cookie.TokenCookieFactory; @@ -103,4 +111,13 @@ public ResponseEntity deleteCurrentCMSMember(HttpServletRequest request, .build(); } + @PostMapping("/profile-image") + public ResponseEntity uploadProfileImage( + @CurrentRedotApp Long redotAppId, + @AuthenticationPrincipal JwtPrincipal jwtPrincipal, + @RequestPart("image") @NotNull MultipartFile image + ) { + return ResponseEntity.ok(cmsMemberService.uploadProfileImage(redotAppId, jwtPrincipal.id(), image)); + } + } diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 1a9603f..7b9c8b9 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -6,19 +6,23 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; import redot.redot_server.domain.cms.member.dto.request.CMSMemberCreateRequest; -import redot.redot_server.domain.cms.member.dto.response.CMSMemberResponse; import redot.redot_server.domain.cms.member.dto.request.CMSMemberRoleRequest; import redot.redot_server.domain.cms.member.dto.request.CMSMemberSearchCondition; import redot.redot_server.domain.cms.member.dto.request.CMSMemberUpdateRequest; +import redot.redot_server.domain.cms.member.dto.response.CMSMemberResponse; import redot.redot_server.domain.cms.member.entity.CMSMember; -import redot.redot_server.domain.redot.app.entity.RedotApp; import redot.redot_server.domain.cms.member.exception.CMSMemberErrorCode; import redot.redot_server.domain.cms.member.exception.CMSMemberException; +import redot.redot_server.domain.cms.member.repository.CMSMemberRepository; +import redot.redot_server.domain.redot.app.entity.RedotApp; import redot.redot_server.domain.redot.app.exception.RedotAppErrorCode; import redot.redot_server.domain.redot.app.exception.RedotAppException; -import redot.redot_server.domain.cms.member.repository.CMSMemberRepository; import redot.redot_server.domain.redot.app.repository.RedotAppRepository; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.dto.response.PageResponse; @Service @@ -29,6 +33,7 @@ public class CMSMemberService { private final CMSMemberRepository cmsMemberRepository; private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; + private final ImageUploadService imageUploadService; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -86,4 +91,13 @@ public PageResponse getCMSMemberListBySearchCondition(Long re return PageResponse.from(page); } + + @Transactional + public UploadedImageUrlResponse uploadProfileImage(Long redotAppId, Long memberId, MultipartFile imageFile) { + cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) + .orElseThrow(() -> new CMSMemberException(CMSMemberErrorCode.CMS_MEMBER_NOT_FOUND)); + + String imageUrl = imageUploadService.upload(ImageDirectory.CMS_MEMBER_PROFILE, memberId, imageFile); + return new UploadedImageUrlResponse(imageUrl); + } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java new file mode 100644 index 0000000..b151dff --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java @@ -0,0 +1,30 @@ +package redot.redot_server.domain.redot.member.controller; + +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestPart; +import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.multipart.MultipartFile; +import redot.redot_server.domain.redot.member.service.RedotMemberService; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.security.principal.JwtPrincipal; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/redot/members") +public class RedotMemberController { + + private final RedotMemberService redotMemberService; + + @PostMapping("/profile-image") + public ResponseEntity uploadProfileImage( + @AuthenticationPrincipal JwtPrincipal jwtPrincipal, + @RequestPart("image") @NotNull MultipartFile image + ) { + return ResponseEntity.ok(redotMemberService.uploadProfileImage(jwtPrincipal.id(), image)); + } +} diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index 21f76f8..97e2310 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -4,9 +4,15 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.web.multipart.MultipartFile; +import redot.redot_server.domain.auth.exception.AuthErrorCode; +import redot.redot_server.domain.auth.exception.AuthException; import redot.redot_server.domain.redot.member.entity.RedotMember; import redot.redot_server.domain.redot.member.entity.SocialProvider; import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.security.social.model.SocialProfile; import redot.redot_server.global.util.EmailUtils; @@ -16,6 +22,7 @@ public class RedotMemberService { private final RedotMemberRepository redotMemberRepository; + private final ImageUploadService imageUploadService; @Transactional public RedotMember findOrCreateSocialMember(SocialProfile profile, SocialProvider provider) { @@ -44,4 +51,13 @@ public RedotMember findOrCreateSocialMember(SocialProfile profile, SocialProvide return redotMemberRepository.save(socialMember); } + @Transactional + public UploadedImageUrlResponse uploadProfileImage(Long memberId, MultipartFile imageFile) { + redotMemberRepository.findById(memberId) + .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); + + String imageUrl = imageUploadService.upload(ImageDirectory.REDOT_MEMBER_PROFILE, memberId, imageFile); + return new UploadedImageUrlResponse(imageUrl); + } + } From 7ddcbcd66bb6891d5df7d94c93addcfdd3b2e8a0 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 12:39:30 +0900 Subject: [PATCH 04/17] refactor(site-setting): streamline logo upload process by integrating ImageUploadService --- .../site/setting/service/SiteSettingService.java | 13 ++++++------- 1 file changed, 6 insertions(+), 7 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java index e37ac80..53f07d7 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java @@ -6,18 +6,19 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import redot.redot_server.domain.cms.site.setting.dto.request.SiteSettingUpdateRequest; +import redot.redot_server.domain.cms.site.setting.dto.response.SiteSettingResponse; import redot.redot_server.domain.site.domain.entity.Domain; import redot.redot_server.domain.site.domain.exception.DomainErrorCode; import redot.redot_server.domain.site.domain.exception.DomainException; import redot.redot_server.domain.site.domain.repository.DomainRepository; -import redot.redot_server.domain.cms.site.setting.dto.response.SiteSettingResponse; -import redot.redot_server.domain.cms.site.setting.dto.request.SiteSettingUpdateRequest; import redot.redot_server.domain.site.setting.entity.SiteSetting; import redot.redot_server.domain.site.setting.exception.SiteSettingErrorCode; import redot.redot_server.domain.site.setting.exception.SiteSettingException; import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; -import redot.redot_server.domain.site.setting.util.LogoPathGenerator; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.s3.util.S3Manager; @Service @@ -28,6 +29,7 @@ public class SiteSettingService { private final SiteSettingRepository siteSettingRepository; private final DomainRepository domainRepository; private final S3Manager s3Manager; + private final ImageUploadService imageUploadService; @Transactional public SiteSettingResponse updateSiteSetting(Long redotAppId, SiteSettingUpdateRequest request) { @@ -55,10 +57,7 @@ public UploadedImageUrlResponse uploadLogoImage(Long redotAppId, MultipartFile l throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_REQUIRED); } - String logoUrl = s3Manager.uploadFile( - logoFile, - LogoPathGenerator.generateLogoPath(redotAppId, logoFile.getOriginalFilename()) - ); + String logoUrl = imageUploadService.upload(ImageDirectory.APP_LOGO, redotAppId, logoFile); return new UploadedImageUrlResponse(logoUrl); } From 567e7b19fe982beb9022883d91ab64f76f7b4d68 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 13:38:04 +0900 Subject: [PATCH 05/17] refactor(image-storage): replace ImageUploadService with ImageStorageService for image handling --- .../domain/admin/service/AdminService.java | 6 +++--- .../domain/cms/member/service/CMSMemberService.java | 6 +++--- .../cms/site/setting/service/SiteSettingService.java | 10 ++++------ .../redot/member/service/RedotMemberService.java | 6 +++--- ...mageUploadService.java => ImageStorageService.java} | 6 +++++- 5 files changed, 18 insertions(+), 16 deletions(-) rename src/main/java/redot/redot_server/global/s3/service/{ImageUploadService.java => ImageStorageService.java} (88%) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java index 794ad2e..42641b1 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java @@ -17,7 +17,7 @@ import redot.redot_server.domain.auth.exception.AuthErrorCode; import redot.redot_server.domain.auth.exception.AuthException; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.EmailUtils; import redot.redot_server.global.util.dto.response.PageResponse; @@ -28,7 +28,7 @@ public class AdminService { private final AdminRepository adminRepository; private final PasswordEncoder passwordEncoder; - private final ImageUploadService imageUploadService; + private final ImageStorageService imageStorageService; @Transactional public AdminResponse createAdmin(AdminCreateRequest request) { @@ -112,7 +112,7 @@ public UploadedImageUrlResponse uploadProfileImage(Long adminId, MultipartFile i adminRepository.findById(adminId) .orElseThrow(() -> new AuthException(AuthErrorCode.ADMIN_NOT_FOUND)); - String imageUrl = imageUploadService.upload(ImageDirectory.ADMIN_PROFILE, adminId, imageFile); + String imageUrl = imageStorageService.upload(ImageDirectory.ADMIN_PROFILE, adminId, imageFile); return new UploadedImageUrlResponse(imageUrl); } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 7b9c8b9..0e844c5 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -21,7 +21,7 @@ import redot.redot_server.domain.redot.app.exception.RedotAppException; import redot.redot_server.domain.redot.app.repository.RedotAppRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.dto.response.PageResponse; @@ -33,7 +33,7 @@ public class CMSMemberService { private final CMSMemberRepository cmsMemberRepository; private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; - private final ImageUploadService imageUploadService; + private final ImageStorageService imageStorageService; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -97,7 +97,7 @@ public UploadedImageUrlResponse uploadProfileImage(Long redotAppId, Long memberI cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) .orElseThrow(() -> new CMSMemberException(CMSMemberErrorCode.CMS_MEMBER_NOT_FOUND)); - String imageUrl = imageUploadService.upload(ImageDirectory.CMS_MEMBER_PROFILE, memberId, imageFile); + String imageUrl = imageStorageService.upload(ImageDirectory.CMS_MEMBER_PROFILE, memberId, imageFile); return new UploadedImageUrlResponse(imageUrl); } } diff --git a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java index 53f07d7..dd9ac5d 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java @@ -17,9 +17,8 @@ import redot.redot_server.domain.site.setting.exception.SiteSettingException; import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; -import redot.redot_server.global.s3.util.S3Manager; @Service @RequiredArgsConstructor @@ -28,8 +27,7 @@ public class SiteSettingService { private final SiteSettingRepository siteSettingRepository; private final DomainRepository domainRepository; - private final S3Manager s3Manager; - private final ImageUploadService imageUploadService; + private final ImageStorageService imageStorageService; @Transactional public SiteSettingResponse updateSiteSetting(Long redotAppId, SiteSettingUpdateRequest request) { @@ -57,7 +55,7 @@ public UploadedImageUrlResponse uploadLogoImage(Long redotAppId, MultipartFile l throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_REQUIRED); } - String logoUrl = imageUploadService.upload(ImageDirectory.APP_LOGO, redotAppId, logoFile); + String logoUrl = imageStorageService.upload(ImageDirectory.APP_LOGO, redotAppId, logoFile); return new UploadedImageUrlResponse(logoUrl); } @@ -83,7 +81,7 @@ private void deleteOldLogoIfChanged(SiteSetting siteSetting, String newLogoUrl) return; } if (newLogoUrl == null || !newLogoUrl.equals(currentLogoUrl)) { - s3Manager.deleteFile(currentLogoUrl); + imageStorageService.delete(currentLogoUrl); } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index 97e2310..8a7248e 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -11,7 +11,7 @@ import redot.redot_server.domain.redot.member.entity.SocialProvider; import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageUploadService; +import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.security.social.model.SocialProfile; import redot.redot_server.global.util.EmailUtils; @@ -22,7 +22,7 @@ public class RedotMemberService { private final RedotMemberRepository redotMemberRepository; - private final ImageUploadService imageUploadService; + private final ImageStorageService imageStorageService; @Transactional public RedotMember findOrCreateSocialMember(SocialProfile profile, SocialProvider provider) { @@ -56,7 +56,7 @@ public UploadedImageUrlResponse uploadProfileImage(Long memberId, MultipartFile redotMemberRepository.findById(memberId) .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); - String imageUrl = imageUploadService.upload(ImageDirectory.REDOT_MEMBER_PROFILE, memberId, imageFile); + String imageUrl = imageStorageService.upload(ImageDirectory.REDOT_MEMBER_PROFILE, memberId, imageFile); return new UploadedImageUrlResponse(imageUrl); } diff --git a/src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java similarity index 88% rename from src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java rename to src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java index 876313e..28fceda 100644 --- a/src/main/java/redot/redot_server/global/s3/service/ImageUploadService.java +++ b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java @@ -11,7 +11,7 @@ @Service @RequiredArgsConstructor -public class ImageUploadService { +public class ImageStorageService { private final S3Manager s3Manager; @@ -23,4 +23,8 @@ public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) String path = ImagePathGenerator.generate(directory, ownerId, file.getOriginalFilename()); return s3Manager.uploadFile(file, path); } + + public void delete(String imageUrl) { + s3Manager.deleteFile(imageUrl); + } } From 764312e3e8d0990dfb55e75e82e0e9f905a66c99 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 13:54:35 +0900 Subject: [PATCH 06/17] refactor(profile-image): standardize profile image upload endpoint across controllers --- .../domain/admin/controller/AdminController.java | 6 +++--- .../domain/cms/member/controller/CMSMemberController.java | 6 +----- .../redot/member/controller/RedotMemberController.java | 2 +- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java index 958ba23..10181fe 100644 --- a/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java +++ b/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java @@ -83,11 +83,11 @@ public ResponseEntity deleteCurrentAdmin(HttpServletRequest request, @Auth .build(); } - @PostMapping("/{adminId}/profile-image") + @PostMapping("/upload-profile-image") public ResponseEntity uploadProfileImage( - @PathVariable Long adminId, + @AuthenticationPrincipal JwtPrincipal jwtPrincipal, @RequestPart("image") @NotNull MultipartFile image ) { - return ResponseEntity.ok(adminService.uploadProfileImage(adminId, image)); + return ResponseEntity.ok(adminService.uploadProfileImage(jwtPrincipal.id(), image)); } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java b/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java index 800d977..1bb4f4d 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java +++ b/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java @@ -36,10 +36,6 @@ import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; import redot.redot_server.global.security.principal.JwtPrincipal; import redot.redot_server.global.util.dto.response.PageResponse; -import redot.redot_server.global.redotapp.resolver.annotation.CurrentRedotApp; -import redot.redot_server.global.jwt.cookie.TokenCookieFactory; -import redot.redot_server.global.jwt.token.TokenType; -import redot.redot_server.global.security.principal.JwtPrincipal; @RestController @RequiredArgsConstructor @@ -111,7 +107,7 @@ public ResponseEntity deleteCurrentCMSMember(HttpServletRequest request, .build(); } - @PostMapping("/profile-image") + @PostMapping("/upload-profile-image") public ResponseEntity uploadProfileImage( @CurrentRedotApp Long redotAppId, @AuthenticationPrincipal JwtPrincipal jwtPrincipal, diff --git a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java index b151dff..25ff58e 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java +++ b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java @@ -20,7 +20,7 @@ public class RedotMemberController { private final RedotMemberService redotMemberService; - @PostMapping("/profile-image") + @PostMapping("/upload-profile-image") public ResponseEntity uploadProfileImage( @AuthenticationPrincipal JwtPrincipal jwtPrincipal, @RequestPart("image") @NotNull MultipartFile image From 61848a5627dfd1d6e5cce35bb17dfd9d7b211e7d Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 13:55:35 +0900 Subject: [PATCH 07/17] refactor(image-directory): update profile image paths for admin and CMS member --- .../redot/redot_server/global/s3/util/ImageDirectory.java | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java b/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java index b9de7e1..44f8533 100644 --- a/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java +++ b/src/main/java/redot/redot_server/global/s3/util/ImageDirectory.java @@ -3,8 +3,8 @@ import java.util.function.Function; public enum ImageDirectory { - ADMIN_PROFILE(id -> String.format("admin/profile/%d", id)), - CMS_MEMBER_PROFILE(id -> String.format("cms/member/profile/%d", id)), + ADMIN_PROFILE(id -> String.format("redot/admin/profile/%d", id)), + CMS_MEMBER_PROFILE(id -> String.format("app/cms/member/profile/%d", id)), REDOT_MEMBER_PROFILE(id -> String.format("redot/member/profile/%d", id)), APP_LOGO(id -> String.format("app/%d/logo", id)); From 5af427fb08a031388ddb5c3dbfd170dfcddf7408 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 14:23:38 +0900 Subject: [PATCH 08/17] feat(image-upload): enhance image validation with size and type checks --- .../redot_server/RedotServerApplication.java | 3 +- .../s3/config/ImageUploadProperties.java | 19 +++++++++ .../global/s3/exception/ImageErrorCode.java | 4 +- .../s3/service/ImageStorageService.java | 40 +++++++++++++++++-- 4 files changed, 61 insertions(+), 5 deletions(-) create mode 100644 src/main/java/redot/redot_server/global/s3/config/ImageUploadProperties.java diff --git a/src/main/java/redot/redot_server/RedotServerApplication.java b/src/main/java/redot/redot_server/RedotServerApplication.java index 27637c0..3afdf73 100644 --- a/src/main/java/redot/redot_server/RedotServerApplication.java +++ b/src/main/java/redot/redot_server/RedotServerApplication.java @@ -4,10 +4,11 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.boot.context.properties.EnableConfigurationProperties; import redot.redot_server.global.email.EmailVerificationProperties; +import redot.redot_server.global.s3.config.ImageUploadProperties; import redot.redot_server.global.security.social.config.AuthRedirectProperties; @SpringBootApplication -@EnableConfigurationProperties({AuthRedirectProperties.class, EmailVerificationProperties.class}) +@EnableConfigurationProperties({AuthRedirectProperties.class, EmailVerificationProperties.class, ImageUploadProperties.class}) public class RedotServerApplication { public static void main(String[] args) { SpringApplication.run(RedotServerApplication.class, args); diff --git a/src/main/java/redot/redot_server/global/s3/config/ImageUploadProperties.java b/src/main/java/redot/redot_server/global/s3/config/ImageUploadProperties.java new file mode 100644 index 0000000..8bc1c8b --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/config/ImageUploadProperties.java @@ -0,0 +1,19 @@ +package redot.redot_server.global.s3.config; + +import java.util.ArrayList; +import java.util.List; +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +@Getter +@Setter +@ConfigurationProperties(prefix = "image.upload") +public class ImageUploadProperties { + + private long maxSizeBytes = 5_242_880L; // 5MB default + + private List allowedContentTypes = new ArrayList<>(List.of( + "image/*" + )); +} diff --git a/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java index cf7fa9e..7934406 100644 --- a/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java +++ b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java @@ -10,7 +10,9 @@ public enum ImageErrorCode implements ErrorCode { IMAGE_FILE_REQUIRED(400, 5200, "업로드할 이미지를 선택해주세요."), INVALID_IMAGE_FILE_NAME(400, 5201, "이미지 파일명이 올바르지 않습니다."), INVALID_IMAGE_EXTENSION(400, 5202, "지원하지 않는 이미지 확장자입니다."), - INVALID_IMAGE_OWNER(400, 5203, "이미지를 저장할 대상을 찾을 수 없습니다."); + INVALID_IMAGE_OWNER(400, 5203, "이미지를 저장할 대상을 찾을 수 없습니다."), + IMAGE_TOO_LARGE(400, 5204, "허용된 크기를 초과한 이미지입니다."), + UNSUPPORTED_IMAGE_TYPE(400, 5205, "지원하지 않는 이미지 형식입니다."); private final int statusCode; private final int exceptionCode; diff --git a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java index 28fceda..78bc806 100644 --- a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java +++ b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java @@ -3,6 +3,9 @@ import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; +import java.util.List; +import org.springframework.util.StringUtils; +import redot.redot_server.global.s3.config.ImageUploadProperties; import redot.redot_server.global.s3.exception.ImageErrorCode; import redot.redot_server.global.s3.exception.ImageUploadException; import redot.redot_server.global.s3.util.ImageDirectory; @@ -14,11 +17,10 @@ public class ImageStorageService { private final S3Manager s3Manager; + private final ImageUploadProperties properties; public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) { - if (file == null || file.isEmpty()) { - throw new ImageUploadException(ImageErrorCode.IMAGE_FILE_REQUIRED); - } + validateFile(file); String path = ImagePathGenerator.generate(directory, ownerId, file.getOriginalFilename()); return s3Manager.uploadFile(file, path); @@ -27,4 +29,36 @@ public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) public void delete(String imageUrl) { s3Manager.deleteFile(imageUrl); } + + private void validateFile(MultipartFile file) { + if (file == null || file.isEmpty()) { + throw new ImageUploadException(ImageErrorCode.IMAGE_FILE_REQUIRED); + } + + if (file.getSize() > properties.getMaxSizeBytes()) { + throw new ImageUploadException(ImageErrorCode.IMAGE_TOO_LARGE); + } + + String contentType = file.getContentType(); + List allowed = properties.getAllowedContentTypes(); + boolean allowedType = StringUtils.hasText(contentType) && + allowed.stream().anyMatch(pattern -> matchesContentType(contentType, pattern)); + if (!allowedType) { + throw new ImageUploadException(ImageErrorCode.UNSUPPORTED_IMAGE_TYPE); + } + } + + private boolean matchesContentType(String contentType, String pattern) { + if (!StringUtils.hasText(pattern)) { + return false; + } + if ("*/*".equals(pattern)) { + return true; + } + if (pattern.endsWith("/*")) { + String prefix = pattern.substring(0, pattern.length() - 1); + return contentType.regionMatches(true, 0, prefix, 0, prefix.length()); + } + return contentType.equalsIgnoreCase(pattern); + } } From 90b255670ebddad94fcd98bed7fb2f9f58f72d9a Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 14:32:55 +0900 Subject: [PATCH 09/17] feat(image-upload): implement ImageMimeDetector for enhanced image type detection --- .../s3/service/ImageStorageService.java | 10 ++ .../global/s3/util/ImageMimeDetector.java | 93 +++++++++++++++++++ 2 files changed, 103 insertions(+) create mode 100644 src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java diff --git a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java index 78bc806..6ea752c 100644 --- a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java +++ b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java @@ -9,6 +9,7 @@ import redot.redot_server.global.s3.exception.ImageErrorCode; import redot.redot_server.global.s3.exception.ImageUploadException; import redot.redot_server.global.s3.util.ImageDirectory; +import redot.redot_server.global.s3.util.ImageMimeDetector; import redot.redot_server.global.s3.util.ImagePathGenerator; import redot.redot_server.global.s3.util.S3Manager; @@ -18,6 +19,7 @@ public class ImageStorageService { private final S3Manager s3Manager; private final ImageUploadProperties properties; + private final ImageMimeDetector imageMimeDetector; public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) { validateFile(file); @@ -46,6 +48,14 @@ private void validateFile(MultipartFile file) { if (!allowedType) { throw new ImageUploadException(ImageErrorCode.UNSUPPORTED_IMAGE_TYPE); } + + String detected = imageMimeDetector.detect(file) + .orElseThrow(() -> new ImageUploadException(ImageErrorCode.UNSUPPORTED_IMAGE_TYPE)); + + boolean detectedAllowed = allowed.stream().anyMatch(pattern -> matchesContentType(detected, pattern)); + if (!detectedAllowed) { + throw new ImageUploadException(ImageErrorCode.UNSUPPORTED_IMAGE_TYPE); + } } private boolean matchesContentType(String contentType, String pattern) { diff --git a/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java new file mode 100644 index 0000000..8fbcb3e --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java @@ -0,0 +1,93 @@ +package redot.redot_server.global.s3.util; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; +import java.util.Optional; +import org.springframework.stereotype.Component; +import org.springframework.web.multipart.MultipartFile; + +@Component +public class ImageMimeDetector { + + private static final byte[] JPEG = {(byte) 0xFF, (byte) 0xD8, (byte) 0xFF}; + private static final byte[] PNG = {(byte) 0x89, 0x50, 0x4E, 0x47, 0x0D, 0x0A, 0x1A, 0x0A}; + private static final byte[] GIF87A = "GIF87a".getBytes(StandardCharsets.US_ASCII); + private static final byte[] GIF89A = "GIF89a".getBytes(StandardCharsets.US_ASCII); + private static final byte[] BMP = {0x42, 0x4D}; + private static final byte[] HEIC = {'f', 't', 'y', 'p', 'h', 'e', 'i', 'c'}; + private static final byte[] HEIF = {'f', 't', 'y', 'p', 'h', 'e', 'i', 'f'}; + + public Optional detect(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + byte[] header = inputStream.readNBytes(12); + if (matches(header, JPEG)) { + return Optional.of("image/jpeg"); + } + if (matches(header, PNG)) { + return Optional.of("image/png"); + } + if (matches(header, GIF87A) || matches(header, GIF89A)) { + return Optional.of("image/gif"); + } + if (matches(header, BMP)) { + return Optional.of("image/bmp"); + } + if (isWebp(header)) { + return Optional.of("image/webp"); + } + if (isHeic(header, inputStream)) { + return Optional.of("image/heic"); + } + if (isSvg(file)) { + return Optional.of("image/svg+xml"); + } + return Optional.empty(); + } catch (IOException e) { + return Optional.empty(); + } + } + + private boolean matches(byte[] header, byte[] signature) { + if (header.length < signature.length) { + return false; + } + for (int i = 0; i < signature.length; i++) { + if (header[i] != signature[i]) { + return false; + } + } + return true; + } + + private boolean isWebp(byte[] header) { + if (header.length < 12) { + return false; + } + return header[0] == 'R' && header[1] == 'I' && header[2] == 'F' && header[3] == 'F' + && header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P'; + } + + private boolean isHeic(byte[] header, InputStream inputStream) throws IOException { + if (header.length < 12) { + return false; + } + byte[] type = new byte[8]; + System.arraycopy(header, 4, type, 0, 8); + if (matches(type, HEIC) || matches(type, HEIF)) { + return true; + } + byte[] next = inputStream.readNBytes(12); + return next.length >= 12 && (matches(next, HEIC) || matches(next, HEIF)); + } + + private boolean isSvg(MultipartFile file) { + try (InputStream inputStream = file.getInputStream()) { + byte[] snippetBytes = inputStream.readNBytes(256); + String snippet = new String(snippetBytes, StandardCharsets.UTF_8).toLowerCase(); + return snippet.contains(" Date: Sun, 7 Dec 2025 14:38:20 +0900 Subject: [PATCH 10/17] refactor(image-mime-detector): simplify HEIC detection by removing unnecessary input stream check --- .../redot_server/global/s3/util/ImageMimeDetector.java | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java index 8fbcb3e..54e4cb3 100644 --- a/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java +++ b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java @@ -36,7 +36,7 @@ public Optional detect(MultipartFile file) { if (isWebp(header)) { return Optional.of("image/webp"); } - if (isHeic(header, inputStream)) { + if (isHeic(header)) { return Optional.of("image/heic"); } if (isSvg(file)) { @@ -68,17 +68,13 @@ private boolean isWebp(byte[] header) { && header[8] == 'W' && header[9] == 'E' && header[10] == 'B' && header[11] == 'P'; } - private boolean isHeic(byte[] header, InputStream inputStream) throws IOException { + private boolean isHeic(byte[] header) { if (header.length < 12) { return false; } byte[] type = new byte[8]; System.arraycopy(header, 4, type, 0, 8); - if (matches(type, HEIC) || matches(type, HEIF)) { - return true; - } - byte[] next = inputStream.readNBytes(12); - return next.length >= 12 && (matches(next, HEIC) || matches(next, HEIF)); + return matches(type, HEIC) || matches(type, HEIF); } private boolean isSvg(MultipartFile file) { From 4df2c671944ee8ee7f8f88faa2f1086e569afef7 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 15:47:50 +0900 Subject: [PATCH 11/17] feat(member-management): add update info endpoint for RedotMember --- .../controller/RedotMemberController.java | 15 +++++++++++++++ .../member/dto/RedotMemberUpdateRequest.java | 7 +++++++ .../dto/response/RedotMemberResponse.java | 4 ++-- .../redot/member/entity/RedotMember.java | 5 +++++ .../member/service/RedotMemberService.java | 19 +++++++++++++++++++ 5 files changed, 48 insertions(+), 2 deletions(-) create mode 100644 src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java diff --git a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java index 25ff58e..b9353df 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java +++ b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java @@ -5,10 +5,14 @@ import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.bind.annotation.RestController; import org.springframework.web.multipart.MultipartFile; +import redot.redot_server.domain.redot.member.dto.RedotMemberUpdateRequest; +import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; import redot.redot_server.domain.redot.member.service.RedotMemberService; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; import redot.redot_server.global.security.principal.JwtPrincipal; @@ -20,6 +24,17 @@ public class RedotMemberController { private final RedotMemberService redotMemberService; + + @PutMapping + public ResponseEntity updateRedotMemberInfo( + @AuthenticationPrincipal JwtPrincipal jwtPrincipal, + @RequestBody RedotMemberUpdateRequest request + ) { + RedotMemberResponse redotMemberResponse = redotMemberService.updateRedotMemberInfo(jwtPrincipal.id(), request); + return ResponseEntity.ok(redotMemberResponse); + } + + @PostMapping("/upload-profile-image") public ResponseEntity uploadProfileImage( @AuthenticationPrincipal JwtPrincipal jwtPrincipal, diff --git a/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java new file mode 100644 index 0000000..9697379 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java @@ -0,0 +1,7 @@ +package redot.redot_server.domain.redot.member.dto; + +public record RedotMemberUpdateRequest( + String name, + String profileImageUrl +) { +} diff --git a/src/main/java/redot/redot_server/domain/redot/member/dto/response/RedotMemberResponse.java b/src/main/java/redot/redot_server/domain/redot/member/dto/response/RedotMemberResponse.java index f92a0d3..befe407 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/dto/response/RedotMemberResponse.java +++ b/src/main/java/redot/redot_server/domain/redot/member/dto/response/RedotMemberResponse.java @@ -10,7 +10,7 @@ public record RedotMemberResponse( String profileImageUrl, SocialProvider socialProvider ) { - public static RedotMemberResponse from(RedotMember member) { + public static RedotMemberResponse fromEntity(RedotMember member) { return new RedotMemberResponse( member.getId(), member.getName(), @@ -21,6 +21,6 @@ public static RedotMemberResponse from(RedotMember member) { } public static RedotMemberResponse fromNullable(RedotMember member) { - return member == null ? null : from(member); + return member == null ? null : fromEntity(member); } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/entity/RedotMember.java b/src/main/java/redot/redot_server/domain/redot/member/entity/RedotMember.java index faa3d15..e440cfa 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/entity/RedotMember.java +++ b/src/main/java/redot/redot_server/domain/redot/member/entity/RedotMember.java @@ -120,4 +120,9 @@ public void linkSocialAccount(SocialProvider socialProvider, public void resetPassword(String encodedPassword) { this.password = encodedPassword; } + + public void updateInfo(String name, String profileImageUrl) { + this.name = name; + this.profileImageUrl = profileImageUrl; + } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index 8a7248e..a7572b2 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -7,6 +7,8 @@ import org.springframework.web.multipart.MultipartFile; import redot.redot_server.domain.auth.exception.AuthErrorCode; import redot.redot_server.domain.auth.exception.AuthException; +import redot.redot_server.domain.redot.member.dto.RedotMemberUpdateRequest; +import redot.redot_server.domain.redot.member.dto.response.RedotMemberResponse; import redot.redot_server.domain.redot.member.entity.RedotMember; import redot.redot_server.domain.redot.member.entity.SocialProvider; import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; @@ -60,4 +62,21 @@ public UploadedImageUrlResponse uploadProfileImage(Long memberId, MultipartFile return new UploadedImageUrlResponse(imageUrl); } + public RedotMemberResponse updateRedotMemberInfo(Long id, RedotMemberUpdateRequest request) { + RedotMember redotMember = redotMemberRepository.findById(id) + .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); + + deleteOldProfileImageUrlIfChanged(request, redotMember); + + redotMember.updateInfo(request.name(), request.profileImageUrl()); + + return RedotMemberResponse.fromEntity(redotMember); + } + + private void deleteOldProfileImageUrlIfChanged(RedotMemberUpdateRequest request, RedotMember redotMember) { + String oldProfileImageUrl = redotMember.getProfileImageUrl(); + if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { + imageStorageService.delete(oldProfileImageUrl); + } + } } From 9ea64ed6343ddfde9210d4111d4e17be1f51c871 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 15:47:59 +0900 Subject: [PATCH 12/17] feat(member-management): implement profile image cleanup on update --- .../domain/admin/service/AdminService.java | 9 ++++++++ .../auth/service/RedotMemberAuthService.java | 4 ++-- .../cms/member/service/CMSMemberService.java | 9 ++++++++ .../setting/service/SiteSettingService.java | 23 +++++++++---------- 4 files changed, 31 insertions(+), 14 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java index 42641b1..1d0d05d 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java @@ -75,6 +75,8 @@ public AdminResponse updateAdmin(Long adminId, AdminUpdateRequest request) { throw new AuthException(AuthErrorCode.EMAIL_ALREADY_EXISTS); } + deleteOldProfileImageUrlIfChanged(request, admin); + admin.update(request.name(), normalizedEmail, request.profileImageUrl()); return AdminResponse.from(admin); @@ -83,6 +85,13 @@ public AdminResponse updateAdmin(Long adminId, AdminUpdateRequest request) { } } + private void deleteOldProfileImageUrlIfChanged(AdminUpdateRequest request, Admin admin) { + String oldProfileImageUrl = admin.getProfileImageUrl(); + if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { + imageStorageService.delete(oldProfileImageUrl); + } + } + @Transactional public void resetAdminPassword(Long adminId, AdminResetPasswordRequest request) { Admin admin = adminRepository.findById(adminId) diff --git a/src/main/java/redot/redot_server/domain/auth/service/RedotMemberAuthService.java b/src/main/java/redot/redot_server/domain/auth/service/RedotMemberAuthService.java index 66dd8d7..fc4c953 100644 --- a/src/main/java/redot/redot_server/domain/auth/service/RedotMemberAuthService.java +++ b/src/main/java/redot/redot_server/domain/auth/service/RedotMemberAuthService.java @@ -53,7 +53,7 @@ public RedotMemberResponse signUp(RedotMemberCreateRequest request) { ); RedotMember savedMember = redotMemberRepository.save(redotMember); - return RedotMemberResponse.from(savedMember); + return RedotMemberResponse.fromEntity(savedMember); } public AuthResult signIn(HttpServletRequest request, RedotMemberSignInRequest signInRequest) { @@ -107,7 +107,7 @@ public RedotMemberResponse getCurrentMember(Long memberId) { RedotMember member = redotMemberRepository.findById(memberId) .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); - return RedotMemberResponse.from(member); + return RedotMemberResponse.fromEntity(member); } @Transactional diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 0e844c5..9dd8e29 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -69,11 +69,20 @@ public CMSMemberResponse updateCMSMember(Long redotAppId, Long memberId, CMSMemb CMSMember cmsMember = cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) .orElseThrow(() -> new CMSMemberException(CMSMemberErrorCode.CMS_MEMBER_NOT_FOUND)); + deleteOldProfileImageUrlIfChanged(request, cmsMember); + cmsMember.updateProfile(request.name(), request.profileImageUrl()); return CMSMemberResponse.fromEntity(redotAppId, cmsMember); } + private void deleteOldProfileImageUrlIfChanged(CMSMemberUpdateRequest request, CMSMember cmsMember) { + String oldProfileImageUrl = cmsMember.getProfileImageUrl(); + if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { + imageStorageService.delete(oldProfileImageUrl); + } + } + @Transactional public void deleteCMSMember(Long redotAppId, Long memberId) { CMSMember cmsMember = cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) diff --git a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java index dd9ac5d..bbf6c2e 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java @@ -36,20 +36,28 @@ public SiteSettingResponse updateSiteSetting(Long redotAppId, SiteSettingUpdateR Domain domain = domainRepository.findByRedotAppId(redotAppId) .orElseThrow(() -> new DomainException(DomainErrorCode.DOMAIN_NOT_FOUND)); - if(isCustomDomainExists(request, domain)) { + if (isCustomDomainExists(request, domain)) { throw new DomainException(DomainErrorCode.CUSTOM_DOMAIN_ALREADY_EXISTS); } + deleteOldLogoUrlIfChanged(request, siteSetting); + siteSetting.updateSiteName(request.siteName()); siteSetting.updateLogoUrl(request.logoUrl()); siteSetting.updateGaInfo(request.gaInfo()); domain.updateCustomDomain(request.customDomain()); - deleteOldLogoIfChanged(siteSetting, request.logoUrl()); - return SiteSettingResponse.fromEntity(siteSetting, domain); } + private void deleteOldLogoUrlIfChanged(SiteSettingUpdateRequest request, SiteSetting siteSetting) { + String oldLogoUrl = siteSetting.getLogoUrl(); + + if (oldLogoUrl != null && !oldLogoUrl.equals(request.logoUrl())) { + imageStorageService.delete(oldLogoUrl); + } + } + public UploadedImageUrlResponse uploadLogoImage(Long redotAppId, MultipartFile logoFile) { if (logoFile == null || logoFile.isEmpty()) { throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_REQUIRED); @@ -75,15 +83,6 @@ private boolean isCustomDomainExists(SiteSettingUpdateRequest request, Domain do return domainRepository.existsByCustomDomain(requestedCustomDomain); } - private void deleteOldLogoIfChanged(SiteSetting siteSetting, String newLogoUrl) { - String currentLogoUrl = siteSetting.getLogoUrl(); - if(currentLogoUrl == null) { - return; - } - if (newLogoUrl == null || !newLogoUrl.equals(currentLogoUrl)) { - imageStorageService.delete(currentLogoUrl); - } - } public SiteSettingResponse getSiteSetting(Long redotAppId) { SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotAppId) From 7f1552b241ff1584e264a1c370341629cba8c416 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 15:57:44 +0900 Subject: [PATCH 13/17] feat(member-management): add validation to RedotMemberUpdateRequest and enable transaction for update method --- .../domain/redot/member/dto/RedotMemberUpdateRequest.java | 3 +++ .../domain/redot/member/service/RedotMemberService.java | 1 + 2 files changed, 4 insertions(+) diff --git a/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java index 9697379..9a81617 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java +++ b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java @@ -1,6 +1,9 @@ package redot.redot_server.domain.redot.member.dto; +import jakarta.validation.constraints.NotNull; + public record RedotMemberUpdateRequest( + @NotNull String name, String profileImageUrl ) { diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index a7572b2..1f1876c 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -62,6 +62,7 @@ public UploadedImageUrlResponse uploadProfileImage(Long memberId, MultipartFile return new UploadedImageUrlResponse(imageUrl); } + @Transactional public RedotMemberResponse updateRedotMemberInfo(Long id, RedotMemberUpdateRequest request) { RedotMember redotMember = redotMemberRepository.findById(id) .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); From d8f4cc9a24f911abc091666a051101201c6a2055 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 16:16:19 +0900 Subject: [PATCH 14/17] feat(member-management): implement image deletion event for profile image updates --- .../domain/admin/service/AdminService.java | 11 ++++++---- .../cms/member/service/CMSMemberService.java | 11 ++++++---- .../setting/service/SiteSettingService.java | 5 ++++- .../member/service/RedotMemberService.java | 5 ++++- .../global/s3/event/ImageDeletionEvent.java | 3 +++ .../s3/event/ImageDeletionEventListener.java | 22 +++++++++++++++++++ .../s3/service/ImageStorageService.java | 5 ++++- 7 files changed, 51 insertions(+), 11 deletions(-) create mode 100644 src/main/java/redot/redot_server/global/s3/event/ImageDeletionEvent.java create mode 100644 src/main/java/redot/redot_server/global/s3/event/ImageDeletionEventListener.java diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java index 1d0d05d..f173e62 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java @@ -1,6 +1,7 @@ package redot.redot_server.domain.admin.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.dao.DataIntegrityViolationException; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -16,9 +17,10 @@ import redot.redot_server.domain.admin.repository.AdminRepository; import redot.redot_server.domain.auth.exception.AuthErrorCode; import redot.redot_server.domain.auth.exception.AuthException; -import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageStorageService; -import redot.redot_server.global.s3.util.ImageDirectory; +import redot_redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot_redot_server.global.s3.event.ImageDeletionEvent; +import redot-redot_server.global.s3.service.ImageStorageService; +import redot-redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.EmailUtils; import redot.redot_server.global.util.dto.response.PageResponse; @@ -29,6 +31,7 @@ public class AdminService { private final AdminRepository adminRepository; private final PasswordEncoder passwordEncoder; private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public AdminResponse createAdmin(AdminCreateRequest request) { @@ -88,7 +91,7 @@ public AdminResponse updateAdmin(Long adminId, AdminUpdateRequest request) { private void deleteOldProfileImageUrlIfChanged(AdminUpdateRequest request, Admin admin) { String oldProfileImageUrl = admin.getProfileImageUrl(); if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { - imageStorageService.delete(oldProfileImageUrl); + eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); } } diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 9dd8e29..6b392be 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -1,6 +1,7 @@ package redot.redot_server.domain.cms.member.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; @@ -20,9 +21,10 @@ import redot.redot_server.domain.redot.app.exception.RedotAppErrorCode; import redot.redot_server.domain.redot.app.exception.RedotAppException; import redot.redot_server.domain.redot.app.repository.RedotAppRepository; -import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot.redot_server.global.s3.service.ImageStorageService; -import redot.redot_server.global.s3.util.ImageDirectory; +import redot_redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot_redot_server.global.s3.event.ImageDeletionEvent; +import redot_redot_server.global.s3.service.ImageStorageService; +import redot_redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.dto.response.PageResponse; @Service @@ -34,6 +36,7 @@ public class CMSMemberService { private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -79,7 +82,7 @@ public CMSMemberResponse updateCMSMember(Long redotAppId, Long memberId, CMSMemb private void deleteOldProfileImageUrlIfChanged(CMSMemberUpdateRequest request, CMSMember cmsMember) { String oldProfileImageUrl = cmsMember.getProfileImageUrl(); if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { - imageStorageService.delete(oldProfileImageUrl); + eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); } } diff --git a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java index bbf6c2e..ea9f372 100644 --- a/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java +++ b/src/main/java/redot/redot_server/domain/cms/site/setting/service/SiteSettingService.java @@ -3,6 +3,7 @@ import java.util.Objects; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -17,6 +18,7 @@ import redot.redot_server.domain.site.setting.exception.SiteSettingException; import redot.redot_server.domain.site.setting.repository.SiteSettingRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.event.ImageDeletionEvent; import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; @@ -28,6 +30,7 @@ public class SiteSettingService { private final SiteSettingRepository siteSettingRepository; private final DomainRepository domainRepository; private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public SiteSettingResponse updateSiteSetting(Long redotAppId, SiteSettingUpdateRequest request) { @@ -54,7 +57,7 @@ private void deleteOldLogoUrlIfChanged(SiteSettingUpdateRequest request, SiteSet String oldLogoUrl = siteSetting.getLogoUrl(); if (oldLogoUrl != null && !oldLogoUrl.equals(request.logoUrl())) { - imageStorageService.delete(oldLogoUrl); + eventPublisher.publishEvent(new ImageDeletionEvent(oldLogoUrl)); } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index 1f1876c..b772cc4 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -2,6 +2,7 @@ import java.util.Optional; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; @@ -13,6 +14,7 @@ import redot.redot_server.domain.redot.member.entity.SocialProvider; import redot.redot_server.domain.redot.member.repository.RedotMemberRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.event.ImageDeletionEvent; import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.security.social.model.SocialProfile; @@ -25,6 +27,7 @@ public class RedotMemberService { private final RedotMemberRepository redotMemberRepository; private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public RedotMember findOrCreateSocialMember(SocialProfile profile, SocialProvider provider) { @@ -77,7 +80,7 @@ public RedotMemberResponse updateRedotMemberInfo(Long id, RedotMemberUpdateReque private void deleteOldProfileImageUrlIfChanged(RedotMemberUpdateRequest request, RedotMember redotMember) { String oldProfileImageUrl = redotMember.getProfileImageUrl(); if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { - imageStorageService.delete(oldProfileImageUrl); + eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); } } } diff --git a/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEvent.java b/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEvent.java new file mode 100644 index 0000000..3f2a672 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEvent.java @@ -0,0 +1,3 @@ +package redot.redot_server.global.s3.event; + +public record ImageDeletionEvent(String imageUrl) {} diff --git a/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEventListener.java b/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEventListener.java new file mode 100644 index 0000000..8e04881 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/event/ImageDeletionEventListener.java @@ -0,0 +1,22 @@ +package redot.redot_server.global.s3.event; + +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; +import redot.redot_server.global.s3.service.ImageStorageService; + +@Component +@RequiredArgsConstructor +public class ImageDeletionEventListener { + + private final ImageStorageService imageStorageService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void handle(ImageDeletionEvent event) { + if (event == null || event.imageUrl() == null || event.imageUrl().isBlank()) { + return; + } + imageStorageService.delete(event.imageUrl()); + } +} diff --git a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java index 6ea752c..627cdc2 100644 --- a/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java +++ b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java @@ -28,7 +28,10 @@ public String upload(ImageDirectory directory, Long ownerId, MultipartFile file) return s3Manager.uploadFile(file, path); } - public void delete(String imageUrl) { + public void delete(String imageUrl) throws ImageUploadException { + if(!s3Manager.exists(imageUrl)){ + return; + } s3Manager.deleteFile(imageUrl); } From 62b04f8605d3825997b3769a2c4d3a53fca86904 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 16:21:50 +0900 Subject: [PATCH 15/17] fix(member-management): fix package path --- .../domain/admin/service/AdminService.java | 8 +++---- .../cms/member/service/CMSMemberService.java | 24 +++++++------------ 2 files changed, 13 insertions(+), 19 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java index f173e62..be3dfda 100644 --- a/src/main/java/redot/redot_server/domain/admin/service/AdminService.java +++ b/src/main/java/redot/redot_server/domain/admin/service/AdminService.java @@ -17,10 +17,10 @@ import redot.redot_server.domain.admin.repository.AdminRepository; import redot.redot_server.domain.auth.exception.AuthErrorCode; import redot.redot_server.domain.auth.exception.AuthException; -import redot_redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot_redot_server.global.s3.event.ImageDeletionEvent; -import redot-redot_server.global.s3.service.ImageStorageService; -import redot-redot_server.global.s3.util.ImageDirectory; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.event.ImageDeletionEvent; +import redot.redot_server.global.s3.service.ImageStorageService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.EmailUtils; import redot.redot_server.global.util.dto.response.PageResponse; diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 6b392be..0762523 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -1,7 +1,6 @@ package redot.redot_server.domain.cms.member.service; import lombok.RequiredArgsConstructor; -import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,12 +20,12 @@ import redot.redot_server.domain.redot.app.exception.RedotAppErrorCode; import redot.redot_server.domain.redot.app.exception.RedotAppException; import redot.redot_server.domain.redot.app.repository.RedotAppRepository; -import redot_redot_server.global.s3.dto.UploadedImageUrlResponse; -import redot_redot_server.global.s3.event.ImageDeletionEvent; -import redot_redot_server.global.s3.service.ImageStorageService; -import redot_redot_server.global.s3.util.ImageDirectory; +import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.service.ImageStorageService; +import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.dto.response.PageResponse; + @Service @RequiredArgsConstructor @Transactional(readOnly = true) @@ -36,7 +35,6 @@ public class CMSMemberService { private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; private final ImageStorageService imageStorageService; - private final ApplicationEventPublisher eventPublisher; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -72,18 +70,14 @@ public CMSMemberResponse updateCMSMember(Long redotAppId, Long memberId, CMSMemb CMSMember cmsMember = cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) .orElseThrow(() -> new CMSMemberException(CMSMemberErrorCode.CMS_MEMBER_NOT_FOUND)); - deleteOldProfileImageUrlIfChanged(request, cmsMember); - - cmsMember.updateProfile(request.name(), request.profileImageUrl()); - - return CMSMemberResponse.fromEntity(redotAppId, cmsMember); - } - - private void deleteOldProfileImageUrlIfChanged(CMSMemberUpdateRequest request, CMSMember cmsMember) { String oldProfileImageUrl = cmsMember.getProfileImageUrl(); if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { - eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); + imageStorageService.delete(oldProfileImageUrl); } + + cmsMember.updateProfile(request.name(), request.profileImageUrl()); + + return CMSMemberResponse.fromEntity(redotAppId, cmsMember); } @Transactional From 0a61c3b063e87bde3f3db5d8f4fe215b44147408 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 16:29:38 +0900 Subject: [PATCH 16/17] feat(member-management): publish image deletion event on profile image update --- .../cms/member/service/CMSMemberService.java | 17 ++++++++++++----- 1 file changed, 12 insertions(+), 5 deletions(-) diff --git a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java index 0762523..3f72097 100644 --- a/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java +++ b/src/main/java/redot/redot_server/domain/cms/member/service/CMSMemberService.java @@ -1,6 +1,7 @@ package redot.redot_server.domain.cms.member.service; import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.security.crypto.password.PasswordEncoder; @@ -21,6 +22,7 @@ import redot.redot_server.domain.redot.app.exception.RedotAppException; import redot.redot_server.domain.redot.app.repository.RedotAppRepository; import redot.redot_server.global.s3.dto.UploadedImageUrlResponse; +import redot.redot_server.global.s3.event.ImageDeletionEvent; import redot.redot_server.global.s3.service.ImageStorageService; import redot.redot_server.global.s3.util.ImageDirectory; import redot.redot_server.global.util.dto.response.PageResponse; @@ -35,6 +37,7 @@ public class CMSMemberService { private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -70,16 +73,20 @@ public CMSMemberResponse updateCMSMember(Long redotAppId, Long memberId, CMSMemb CMSMember cmsMember = cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) .orElseThrow(() -> new CMSMemberException(CMSMemberErrorCode.CMS_MEMBER_NOT_FOUND)); - String oldProfileImageUrl = cmsMember.getProfileImageUrl(); - if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { - imageStorageService.delete(oldProfileImageUrl); - } - + deleteOldProfileImageUrlIfChanged(request, cmsMember); + cmsMember.updateProfile(request.name(), request.profileImageUrl()); return CMSMemberResponse.fromEntity(redotAppId, cmsMember); } + private void deleteOldProfileImageUrlIfChanged(CMSMemberUpdateRequest request, CMSMember cmsMember) { + String oldProfileImageUrl = cmsMember.getProfileImageUrl(); + if (oldProfileImageUrl != null && !oldProfileImageUrl.equals(request.profileImageUrl())) { + eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); + } + } + @Transactional public void deleteCMSMember(Long redotAppId, Long memberId) { CMSMember cmsMember = cmsMemberRepository.findByIdAndRedotApp_Id(memberId, redotAppId) From 4ae88c1c1fe21fcfbdea43add3ab21c6cbd33a20 Mon Sep 17 00:00:00 2001 From: Dohun Kim Date: Sun, 7 Dec 2025 16:35:25 +0900 Subject: [PATCH 17/17] feat(member-management): add delete endpoint for RedotMember and implement deletion logic --- .../redot/member/controller/RedotMemberController.java | 9 +++++++++ .../domain/redot/member/service/RedotMemberService.java | 7 +++++++ 2 files changed, 16 insertions(+) diff --git a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java index b9353df..c11a885 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java +++ b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java @@ -4,6 +4,7 @@ import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; @@ -42,4 +43,12 @@ public ResponseEntity uploadProfileImage( ) { return ResponseEntity.ok(redotMemberService.uploadProfileImage(jwtPrincipal.id(), image)); } + + @DeleteMapping + public ResponseEntity deleteRedotMember( + @AuthenticationPrincipal JwtPrincipal jwtPrincipal + ) { + redotMemberService.deleteRedotMember(jwtPrincipal.id()); + return ResponseEntity.noContent().build(); + } } diff --git a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java index b772cc4..d3c7a1e 100644 --- a/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java +++ b/src/main/java/redot/redot_server/domain/redot/member/service/RedotMemberService.java @@ -83,4 +83,11 @@ private void deleteOldProfileImageUrlIfChanged(RedotMemberUpdateRequest request, eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); } } + + @Transactional + public void deleteRedotMember(Long id) { + RedotMember redotMember = redotMemberRepository.findById(id) + .orElseThrow(() -> new AuthException(AuthErrorCode.REDOT_MEMBER_NOT_FOUND)); + redotMember.delete(); + } }