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 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/domain/admin/controller/AdminController.java b/src/main/java/redot/redot_server/domain/admin/controller/AdminController.java index 9baeb41..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 @@ -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("/upload-profile-image") + public ResponseEntity uploadProfileImage( + @AuthenticationPrincipal JwtPrincipal jwtPrincipal, + @RequestPart("image") @NotNull MultipartFile image + ) { + return ResponseEntity.ok(adminService.uploadProfileImage(jwtPrincipal.id(), 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..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 @@ -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; @@ -9,14 +10,19 @@ 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.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; @Service @RequiredArgsConstructor @@ -24,6 +30,8 @@ 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) { @@ -70,6 +78,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); @@ -78,6 +88,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())) { + eventPublisher.publishEvent(new ImageDeletionEvent(oldProfileImageUrl)); + } + } + @Transactional public void resetAdminPassword(Long adminId, AdminResetPasswordRequest request) { Admin admin = adminRepository.findById(adminId) @@ -101,4 +118,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 = imageStorageService.upload(ImageDirectory.ADMIN_PROFILE, adminId, imageFile); + return new UploadedImageUrlResponse(imageUrl); + } } 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/controller/CMSMemberController.java b/src/main/java/redot/redot_server/domain/cms/member/controller/CMSMemberController.java index fdc4aeb..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 @@ -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,20 +19,23 @@ 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.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.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; @RestController @RequiredArgsConstructor @@ -103,4 +107,13 @@ public ResponseEntity deleteCurrentCMSMember(HttpServletRequest request, .build(); } + @PostMapping("/upload-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..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,26 +1,33 @@ 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; 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.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 @RequiredArgsConstructor @Transactional(readOnly = true) @@ -29,6 +36,8 @@ public class CMSMemberService { private final CMSMemberRepository cmsMemberRepository; private final RedotAppRepository redotAppRepository; private final PasswordEncoder passwordEncoder; + private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public CMSMemberResponse createCMSMember(Long redotAppId, CMSMemberCreateRequest request) { @@ -64,11 +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)); + 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) @@ -86,4 +104,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 = 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 e37ac80..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,22 +3,24 @@ 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; +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.util.S3Manager; +import redot.redot_server.global.s3.event.ImageDeletionEvent; +import redot.redot_server.global.s3.service.ImageStorageService; +import redot.redot_server.global.s3.util.ImageDirectory; @Service @RequiredArgsConstructor @@ -27,7 +29,8 @@ public class SiteSettingService { private final SiteSettingRepository siteSettingRepository; private final DomainRepository domainRepository; - private final S3Manager s3Manager; + private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public SiteSettingResponse updateSiteSetting(Long redotAppId, SiteSettingUpdateRequest request) { @@ -36,29 +39,34 @@ 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())) { + eventPublisher.publishEvent(new ImageDeletionEvent(oldLogoUrl)); + } + } + public UploadedImageUrlResponse uploadLogoImage(Long redotAppId, MultipartFile logoFile) { if (logoFile == null || logoFile.isEmpty()) { throw new SiteSettingException(SiteSettingErrorCode.LOGO_FILE_REQUIRED); } - String logoUrl = s3Manager.uploadFile( - logoFile, - LogoPathGenerator.generateLogoPath(redotAppId, logoFile.getOriginalFilename()) - ); + String logoUrl = imageStorageService.upload(ImageDirectory.APP_LOGO, redotAppId, logoFile); return new UploadedImageUrlResponse(logoUrl); } @@ -78,15 +86,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)) { - s3Manager.deleteFile(currentLogoUrl); - } - } public SiteSettingResponse getSiteSetting(Long redotAppId) { SiteSetting siteSetting = siteSettingRepository.findByRedotAppId(redotAppId) 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..c11a885 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/member/controller/RedotMemberController.java @@ -0,0 +1,54 @@ +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.DeleteMapping; +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; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/redot/members") +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, + @RequestPart("image") @NotNull MultipartFile image + ) { + 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/dto/RedotMemberUpdateRequest.java b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java new file mode 100644 index 0000000..9a81617 --- /dev/null +++ b/src/main/java/redot/redot_server/domain/redot/member/dto/RedotMemberUpdateRequest.java @@ -0,0 +1,10 @@ +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/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 21f76f8..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 @@ -2,11 +2,21 @@ 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; +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; +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; import redot.redot_server.global.util.EmailUtils; @@ -16,6 +26,8 @@ public class RedotMemberService { private final RedotMemberRepository redotMemberRepository; + private final ImageStorageService imageStorageService; + private final ApplicationEventPublisher eventPublisher; @Transactional public RedotMember findOrCreateSocialMember(SocialProfile profile, SocialProvider provider) { @@ -44,4 +56,38 @@ 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 = imageStorageService.upload(ImageDirectory.REDOT_MEMBER_PROFILE, memberId, imageFile); + 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)); + + 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())) { + 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(); + } } 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/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/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/exception/ImageErrorCode.java b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java new file mode 100644 index 0000000..7934406 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/exception/ImageErrorCode.java @@ -0,0 +1,20 @@ +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, "이미지를 저장할 대상을 찾을 수 없습니다."), + IMAGE_TOO_LARGE(400, 5204, "허용된 크기를 초과한 이미지입니다."), + UNSUPPORTED_IMAGE_TYPE(400, 5205, "지원하지 않는 이미지 형식입니다."); + + 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/ImageStorageService.java b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java new file mode 100644 index 0000000..627cdc2 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/service/ImageStorageService.java @@ -0,0 +1,77 @@ +package redot.redot_server.global.s3.service; + +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; +import redot.redot_server.global.s3.util.ImageMimeDetector; +import redot.redot_server.global.s3.util.ImagePathGenerator; +import redot.redot_server.global.s3.util.S3Manager; + +@Service +@RequiredArgsConstructor +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); + + String path = ImagePathGenerator.generate(directory, ownerId, file.getOriginalFilename()); + return s3Manager.uploadFile(file, path); + } + + public void delete(String imageUrl) throws ImageUploadException { + if(!s3Manager.exists(imageUrl)){ + return; + } + 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); + } + + 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) { + 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); + } +} 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..44f8533 --- /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("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)); + + 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/ImageMimeDetector.java b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java new file mode 100644 index 0000000..54e4cb3 --- /dev/null +++ b/src/main/java/redot/redot_server/global/s3/util/ImageMimeDetector.java @@ -0,0 +1,89 @@ +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)) { + 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) { + if (header.length < 12) { + return false; + } + byte[] type = new byte[8]; + System.arraycopy(header, 4, type, 0, 8); + return matches(type, HEIC) || matches(type, 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("