diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d5a6a932..c19616ba 100644 --- a/.github/workflows/cicd.yml +++ b/.github/workflows/cicd.yml @@ -17,6 +17,17 @@ jobs: name: Test runs-on: ubuntu-latest + services: + redis: + image: redis:7-alpine + ports: + - 6379:6379 + options: >- + --health-cmd "redis-cli ping" + --health-interval 10s + --health-timeout 5s + --health-retries 5 + steps: - name: Checkout uses: actions/checkout@v4 diff --git a/src/main/java/com/project/dorumdorum/domain/calendar/application/dto/response/CalendarEventResponse.java b/src/main/java/com/project/dorumdorum/domain/calendar/application/dto/response/CalendarEventResponse.java index 4f67c14a..8a4ca6c6 100644 --- a/src/main/java/com/project/dorumdorum/domain/calendar/application/dto/response/CalendarEventResponse.java +++ b/src/main/java/com/project/dorumdorum/domain/calendar/application/dto/response/CalendarEventResponse.java @@ -1,9 +1,16 @@ package com.project.dorumdorum.domain.calendar.application.dto.response; +import com.fasterxml.jackson.annotation.JsonFormat; +import com.project.dorumdorum.domain.calendar.domain.entity.CalendarEventType; + import java.time.LocalDate; +import java.time.LocalTime; public record CalendarEventResponse( LocalDate date, - String title + String title, + String content, + @JsonFormat(pattern = "HH:mm") LocalTime time, + CalendarEventType type ) { } diff --git a/src/main/java/com/project/dorumdorum/domain/calendar/application/mapper/CalendarEventMapper.java b/src/main/java/com/project/dorumdorum/domain/calendar/application/mapper/CalendarEventMapper.java index acdcb87c..33b60968 100644 --- a/src/main/java/com/project/dorumdorum/domain/calendar/application/mapper/CalendarEventMapper.java +++ b/src/main/java/com/project/dorumdorum/domain/calendar/application/mapper/CalendarEventMapper.java @@ -10,6 +10,8 @@ @Mapper(componentModel = "spring") public interface CalendarEventMapper { @Mapping(target = "date", source = "eventDate") + @Mapping(target = "time", source = "eventTime") + @Mapping(target = "type", source = "eventType") CalendarEventResponse toResponse(CalendarEvent event); List toResponseList(List events); } diff --git a/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEvent.java b/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEvent.java index 5f352e2a..ea7e5d9d 100644 --- a/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEvent.java +++ b/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEvent.java @@ -8,7 +8,12 @@ import jakarta.persistence.Table; import lombok.*; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; + +import javax.annotation.Nullable; import java.time.LocalDate; +import java.time.LocalTime; @Entity @Getter @@ -27,4 +32,14 @@ public class CalendarEvent extends BaseEntity { @Column(nullable = false) private String title; + + @Column(nullable = false) + private String content; + + @Column(nullable = false) + private LocalTime eventTime; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private CalendarEventType eventType; } diff --git a/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEventType.java b/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEventType.java new file mode 100644 index 00000000..ed0674e1 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEventType.java @@ -0,0 +1,8 @@ +package com.project.dorumdorum.domain.calendar.domain.entity; + +public enum CalendarEventType { + CHECK, // 점호 + CLEAN, // 청소 + NOTICE, // 공지 + EVENT // 행사 +} diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java index da9c00cd..ad6934a9 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/service/RoomService.java @@ -57,8 +57,7 @@ public List searchByCursor( } public FindRoomsResponse findMyRoom(String userNo) { - return roomRepository.findMyRoom(userNo) - .orElseThrow(() -> new RestApiException(ROOM_NOT_FOUND)); + return roomRepository.findMyRoom(userNo).orElse(null); } public List findLikedRooms(String userNo) { diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/ResetPasswordRequest.java b/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/ResetPasswordRequest.java new file mode 100644 index 00000000..2cb9bea8 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/ResetPasswordRequest.java @@ -0,0 +1,16 @@ +package com.project.dorumdorum.domain.user.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; + +public record ResetPasswordRequest( + @Email @NotBlank String email, + @NotBlank @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") @Pattern(regexp = "^[A-Za-z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]+$", message = "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.") String newPassword, + @NotBlank String newPasswordCheck +) { + public boolean isPasswordMatch() { + return newPassword.equals(newPasswordCheck); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/SignUpRequest.java b/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/SignUpRequest.java index 16bd3290..8bf199f6 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/SignUpRequest.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/SignUpRequest.java @@ -5,6 +5,8 @@ import jakarta.validation.constraints.Email; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; import java.time.LocalDate; import java.time.format.DateTimeFormatter; @@ -15,7 +17,7 @@ public record SignUpRequest( @NotBlank String name, @NotBlank String nickname, @Email @NotBlank String email, - @NotBlank String password, + @NotBlank @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") @Pattern(regexp = "^[A-Za-z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]+$", message = "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.") String password, @NotBlank String passwordCheck, @NotNull Gender gender, @NotBlank String studentNo, diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/ResetPasswordUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/ResetPasswordUseCase.java new file mode 100644 index 00000000..d9b37f12 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/ResetPasswordUseCase.java @@ -0,0 +1,44 @@ +package com.project.dorumdorum.domain.user.application.usecase; + +import com.project.dorumdorum.domain.user.application.dto.request.ResetPasswordRequest; +import com.project.dorumdorum.domain.user.domain.entity.User; +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetVerifiedRepository; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.FAILED_EMAIL_VERIFICATION; +import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus._PASSWORD_NOT_MATCHES; + +@Service +@RequiredArgsConstructor +public class ResetPasswordUseCase { + + private final UserService userService; + private final PasswordResetVerifiedRepository passwordResetVerifiedRepository; + private final PasswordEncoder passwordEncoder; + + /** + * 비밀번호 재설정 + * - 비밀번호 일치 여부 확인 + * - 이메일 인증 완료 여부 확인 + * - 비밀번호 업데이트 + */ + @Transactional + public void execute(ResetPasswordRequest request) { + if (!request.isPasswordMatch()) { + throw new RestApiException(_PASSWORD_NOT_MATCHES); + } + + if (!passwordResetVerifiedRepository.existsByEmail(request.email())) { + throw new RestApiException(FAILED_EMAIL_VERIFICATION); + } + + User user = userService.findByEmail(request.email()); + user.updatePassword(passwordEncoder.encode(request.newPassword())); + passwordResetVerifiedRepository.delete(request.email()); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendPasswordResetEmailUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendPasswordResetEmailUseCase.java new file mode 100644 index 00000000..896bf0d2 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendPasswordResetEmailUseCase.java @@ -0,0 +1,39 @@ +package com.project.dorumdorum.domain.user.application.usecase; + +import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.ratelimit.RateLimited; +import com.project.dorumdorum.global.util.SecureRandomGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.INVALID_EMAIL_DOMAIN; + +@Service +@RequiredArgsConstructor +public class SendPasswordResetEmailUseCase { + + private final UserService userService; + private final EmailVerificationService emailVerificationService; + private final SecureRandomGenerator secureRandomGenerator; + + /** + * 비밀번호 재설정 인증 코드 발송 + * - 허용된 대학 이메일 도메인인지 검증 + * - 가입된 이메일인 경우에만 실제로 코드를 발송 (미가입 이메일도 동일한 200 응답 반환) + */ + @RateLimited(tag = "password-reset-email", key = "#email") + public void send(String email) { + if (!emailVerificationService.isAllowedUniversityEmail(email)) { + throw new RestApiException(INVALID_EMAIL_DOMAIN); + } + + if (!userService.isAlreadyRegistered(email)) { + return; + } + + String code = secureRandomGenerator.generate(); + emailVerificationService.sendCode(email, code); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java index 08d23085..835c85a6 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendVerificationEmailUseCase.java @@ -3,6 +3,7 @@ import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; import com.project.dorumdorum.domain.user.domain.service.UserService; import com.project.dorumdorum.global.exception.RestApiException; +import com.project.dorumdorum.global.ratelimit.RateLimited; import com.project.dorumdorum.global.util.SecureRandomGenerator; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -24,6 +25,7 @@ public class SendVerificationEmailUseCase { * - 허용된 대학 이메일 도메인인지 검증 * - 인증 코드를 생성해 메일로 발송 */ + @RateLimited(tag = "verification-email", key = "#email") public void send(String email) { if (userService.isAlreadyRegistered(email)) { throw new RestApiException(DUPLICATE_EMAIL); diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java index 8a0c82ff..83a61cb7 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/SignUpUseCase.java @@ -2,6 +2,7 @@ import com.project.dorumdorum.domain.user.application.dto.request.SignUpRequest; import com.project.dorumdorum.domain.user.domain.entity.User; +import com.project.dorumdorum.domain.user.domain.repository.EmailVerifiedRepository; import com.project.dorumdorum.domain.user.domain.service.UserService; import com.project.dorumdorum.global.exception.RestApiException; import lombok.RequiredArgsConstructor; @@ -9,6 +10,7 @@ import org.springframework.transaction.annotation.Transactional; import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.DUPLICATE_SIGN_UP_INFO; +import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.EMAIL_NOT_VERIFIED; import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus._PASSWORD_NOT_MATCHES; @Service @@ -17,15 +19,21 @@ public class SignUpUseCase { private final UserService userService; + private final EmailVerifiedRepository emailVerifiedRepository; /** * 회원가입 처리 + * - 이메일 인증 완료 여부를 검증 * - 비밀번호 확인 여부를 검증 * - 중복 이메일 가입을 차단 * - 사용자를 저장하고 생성된 사용자 번호를 반환 */ public String execute(SignUpRequest request) { - if(!request.isCheckedPassword()) { + if (!emailVerifiedRepository.existsByEmail(request.email())) { + throw new RestApiException(EMAIL_NOT_VERIFIED); + } + + if (!request.isCheckedPassword()) { throw new RestApiException(_PASSWORD_NOT_MATCHES); } @@ -35,6 +43,7 @@ public String execute(SignUpRequest request) { } User savedUser = userService.save(request); + emailVerifiedRepository.delete(request.email()); return savedUser.getUserNo(); } } diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java index 4369bde2..bbf8ab5d 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyEmailUseCase.java @@ -1,5 +1,6 @@ package com.project.dorumdorum.domain.user.application.usecase; +import com.project.dorumdorum.domain.user.domain.repository.EmailVerifiedRepository; import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -9,13 +10,15 @@ public class VerifyEmailUseCase { private final EmailVerificationService emailVerificationService; + private final EmailVerifiedRepository emailVerifiedRepository; /** * 이메일 인증 코드 검증 * - 이메일과 인증 코드의 일치 여부를 확인 - * - 유효하지 않으면 예외를 발생 + * - 검증 성공 시 회원가입 가능 상태를 Redis에 저장 */ public void execute(String email, String code) { emailVerificationService.verifyCode(email, code); + emailVerifiedRepository.save(email); } } diff --git a/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyPasswordResetCodeUseCase.java b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyPasswordResetCodeUseCase.java new file mode 100644 index 00000000..6ab9f507 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyPasswordResetCodeUseCase.java @@ -0,0 +1,24 @@ +package com.project.dorumdorum.domain.user.application.usecase; + +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetVerifiedRepository; +import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +@Service +@RequiredArgsConstructor +public class VerifyPasswordResetCodeUseCase { + + private final PasswordResetVerifiedRepository passwordResetVerifiedRepository; + private final EmailVerificationService emailVerificationService; + + /** + * 비밀번호 재설정 인증 코드 검증 + * - 인증 코드 일치 여부 확인 + * - 검증 성공 시 비밀번호 재설정 가능 상태를 Redis에 저장 + */ + public void execute(String email, String code) { + emailVerificationService.verifyCode(email, code); + passwordResetVerifiedRepository.save(email); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java b/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java index 7e790a59..8aac584e 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java @@ -67,4 +67,8 @@ public void updateFirebaseToken(String firebaseToken) { this.firebaseToken = firebaseToken; } + public void updatePassword(String encodedPassword) { + this.password = encodedPassword; + } + } diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/repository/EmailVerifiedRepository.java b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/EmailVerifiedRepository.java new file mode 100644 index 00000000..d1b79f33 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/EmailVerifiedRepository.java @@ -0,0 +1,8 @@ +package com.project.dorumdorum.domain.user.domain.repository; + +public interface EmailVerifiedRepository { + + void save(String email); + boolean existsByEmail(String email); + void delete(String email); +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetCodeRepository.java b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetCodeRepository.java new file mode 100644 index 00000000..35feb420 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetCodeRepository.java @@ -0,0 +1,10 @@ +package com.project.dorumdorum.domain.user.domain.repository; + +import java.util.Optional; + +public interface PasswordResetCodeRepository { + + void save(String email, String code); + Optional findByEmail(String email); + void delete(String email); +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetVerifiedRepository.java b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetVerifiedRepository.java new file mode 100644 index 00000000..0846b40f --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetVerifiedRepository.java @@ -0,0 +1,8 @@ +package com.project.dorumdorum.domain.user.domain.repository; + +public interface PasswordResetVerifiedRepository { + + void save(String email); + boolean existsByEmail(String email); + void delete(String email); +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisEmailVerifiedRepository.java b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisEmailVerifiedRepository.java new file mode 100644 index 00000000..d98654bb --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisEmailVerifiedRepository.java @@ -0,0 +1,32 @@ +package com.project.dorumdorum.domain.user.infra.repository; + +import com.project.dorumdorum.domain.user.domain.repository.EmailVerifiedRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class RedisEmailVerifiedRepository implements EmailVerifiedRepository { + + private final RedisTemplate redisTemplate; + private static final String EMAIL_VERIFIED = "EMAIL_VERIFIED:"; + private static final String VERIFIED_VALUE = "verified"; + + @Override + public void save(String email) { + redisTemplate.opsForValue().set(EMAIL_VERIFIED + email, VERIFIED_VALUE, Duration.ofMinutes(30)); + } + + @Override + public boolean existsByEmail(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(EMAIL_VERIFIED + email)); + } + + @Override + public void delete(String email) { + redisTemplate.delete(EMAIL_VERIFIED + email); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetCodeRepository.java b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetCodeRepository.java new file mode 100644 index 00000000..1b8e6971 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetCodeRepository.java @@ -0,0 +1,32 @@ +package com.project.dorumdorum.domain.user.infra.repository; + +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetCodeRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; +import java.util.Optional; + +@Repository +@RequiredArgsConstructor +public class RedisPasswordResetCodeRepository implements PasswordResetCodeRepository { + + private final RedisTemplate redisTemplate; + private static final String PASSWORD_RESET = "PASSWORD_RESET:"; + + @Override + public void save(String email, String code) { + redisTemplate.opsForValue().set(PASSWORD_RESET + email, code, Duration.ofMinutes(10)); + } + + @Override + public Optional findByEmail(String email) { + return Optional.ofNullable(redisTemplate.opsForValue().get(PASSWORD_RESET + email)); + } + + @Override + public void delete(String email) { + redisTemplate.delete(PASSWORD_RESET + email); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetVerifiedRepository.java b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetVerifiedRepository.java new file mode 100644 index 00000000..42371864 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetVerifiedRepository.java @@ -0,0 +1,32 @@ +package com.project.dorumdorum.domain.user.infra.repository; + +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetVerifiedRepository; +import lombok.RequiredArgsConstructor; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Repository; + +import java.time.Duration; + +@Repository +@RequiredArgsConstructor +public class RedisPasswordResetVerifiedRepository implements PasswordResetVerifiedRepository { + + private final RedisTemplate redisTemplate; + private static final String PASSWORD_RESET_VERIFIED = "PASSWORD_RESET_VERIFIED:"; + private static final String VERIFIED_VALUE = "verified"; + + @Override + public void save(String email) { + redisTemplate.opsForValue().set(PASSWORD_RESET_VERIFIED + email, VERIFIED_VALUE, Duration.ofMinutes(10)); + } + + @Override + public boolean existsByEmail(String email) { + return Boolean.TRUE.equals(redisTemplate.hasKey(PASSWORD_RESET_VERIFIED + email)); + } + + @Override + public void delete(String email) { + redisTemplate.delete(PASSWORD_RESET_VERIFIED + email); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/ResetPasswordController.java b/src/main/java/com/project/dorumdorum/domain/user/ui/ResetPasswordController.java new file mode 100644 index 00000000..38dc5e37 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/ResetPasswordController.java @@ -0,0 +1,25 @@ +package com.project.dorumdorum.domain.user.ui; + +import com.project.dorumdorum.domain.user.application.dto.request.ResetPasswordRequest; +import com.project.dorumdorum.domain.user.application.usecase.ResetPasswordUseCase; +import com.project.dorumdorum.domain.user.ui.spec.ResetPasswordApiSpec; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class ResetPasswordController implements ResetPasswordApiSpec { + + private final ResetPasswordUseCase resetPasswordUseCase; + + @Override + public ResponseEntity reset( + @RequestBody @Valid ResetPasswordRequest request + ) { + resetPasswordUseCase.execute(request); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java b/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java new file mode 100644 index 00000000..e74ba11b --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java @@ -0,0 +1,26 @@ +package com.project.dorumdorum.domain.user.ui; + +import com.project.dorumdorum.domain.user.application.usecase.SendPasswordResetEmailUseCase; +import com.project.dorumdorum.domain.user.ui.spec.SendPasswordResetEmailApiSpec; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +public class SendPasswordResetEmailController implements SendPasswordResetEmailApiSpec { + + private final SendPasswordResetEmailUseCase sendPasswordResetEmailUseCase; + + @Override + public ResponseEntity send( + @NotBlank @RequestParam String email + ) { + sendPasswordResetEmailUseCase.send(email); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/SendVerificationEmailController.java b/src/main/java/com/project/dorumdorum/domain/user/ui/SendVerificationEmailController.java index 0c575486..a6dc0074 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/ui/SendVerificationEmailController.java +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/SendVerificationEmailController.java @@ -2,11 +2,14 @@ import com.project.dorumdorum.domain.user.application.usecase.SendVerificationEmailUseCase; import com.project.dorumdorum.domain.user.ui.spec.SendVerificationEmailApiSpec; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Validated @RestController @RequiredArgsConstructor public class SendVerificationEmailController implements SendVerificationEmailApiSpec { @@ -15,7 +18,7 @@ public class SendVerificationEmailController implements SendVerificationEmailApi @Override public ResponseEntity send( - @RequestParam String email + @NotBlank @RequestParam String email ) { sendVerificationEmailUseCase.send(email); return ResponseEntity.ok().build(); diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyEmailController.java b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyEmailController.java index 87059632..ca21f6e2 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyEmailController.java +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyEmailController.java @@ -2,11 +2,14 @@ import com.project.dorumdorum.domain.user.application.usecase.VerifyEmailUseCase; import com.project.dorumdorum.domain.user.ui.spec.VerifyEmailApiSpec; +import jakarta.validation.constraints.NotBlank; import lombok.RequiredArgsConstructor; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +@Validated @RestController @RequiredArgsConstructor public class VerifyEmailController implements VerifyEmailApiSpec { @@ -15,8 +18,8 @@ public class VerifyEmailController implements VerifyEmailApiSpec { @Override public ResponseEntity verifyEmail( - @RequestParam String email, - @RequestParam String code + @NotBlank @RequestParam String email, + @NotBlank @RequestParam String code ) { verifyEmailUseCase.execute(email, code); return ResponseEntity.ok().build(); diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java new file mode 100644 index 00000000..d1a206df --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java @@ -0,0 +1,27 @@ +package com.project.dorumdorum.domain.user.ui; + +import com.project.dorumdorum.domain.user.application.usecase.VerifyPasswordResetCodeUseCase; +import com.project.dorumdorum.domain.user.ui.spec.VerifyPasswordResetCodeApiSpec; +import jakarta.validation.constraints.NotBlank; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@Validated +@RestController +@RequiredArgsConstructor +public class VerifyPasswordResetCodeController implements VerifyPasswordResetCodeApiSpec { + + private final VerifyPasswordResetCodeUseCase verifyPasswordResetCodeUseCase; + + @Override + public ResponseEntity verify( + @NotBlank @RequestParam String email, + @NotBlank @RequestParam String code + ) { + verifyPasswordResetCodeUseCase.execute(email, code); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/spec/ResetPasswordApiSpec.java b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/ResetPasswordApiSpec.java new file mode 100644 index 00000000..46276d5c --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/ResetPasswordApiSpec.java @@ -0,0 +1,21 @@ +package com.project.dorumdorum.domain.user.ui.spec; + +import com.project.dorumdorum.domain.user.application.dto.request.ResetPasswordRequest; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; + +@Tag(name = "User") +public interface ResetPasswordApiSpec { + + @Operation( + summary = "비밀번호 재설정 API", + description = "이메일 인증 완료 후 새로운 비밀번호로 변경합니다." + ) + @PostMapping("/api/users/password/reset") + ResponseEntity reset( + @RequestBody ResetPasswordRequest request + ); +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/spec/SendPasswordResetEmailApiSpec.java b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/SendPasswordResetEmailApiSpec.java new file mode 100644 index 00000000..d536e41c --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/SendPasswordResetEmailApiSpec.java @@ -0,0 +1,20 @@ +package com.project.dorumdorum.domain.user.ui.spec; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "User") +public interface SendPasswordResetEmailApiSpec { + + @Operation( + summary = "비밀번호 재설정 인증 이메일 발송 API", + description = "가입된 학교 이메일 주소로 비밀번호 재설정 인증 코드를 전송합니다." + ) + @PostMapping("/api/email/password-reset/send") + ResponseEntity send( + @Parameter(description = "비밀번호를 재설정할 사용자 이메일 주소") String email + ); +} diff --git a/src/main/java/com/project/dorumdorum/domain/user/ui/spec/VerifyPasswordResetCodeApiSpec.java b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/VerifyPasswordResetCodeApiSpec.java new file mode 100644 index 00000000..5907aff6 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/spec/VerifyPasswordResetCodeApiSpec.java @@ -0,0 +1,21 @@ +package com.project.dorumdorum.domain.user.ui.spec; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.tags.Tag; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; + +@Tag(name = "User") +public interface VerifyPasswordResetCodeApiSpec { + + @Operation( + summary = "비밀번호 재설정 인증 코드 검증 API", + description = "이메일로 받은 인증 코드를 검증합니다. 성공 시 비밀번호 재설정이 가능한 상태로 전환됩니다." + ) + @PostMapping("/api/email/password-reset/verify") + ResponseEntity verify( + @Parameter(description = "인증할 사용자 이메일") String email, + @Parameter(description = "이메일로 전송된 인증 코드") String code + ); +} diff --git a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java index 15284713..976b13ae 100644 --- a/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java +++ b/src/main/java/com/project/dorumdorum/global/alert/DiscordAlertSender.java @@ -28,8 +28,13 @@ public class DiscordAlertSender { private final HttpClient httpClient = HttpClient.newHttpClient(); public void send(SystemAlert alert) { - if (deduplicationService.isDuplicate(alert)) { - log.debug("[Alert] 중복 알림 건너뜀. title={}", alert.title()); + try { + if (deduplicationService.isDuplicate(alert)) { + log.debug("[Alert] 중복 알림 건너뜀. title={}", alert.title()); + return; + } + } catch (IllegalStateException e) { + log.warn("[Alert] Redis 비가용 상태로 중복 검사 건너뜀. title={}", alert.title()); return; } diff --git a/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java b/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java index 9c52f86f..a4b23d44 100644 --- a/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java +++ b/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java @@ -15,6 +15,7 @@ public enum UserErrorStatus implements BaseCodeInterface { EMAIL_NOT_FOUND(HttpStatus.BAD_REQUEST, "ROOM002", "가입되지 않은 이메일입니다."), AGE_PARSING_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "COMMON400", "나이 계산 파싱 오류입니다."), FAILED_SEND_VERIFY_CODE(HttpStatus.INTERNAL_SERVER_ERROR, "MAIL001", "인증번호 전송에 실패하였습니다."), + EMAIL_NOT_VERIFIED(HttpStatus.BAD_REQUEST, "USER002", "이메일 인증이 완료되지 않았습니다."), ; private final HttpStatus httpStatus; diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index ce445a36..66a8f9ad 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -13,7 +13,7 @@ spring: port: 6379 jpa: hibernate: - ddl-auto: validate + ddl-auto: update show-sql: true jwt: @@ -40,4 +40,4 @@ springdoc: firebase: service-account: - path: ${FIREBASE_SERVICE_ACCOUNT_PATH:src/main/resources/firebase/dorumdorum-9ce75-firebase-adminsdk-fbsvc-8d66c0dccb.json} + path: ${FIREBASE_SERVICE_ACCOUNT_PATH:secrets/firebase-service-account.json} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 8e9c973e..12cd74f2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -58,6 +58,12 @@ exclude-auth-path-patterns: method: POST - path-pattern: /api/email/verify method: POST + - path-pattern: /api/email/password-reset/send + method: POST + - path-pattern: /api/email/password-reset/verify + method: POST + - path-pattern: /api/users/password/reset + method: POST - path-pattern: /swagger-ui.html method: GET - path-pattern: /swagger-ui/index.html @@ -93,6 +99,12 @@ exclude-blacklist-path-patterns: method: POST - path-pattern: /api/email/verify method: POST + - path-pattern: /api/email/password-reset/send + method: POST + - path-pattern: /api/email/password-reset/verify + method: POST + - path-pattern: /api/users/password/reset + method: POST - path-pattern: /swagger-ui.html method: GET - path-pattern: /swagger-ui/index.html @@ -130,6 +142,12 @@ exclude-whitelist-path-patterns: method: POST - path-pattern: /api/email/verify method: POST + - path-pattern: /api/email/password-reset/send + method: POST + - path-pattern: /api/email/password-reset/verify + method: POST + - path-pattern: /api/users/password/reset + method: POST - path-pattern: /swagger-ui.html method: GET - path-pattern: /swagger-ui/index.html @@ -193,3 +211,11 @@ rate-limit: permits-per-window: ${CHAT_RATE_LIMIT_PER_WINDOW:30} window-millis: ${CHAT_RATE_LIMIT_WINDOW_MILLIS:10000} ttl-seconds: ${CHAT_RATE_LIMIT_TTL_SECONDS:20} + password-reset-email: + permits-per-window: 1 + window-millis: 30000 + ttl-seconds: 30 + verification-email: + permits-per-window: 1 + window-millis: 30000 + ttl-seconds: 30 diff --git a/src/test/java/com/project/dorumdorum/domain/calendar/unit/response/CalendarEventResponseTest.java b/src/test/java/com/project/dorumdorum/domain/calendar/unit/response/CalendarEventResponseTest.java index 49b06e3f..f24240ef 100644 --- a/src/test/java/com/project/dorumdorum/domain/calendar/unit/response/CalendarEventResponseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/calendar/unit/response/CalendarEventResponseTest.java @@ -14,7 +14,7 @@ class CalendarEventResponseTest { @Test void record_AccessorsReturnValues() { LocalDate date = LocalDate.of(2026, 2, 14); - CalendarEventResponse response = new CalendarEventResponse(date, "event"); + CalendarEventResponse response = new CalendarEventResponse(date, "event", null, null, null); assertThat(response.date()).isEqualTo(date); assertThat(response.title()).isEqualTo("event"); diff --git a/src/test/java/com/project/dorumdorum/domain/calendar/unit/ui/LoadCalendarEventsControllerTest.java b/src/test/java/com/project/dorumdorum/domain/calendar/unit/ui/LoadCalendarEventsControllerTest.java index befbe662..5e7cdc37 100644 --- a/src/test/java/com/project/dorumdorum/domain/calendar/unit/ui/LoadCalendarEventsControllerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/calendar/unit/ui/LoadCalendarEventsControllerTest.java @@ -29,7 +29,7 @@ class LoadCalendarEventsControllerTest { void loadCalendarEvents_ReturnsUseCaseResult() { LocalDate start = LocalDate.of(2026, 1, 1); LocalDate end = LocalDate.of(2026, 1, 31); - List payload = List.of(new CalendarEventResponse(start, "event")); + List payload = List.of(new CalendarEventResponse(start, "event", null, null, null)); when(loadCalendarEventsUseCase.execute(start, end)).thenReturn(payload); ResponseEntity> response = controller.loadCalendarEvents(start, end); diff --git a/src/test/java/com/project/dorumdorum/domain/calendar/unit/usecase/LoadCalendarEventsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/calendar/unit/usecase/LoadCalendarEventsUseCaseTest.java index 2dbae3c6..10ed4ecd 100644 --- a/src/test/java/com/project/dorumdorum/domain/calendar/unit/usecase/LoadCalendarEventsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/calendar/unit/usecase/LoadCalendarEventsUseCaseTest.java @@ -34,7 +34,7 @@ void execute_LoadsEventsAndMapsResponse() { List events = List.of( CalendarEvent.builder().eventNo("e1").eventDate(start).title("title").build() ); - List responses = List.of(new CalendarEventResponse(start, "title")); + List responses = List.of(new CalendarEventResponse(start, "title", null, null, null)); when(calendarEventService.loadBetween(start, end)).thenReturn(events); when(calendarEventMapper.toResponseList(events)).thenReturn(responses); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java index 4aa5939b..7f4441f4 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/service/RoomServiceTest.java @@ -80,9 +80,9 @@ void searchByCursor_DelegatesToRepository() { } @Test - @DisplayName("Should throw when my room is not found") - void findMyRoom_WhenMissing_Throws() { + @DisplayName("Should return null when my room is not found") + void findMyRoom_WhenMissing_ReturnsNull() { when(roomRepository.findMyRoom("u1")).thenReturn(Optional.empty()); - assertThatThrownBy(() -> service.findMyRoom("u1")).isInstanceOf(RestApiException.class); + assertThat(service.findMyRoom("u1")).isNull(); } } diff --git a/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/SignUpUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/SignUpUseCaseTest.java index 81e376e9..1be8a1ae 100644 --- a/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/SignUpUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/SignUpUseCaseTest.java @@ -3,6 +3,7 @@ import com.project.dorumdorum.domain.user.application.dto.request.SignUpRequest; import com.project.dorumdorum.domain.user.application.usecase.SignUpUseCase; import com.project.dorumdorum.domain.user.domain.entity.User; +import com.project.dorumdorum.domain.user.domain.repository.EmailVerifiedRepository; import com.project.dorumdorum.domain.user.domain.service.UserService; import com.project.dorumdorum.domain.user.fixture.RequestFixture; import com.project.dorumdorum.domain.user.fixture.UserFixture; @@ -28,6 +29,8 @@ class SignUpUseCaseTest { @Mock private UserService userService; @Mock + private EmailVerifiedRepository emailVerifiedRepository; + @Mock private DomainEventLogger domainEventLogger; @InjectMocks @@ -40,6 +43,7 @@ void execute_WithValidRequest_SavesUser() { SignUpRequest request = RequestFixture.createValidSignUpRequest(); User savedUser = UserFixture.createDefaultUser(); + when(emailVerifiedRepository.existsByEmail(request.email())).thenReturn(true); when(userService.isAlreadyRegistered(request.email())).thenReturn(false); when(userService.save(request)).thenReturn(savedUser); @@ -70,6 +74,7 @@ void execute_WithPasswordMismatch_ThrowsPasswordNotMatchesException() { void execute_WithAlreadyRegisteredEmail_ThrowsAlreadyRegisteredEmailException() { // Arrange SignUpRequest request = RequestFixture.createValidSignUpRequest(); + when(emailVerifiedRepository.existsByEmail(request.email())).thenReturn(true); when(userService.isAlreadyRegistered(request.email())).thenReturn(true); // Act & Assert @@ -87,6 +92,7 @@ void execute_WithValidRequest_CallsUserServiceInCorrectOrder() { SignUpRequest request = RequestFixture.createValidSignUpRequest(); User savedUser = UserFixture.createDefaultUser(); + when(emailVerifiedRepository.existsByEmail(request.email())).thenReturn(true); when(userService.isAlreadyRegistered(request.email())).thenReturn(false); when(userService.save(request)).thenReturn(savedUser); diff --git a/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/VerifyEmailUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/VerifyEmailUseCaseTest.java index 4357f65f..0dd03adb 100644 --- a/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/VerifyEmailUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/user/unit/usecase/VerifyEmailUseCaseTest.java @@ -1,6 +1,7 @@ package com.project.dorumdorum.domain.user.unit.usecase; import com.project.dorumdorum.domain.user.application.usecase.VerifyEmailUseCase; +import com.project.dorumdorum.domain.user.domain.repository.EmailVerifiedRepository; import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; import com.project.dorumdorum.global.logging.DomainEventLogger; import org.junit.jupiter.api.DisplayName; @@ -19,6 +20,8 @@ class VerifyEmailUseCaseTest { @Mock private EmailVerificationService emailVerificationService; @Mock + private EmailVerifiedRepository emailVerifiedRepository; + @Mock private DomainEventLogger domainEventLogger; @InjectMocks