Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
d4bfd39
chore(deploy): refine production deployment script to selectively res…
eraser502 Dec 6, 2025
07edfb4
refactor(image-upload): implement image upload service and related ut…
eraser502 Dec 7, 2025
512f2f8
feat(profile-image): add profile image upload functionality for admin…
eraser502 Dec 7, 2025
7ddcbcd
refactor(site-setting): streamline logo upload process by integrating…
eraser502 Dec 7, 2025
567e7b1
refactor(image-storage): replace ImageUploadService with ImageStorage…
eraser502 Dec 7, 2025
764312e
refactor(profile-image): standardize profile image upload endpoint ac…
eraser502 Dec 7, 2025
61848a5
refactor(image-directory): update profile image paths for admin and C…
eraser502 Dec 7, 2025
5af427f
feat(image-upload): enhance image validation with size and type checks
eraser502 Dec 7, 2025
90b2556
feat(image-upload): implement ImageMimeDetector for enhanced image ty…
eraser502 Dec 7, 2025
937a757
refactor(image-mime-detector): simplify HEIC detection by removing un…
eraser502 Dec 7, 2025
99b66d8
Feature / s3 / profile image upload(#108)
eraser502 Dec 7, 2025
4df2c67
feat(member-management): add update info endpoint for RedotMember
eraser502 Dec 7, 2025
9ea64ed
feat(member-management): implement profile image cleanup on update
eraser502 Dec 7, 2025
7f1552b
feat(member-management): add validation to RedotMemberUpdateRequest a…
eraser502 Dec 7, 2025
d8f4cc9
feat(member-management): implement image deletion event for profile i…
eraser502 Dec 7, 2025
62b04f8
fix(member-management): fix package path
eraser502 Dec 7, 2025
0a61c3b
feat(member-management): publish image deletion event on profile imag…
eraser502 Dec 7, 2025
4ae88c1
feat(member-management): add delete endpoint for RedotMember and impl…
eraser502 Dec 7, 2025
2f9525f
Feature / redot member management(#110)
eraser502 Dec 7, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 6 additions & 5 deletions .github/workflows/deploy-to-prod-server.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
3 changes: 2 additions & 1 deletion src/main/java/redot/redot_server/RedotServerApplication.java
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -78,4 +82,12 @@ public ResponseEntity<Void> deleteCurrentAdmin(HttpServletRequest request, @Auth
.header(HttpHeaders.SET_COOKIE, deleteRefresh.toString())
.build();
}

@PostMapping("/upload-profile-image")
public ResponseEntity<UploadedImageUrlResponse> uploadProfileImage(
@AuthenticationPrincipal JwtPrincipal jwtPrincipal,
@RequestPart("image") @NotNull MultipartFile image
) {
return ResponseEntity.ok(adminService.uploadProfileImage(jwtPrincipal.id(), image));
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -9,21 +10,28 @@
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
@Transactional(readOnly = true)
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) {
Expand Down Expand Up @@ -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);
Expand All @@ -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)
Expand All @@ -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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand Down Expand Up @@ -103,4 +107,13 @@ public ResponseEntity<Void> deleteCurrentCMSMember(HttpServletRequest request,
.build();
}

@PostMapping("/upload-profile-image")
public ResponseEntity<UploadedImageUrlResponse> uploadProfileImage(
@CurrentRedotApp Long redotAppId,
@AuthenticationPrincipal JwtPrincipal jwtPrincipal,
@RequestPart("image") @NotNull MultipartFile image
) {
return ResponseEntity.ok(cmsMemberService.uploadProfileImage(redotAppId, jwtPrincipal.id(), image));
}

}
Original file line number Diff line number Diff line change
@@ -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)
Expand All @@ -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) {
Expand Down Expand Up @@ -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)
Expand All @@ -86,4 +104,13 @@ public PageResponse<CMSMemberResponse> 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);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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) {
Expand All @@ -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);
}
Expand All @@ -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)
Expand Down
Loading