Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -23,12 +22,16 @@
public class EmailService {

private final VerificationCodeRedisRepository verificationCodeRedisRepository;
private final VerifiedEmailRepository verifiedEmailRepository;
private final VerifiedEmailRedisRepository verifiedEmailRedisRepository;
private final WebClient mailgunClient;

@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;
Expand Down Expand Up @@ -108,14 +111,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);
}

// 임시 비밀번호 발급 이메일 전송
Expand All @@ -132,8 +135,23 @@ 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) {
verifiedEmailRepository.deleteByEmail(email);
verifiedEmailRedisRepository.delete(email);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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("계정이 성공적으로 복구되었습니다.");
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}

Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
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
public interface UserRepository extends JpaRepository<User, Long> {
boolean existsByEmail(String email);
Optional<User> findByEmail(String email);
boolean existsByNickname(String nickname);
List<User> findByIsDeletedTrueAndStatusAndDeletedDateBefore(
UserStatus status,
LocalDateTime time
);
Optional<User> findByEmailAndIsDeletedTrue(String email);
}
Original file line number Diff line number Diff line change
@@ -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);
}
}
Original file line number Diff line number Diff line change
@@ -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<User> targets =
userRepository.findByIsDeletedTrueAndStatusAndDeletedDateBefore(UserStatus.DELETED, time);

userRepository.deleteAll(targets);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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)
);
}
}