From 8514da1b666d85245488e2720065fd8a4c1274af Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 15:20:31 +0900 Subject: [PATCH 1/4] =?UTF-8?q?refactor:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EC=99=84=EB=A3=8C=20=EC=83=81=ED=83=9C=20?= =?UTF-8?q?=EC=A0=80=EC=9E=A5=EC=86=8C=EB=A5=BC=20DB=EC=97=90=EC=84=9C=20R?= =?UTF-8?q?edis=EB=A1=9C=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../VerifiedEmailRedisRepository.java | 31 +++++++++++++++++++ .../domain/email/service/EmailService.java | 13 ++++---- 2 files changed, 37 insertions(+), 7 deletions(-) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java new file mode 100644 index 00000000..5a1bf0a2 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/repository/VerifiedEmailRedisRepository.java @@ -0,0 +1,31 @@ +package com.back.web7_9_codecrete_be.domain.email.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class VerifiedEmailRedisRepository { + + private final StringRedisTemplate redisTemplate; + private static final String PREFIX = "verified_email:"; + private static final long VERIFIED_TTL_SECONDS = 60 * 30; // 30분 + + public void save(String email) { + redisTemplate.opsForValue() + .set(PREFIX + email, "true", VERIFIED_TTL_SECONDS, TimeUnit.SECONDS); + } + + public boolean exists(String email) { + return Boolean.TRUE.equals( + redisTemplate.hasKey(PREFIX + email) + ); + } + + public void delete(String email) { + redisTemplate.delete(PREFIX + email); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java index 27af65b8..acd0cfeb 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java @@ -1,8 +1,7 @@ package com.back.web7_9_codecrete_be.domain.email.service; -import com.back.web7_9_codecrete_be.domain.email.entity.VerifiedEmail; import com.back.web7_9_codecrete_be.domain.email.repository.VerificationCodeRedisRepository; -import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRepository; +import com.back.web7_9_codecrete_be.domain.email.repository.VerifiedEmailRedisRepository; import com.back.web7_9_codecrete_be.global.error.code.MailErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import jakarta.transaction.Transactional; @@ -23,7 +22,7 @@ public class EmailService { private final VerificationCodeRedisRepository verificationCodeRedisRepository; - private final VerifiedEmailRepository verifiedEmailRepository; + private final VerifiedEmailRedisRepository verifiedEmailRedisRepository; private final WebClient mailgunClient; @Value("${mailgun.from}") @@ -108,14 +107,14 @@ public void verifyCode(String email, String inputCode) { // 성공 시 Redis에서 삭제 verificationCodeRedisRepository.deleteByEmail(email); - // 인증 완료 상태 저장 (DB) - verifiedEmailRepository.save(new VerifiedEmail(email)); + // 인증 완료 상태 저장 (TTL 30분) + verifiedEmailRedisRepository.save(email); log.info("[이메일 인증 성공] {}", email); } public boolean isVerified(String email) { - return verifiedEmailRepository.existsByEmail(email); + return verifiedEmailRedisRepository.exists(email); } // 임시 비밀번호 발급 이메일 전송 @@ -134,6 +133,6 @@ public void sendNewPassword(String email, String newPassword) { @Transactional public void clearVerifiedEmail(String email) { - verifiedEmailRepository.deleteByEmail(email); + verifiedEmailRedisRepository.delete(email); } } From a94a492c1f3c39bf1aa9b2f31b65af564991aeda Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 16:44:42 +0900 Subject: [PATCH 2/4] =?UTF-8?q?feat:=20=EC=86=8C=ED=94=84=ED=8A=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=EB=90=9C=20=EA=B3=84=EC=A0=95=EC=9D=84=20?= =?UTF-8?q?=EC=8A=A4=EC=BC=80=EC=A4=84=EB=9F=AC=EB=A1=9C=20=ED=95=98?= =?UTF-8?q?=EB=93=9C=20=EC=82=AD=EC=A0=9C=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../users/service/UserCleanupService.java | 26 +++++++++++++++++++ .../scheduler/UserCleanupScheduler.java | 23 ++++++++++++++++ 2 files changed, 49 insertions(+) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java create mode 100644 src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java new file mode 100644 index 00000000..2ae5e551 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserCleanupService.java @@ -0,0 +1,26 @@ +package com.back.web7_9_codecrete_be.domain.users.service; + +import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; + +@Service +@RequiredArgsConstructor +public class UserCleanupService { + + private final UserRepository userRepository; + + @Transactional + public void cleanup(LocalDateTime time) { + List targets = + userRepository.findByIsDeletedTrueAndStatusAndDeletedDateBefore(UserStatus.DELETED, time); + + userRepository.deleteAll(targets); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java b/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java new file mode 100644 index 00000000..15b512e7 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/global/scheduler/UserCleanupScheduler.java @@ -0,0 +1,23 @@ +package com.back.web7_9_codecrete_be.global.scheduler; + +import com.back.web7_9_codecrete_be.domain.users.service.UserCleanupService; +import lombok.RequiredArgsConstructor; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.LocalDateTime; + +@Component +@RequiredArgsConstructor +public class UserCleanupScheduler { + + private final UserCleanupService userCleanupService; + + // 매일 새벽 3시 실행 + @Scheduled(cron = "0 0 3 * * ?") + public void run() { + userCleanupService.cleanup( + LocalDateTime.now().minusDays(30) + ); + } +} From 1c16a8bf9faf8f980d6e7af8ee7882e1161f615b Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 16:46:38 +0900 Subject: [PATCH 3/4] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/error/code/UserErrorCode.java | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java index 64e19fcc..e3ccd2d6 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java +++ b/src/main/java/com/back/web7_9_codecrete_be/global/error/code/UserErrorCode.java @@ -9,15 +9,18 @@ public enum UserErrorCode implements ErrorCode { // 1xx - User 상태 / 중복 - NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-101", "이미 사용 중인 닉네임입니다."), - USER_DELETED(HttpStatus.FORBIDDEN, "U-102", "탈퇴한 사용자입니다."), - USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-103", "사용자를 찾을 수 없습니다."), + NICKNAME_DUPLICATED(HttpStatus.CONFLICT, "U-100", "이미 사용 중인 닉네임입니다."), + USER_DELETED(HttpStatus.FORBIDDEN, "U-101", "탈퇴한 사용자입니다."), + USER_NOT_FOUND(HttpStatus.NOT_FOUND, "U-102", "사용자를 찾을 수 없습니다."), + USER_RESTORE_EXPIRED(HttpStatus.BAD_REQUEST, "U-103", "계정 복구 가능 기간이 만료되었습니다."), + USER_NOT_DELETED(HttpStatus.BAD_REQUEST, "U-104", "탈퇴 상태의 계정만 복구할 수 있습니다."), + INVALID_RESTORE_TOKEN(HttpStatus.BAD_REQUEST, "U-105", "유효하지 않거나 만료된 복구 링크입니다."), // 3xx - 입력값 / 파일 - INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-301", "유효하지 않은 프로필 이미지입니다."), + INVALID_PROFILE_IMAGE(HttpStatus.BAD_REQUEST, "U-110", "유효하지 않은 프로필 이미지입니다."), // 2xx - 인증 / 비밀번호 - INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-201", "현재 비밀번호가 일치하지 않습니다."); + INVALID_PASSWORD(HttpStatus.BAD_REQUEST, "U-120", "현재 비밀번호가 일치하지 않습니다."); private final HttpStatus status; private final String code; From 75bc7482103ac3b5a495b5b41e89e2d5b4ec9c3d Mon Sep 17 00:00:00 2001 From: larama-C Date: Mon, 15 Dec 2025 16:47:42 +0900 Subject: [PATCH 4/4] =?UTF-8?q?feat:=20=EA=B3=84=EC=A0=95=20=EB=B3=B5?= =?UTF-8?q?=EA=B5=AC=20url=EB=A7=81=ED=81=AC=EB=A5=BC=20=ED=86=B5=ED=95=9C?= =?UTF-8?q?=20=EA=B5=AC=ED=98=84(=EC=9E=84=EC=8B=9C)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/email/service/EmailService.java | 19 +++++++ .../users/controller/UserController.java | 14 ++++++ .../domain/users/entity/User.java | 6 +++ .../users/repository/UserRepository.java | 8 +++ .../UserRestoreTokenRedisRepository.java | 29 +++++++++++ .../domain/users/service/UserService.java | 49 +++++++++++++++++++ 6 files changed, 125 insertions(+) create mode 100644 src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java index acd0cfeb..fb871bbb 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/email/service/EmailService.java @@ -28,6 +28,10 @@ public class EmailService { @Value("${mailgun.from}") private String fromEmail; + // 임시 복구 링크 기본 URL + // TODO: 프론트 도메인 확정 시 application.yml로 분리 예정 + private String restoreBaseUrl = "https://example.com/users/restore"; + private static final String CHAR_SET = "ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"; private static final int CODE_LENGTH = 6; private static final int TTL_SECONDS = 300; @@ -131,6 +135,21 @@ public void sendNewPassword(String email, String newPassword) { sendEmail(email, "[NCB] 임시 비밀번호 안내", content); } + public void sendRestoreLink(String email, String token) { + String link = restoreBaseUrl + "?token=" + token; + + String content = """ + 안녕하세요. NCB입니다. + + 아래 링크를 클릭하시면 계정 복구가 완료됩니다. + (링크는 15분간 유효합니다.) + + %s + """.formatted(link); + + sendEmail(email, "[NCB] 계정 복구 안내", content); + } + @Transactional public void clearVerifiedEmail(String email) { verifiedEmailRedisRepository.delete(email); diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java index cff14328..e2d9ebdd 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/controller/UserController.java @@ -73,4 +73,18 @@ public RsData deleteMyAccount() { return RsData.success("회원 탈퇴가 완료되었습니다."); } + + @Operation(summary = "계정 복구 링크 발송", description = "이메일로 계정 복구 링크를 발송합니다.") + @PostMapping("/restore/request") + public RsData requestRestore(@RequestParam String email) { + userService.sendRestoreLink(email); + return RsData.success("계정 복구 링크가 이메일로 발송되었습니다."); + } + + @Operation(summary = "계정 복구 (복구 링크)", description = "이메일로 받은 복구 링크를 통해 계정을 복구합니다.") + @GetMapping("/restore/confirm") + public RsData restoreByToken(@RequestParam String token) { + userService.restoreByToken(token); + return RsData.success("계정이 성공적으로 복구되었습니다."); + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java index 91ac3a93..1b218e85 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/entity/User.java @@ -100,5 +100,11 @@ public void softDelete() { public void promoteToAdmin() { this.role = Role.ADMIN; } + + public void restore() { + this.isDeleted = false; + this.status = UserStatus.ACTIVE; + this.deletedDate = null; + } } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java index ea472801..ea2029ec 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRepository.java @@ -1,9 +1,12 @@ package com.back.web7_9_codecrete_be.domain.users.repository; import com.back.web7_9_codecrete_be.domain.users.entity.User; +import com.back.web7_9_codecrete_be.domain.users.entity.UserStatus; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.time.LocalDateTime; +import java.util.List; import java.util.Optional; @Repository @@ -11,4 +14,9 @@ public interface UserRepository extends JpaRepository { boolean existsByEmail(String email); Optional findByEmail(String email); boolean existsByNickname(String nickname); + List findByIsDeletedTrueAndStatusAndDeletedDateBefore( + UserStatus status, + LocalDateTime time + ); + Optional findByEmailAndIsDeletedTrue(String email); } diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java new file mode 100644 index 00000000..458793c8 --- /dev/null +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/repository/UserRestoreTokenRedisRepository.java @@ -0,0 +1,29 @@ +package com.back.web7_9_codecrete_be.domain.users.repository; + +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.StringRedisTemplate; +import org.springframework.stereotype.Repository; + +import java.util.concurrent.TimeUnit; + +@Repository +@RequiredArgsConstructor +public class UserRestoreTokenRedisRepository { + + private final StringRedisTemplate redisTemplate; + private static final String PREFIX = "user_restore:"; + private static final long TTL_MINUTES = 15; + + public void save(String token, String email) { + redisTemplate.opsForValue() + .set(PREFIX + token, email, TTL_MINUTES, TimeUnit.MINUTES); + } + + public String findEmailByToken(String token) { + return redisTemplate.opsForValue().get(PREFIX + token); + } + + public void delete(String token) { + redisTemplate.delete(PREFIX + token); + } +} diff --git a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java index a31ca739..eb61d124 100644 --- a/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java +++ b/src/main/java/com/back/web7_9_codecrete_be/domain/users/service/UserService.java @@ -2,11 +2,13 @@ import com.back.web7_9_codecrete_be.domain.auth.service.TokenService; +import com.back.web7_9_codecrete_be.domain.email.service.EmailService; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdateNicknameRequest; import com.back.web7_9_codecrete_be.domain.users.dto.request.UserUpdatePasswordRequest; import com.back.web7_9_codecrete_be.domain.users.dto.response.UserResponse; import com.back.web7_9_codecrete_be.domain.users.entity.User; import com.back.web7_9_codecrete_be.domain.users.repository.UserRepository; +import com.back.web7_9_codecrete_be.domain.users.repository.UserRestoreTokenRedisRepository; import com.back.web7_9_codecrete_be.global.error.code.UserErrorCode; import com.back.web7_9_codecrete_be.global.error.exception.BusinessException; import com.back.web7_9_codecrete_be.global.storage.FileStorageService; @@ -16,6 +18,9 @@ import org.springframework.transaction.annotation.Transactional; import org.springframework.web.multipart.MultipartFile; +import java.time.LocalDateTime; +import java.util.UUID; + @Service @RequiredArgsConstructor @Transactional @@ -24,6 +29,8 @@ public class UserService { private final FileStorageService fileStorageService; private final PasswordEncoder passwordEncoder; private final TokenService tokenService; + private final UserRestoreTokenRedisRepository userRestoreTokenRedisRepository; + private final EmailService emailService; // 내 정보 조회 @Transactional(readOnly = true) @@ -98,4 +105,46 @@ public void updatePassword(User user, UserUpdatePasswordRequest req) { // 비밀번호 변경 시 로그아웃 처리 tokenService.removeTokens(user); } + + public void sendRestoreLink(String email) { + User user = userRepository.findByEmail(email) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + + if (!user.getIsDeleted()) { + throw new BusinessException(UserErrorCode.USER_NOT_DELETED); + } + + if (user.getDeletedDate() + .isBefore(LocalDateTime.now().minusDays(30))) { + throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED); + } + + String token = UUID.randomUUID().toString(); + userRestoreTokenRedisRepository.save(token, email); + + String restoreUrl = "https://frontend-domain.com/users/restore?token=" + token; + + // 이미 있는 메일 서비스 사용 + emailService.sendRestoreLink(email, restoreUrl); + } + + public void restoreByToken(String token) { + String email = userRestoreTokenRedisRepository.findEmailByToken(token); + + if (email == null) { + throw new BusinessException(UserErrorCode.INVALID_RESTORE_TOKEN); + } + + User user = userRepository.findByEmailAndIsDeletedTrue(email) + .orElseThrow(() -> new BusinessException(UserErrorCode.USER_NOT_FOUND)); + + if (user.getDeletedDate() + .isBefore(LocalDateTime.now().minusDays(30))) { + throw new BusinessException(UserErrorCode.USER_RESTORE_EXPIRED); + } + + user.restore(); + userRestoreTokenRedisRepository.delete(token); + } + }