From e0b27a43963d4d52aaa50787ce8dd62f868d1f7d Mon Sep 17 00:00:00 2001 From: KoungQ Date: Fri, 29 May 2026 16:42:52 +0900 Subject: [PATCH 01/22] =?UTF-8?q?chore:=20=EA=B0=9C=EB=B0=9C=20=ED=99=98?= =?UTF-8?q?=EA=B2=BD=20ddl=20auto=20=EC=84=A4=EC=A0=95=20=EB=B3=80?= =?UTF-8?q?=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/resources/application-dev.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index ce445a3..66a8f9a 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} From 04d0be27d8f83159c2f79997448032f901f1f7b3 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 14:17:24 +0900 Subject: [PATCH 02/22] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=9D=B8=EC=A6=9D=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/SignUpUseCase.java | 11 ++++++- .../usecase/VerifyEmailUseCase.java | 5 ++- .../domain/user/domain/entity/User.java | 4 +++ .../repository/EmailVerifiedRepository.java | 8 +++++ .../RedisEmailVerifiedRepository.java | 32 +++++++++++++++++++ .../code/status/UserErrorStatus.java | 1 + src/main/resources/application.yml | 18 +++++++++++ 7 files changed, 77 insertions(+), 2 deletions(-) create mode 100644 src/main/java/com/project/dorumdorum/domain/user/domain/repository/EmailVerifiedRepository.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisEmailVerifiedRepository.java 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 8a0c82f..83a61cb 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 4369bde..bbf8ab5 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/domain/entity/User.java b/src/main/java/com/project/dorumdorum/domain/user/domain/entity/User.java index 7e790a5..8aac584 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 0000000..d1b79f3 --- /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/infra/repository/RedisEmailVerifiedRepository.java b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisEmailVerifiedRepository.java new file mode 100644 index 0000000..e9d3289 --- /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(10)); + } + + @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/global/exception/code/status/UserErrorStatus.java b/src/main/java/com/project/dorumdorum/global/exception/code/status/UserErrorStatus.java index 9c52f86..a4b23d4 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.yml b/src/main/resources/application.yml index 8e9c973..15e169e 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 From 5f090bf29fff0d0b8c5f0573f882b1a5186f2e95 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 14:18:24 +0900 Subject: [PATCH 03/22] =?UTF-8?q?feat:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EC=8B=9C=20=EC=9D=B4=EB=A9=94?= =?UTF-8?q?=EC=9D=BC=20=EC=9D=B8=EC=A6=9D=20=EB=B0=8F=20=EB=B9=84=EB=B0=80?= =?UTF-8?q?=EB=B2=88=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20?= =?UTF-8?q?=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/ResetPasswordRequest.java | 14 ++++++ .../usecase/ResetPasswordUseCase.java | 44 +++++++++++++++++++ .../SendPasswordResetEmailUseCase.java | 39 ++++++++++++++++ .../VerifyPasswordResetCodeUseCase.java | 29 ++++++++++++ .../PasswordResetCodeRepository.java | 10 +++++ .../PasswordResetVerifiedRepository.java | 8 ++++ .../RedisPasswordResetCodeRepository.java | 32 ++++++++++++++ .../RedisPasswordResetVerifiedRepository.java | 32 ++++++++++++++ .../user/ui/ResetPasswordController.java | 25 +++++++++++ .../ui/SendPasswordResetEmailController.java | 23 ++++++++++ .../ui/VerifyPasswordResetCodeController.java | 24 ++++++++++ .../user/ui/spec/ResetPasswordApiSpec.java | 21 +++++++++ .../spec/SendPasswordResetEmailApiSpec.java | 20 +++++++++ .../spec/VerifyPasswordResetCodeApiSpec.java | 21 +++++++++ 14 files changed, 342 insertions(+) create mode 100644 src/main/java/com/project/dorumdorum/domain/user/application/dto/request/ResetPasswordRequest.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/application/usecase/ResetPasswordUseCase.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/application/usecase/SendPasswordResetEmailUseCase.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyPasswordResetCodeUseCase.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetCodeRepository.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetVerifiedRepository.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetCodeRepository.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetVerifiedRepository.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/ResetPasswordController.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/spec/ResetPasswordApiSpec.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/spec/SendPasswordResetEmailApiSpec.java create mode 100644 src/main/java/com/project/dorumdorum/domain/user/ui/spec/VerifyPasswordResetCodeApiSpec.java 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 0000000..646c1da --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/dto/request/ResetPasswordRequest.java @@ -0,0 +1,14 @@ +package com.project.dorumdorum.domain.user.application.dto.request; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record ResetPasswordRequest( + @Email @NotBlank String email, + @NotBlank String newPassword, + @NotBlank String newPasswordCheck +) { + public boolean isPasswordMatch() { + return newPassword.equals(newPasswordCheck); + } +} 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 0000000..88c4e38 --- /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()); + passwordResetVerifiedRepository.delete(request.email()); + user.updatePassword(passwordEncoder.encode(request.newPassword())); + } +} 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 0000000..e40821d --- /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.util.SecureRandomGenerator; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.INVALID_EMAIL_DOMAIN; +import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.EMAIL_NOT_FOUND; + +@Service +@RequiredArgsConstructor +public class SendPasswordResetEmailUseCase { + + private final UserService userService; + private final EmailVerificationService emailVerificationService; + private final SecureRandomGenerator secureRandomGenerator; + + /** + * 비밀번호 재설정 인증 코드 발송 + * - 가입된 이메일인지 확인 + * - 허용된 대학 이메일 도메인인지 검증 + * - 인증 코드를 생성해 메일로 발송 + */ + public void send(String email) { + if (!userService.isAlreadyRegistered(email)) { + throw new RestApiException(EMAIL_NOT_FOUND); + } + + if (!emailVerificationService.isAllowedUniversityEmail(email)) { + throw new RestApiException(INVALID_EMAIL_DOMAIN); + } + + String code = secureRandomGenerator.generate(); + emailVerificationService.sendCode(email, code); + } +} 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 0000000..ad35ce6 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/application/usecase/VerifyPasswordResetCodeUseCase.java @@ -0,0 +1,29 @@ +package com.project.dorumdorum.domain.user.application.usecase; + +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetCodeRepository; +import com.project.dorumdorum.domain.user.domain.repository.PasswordResetVerifiedRepository; +import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; +import com.project.dorumdorum.global.exception.RestApiException; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; + +import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.FAILED_EMAIL_VERIFICATION; + +@Service +@RequiredArgsConstructor +public class VerifyPasswordResetCodeUseCase { + + private final PasswordResetCodeRepository passwordResetCodeRepository; + 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/repository/PasswordResetCodeRepository.java b/src/main/java/com/project/dorumdorum/domain/user/domain/repository/PasswordResetCodeRepository.java new file mode 100644 index 0000000..35feb42 --- /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 0000000..0846b40 --- /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/RedisPasswordResetCodeRepository.java b/src/main/java/com/project/dorumdorum/domain/user/infra/repository/RedisPasswordResetCodeRepository.java new file mode 100644 index 0000000..1b8e697 --- /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 0000000..4237186 --- /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 0000000..38dc5e3 --- /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 0000000..9958daf --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java @@ -0,0 +1,23 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class SendPasswordResetEmailController implements SendPasswordResetEmailApiSpec { + + private final SendPasswordResetEmailUseCase sendPasswordResetEmailUseCase; + + @Override + public ResponseEntity send( + @RequestParam String email + ) { + sendPasswordResetEmailUseCase.send(email); + 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 0000000..d4324e3 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java @@ -0,0 +1,24 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class VerifyPasswordResetCodeController implements VerifyPasswordResetCodeApiSpec { + + private final VerifyPasswordResetCodeUseCase verifyPasswordResetCodeUseCase; + + @Override + public ResponseEntity verify( + @RequestParam String email, + @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 0000000..46276d5 --- /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 0000000..d536e41 --- /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 0000000..5907aff --- /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 + ); +} From 112797ced9e6ecc50dd5dc32509753242b7908b2 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 14:27:25 +0900 Subject: [PATCH 04/22] =?UTF-8?q?fix:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20DI=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/usecase/VerifyPasswordResetCodeUseCase.java | 5 ----- 1 file changed, 5 deletions(-) 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 index ad35ce6..6ab9f50 100644 --- 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 @@ -1,19 +1,14 @@ package com.project.dorumdorum.domain.user.application.usecase; -import com.project.dorumdorum.domain.user.domain.repository.PasswordResetCodeRepository; import com.project.dorumdorum.domain.user.domain.repository.PasswordResetVerifiedRepository; import com.project.dorumdorum.domain.user.domain.service.EmailVerificationService; -import com.project.dorumdorum.global.exception.RestApiException; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; -import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.FAILED_EMAIL_VERIFICATION; - @Service @RequiredArgsConstructor public class VerifyPasswordResetCodeUseCase { - private final PasswordResetCodeRepository passwordResetCodeRepository; private final PasswordResetVerifiedRepository passwordResetVerifiedRepository; private final EmailVerificationService emailVerificationService; From bf9f075813fe62a8935e8be419dae317e3683244 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:18:38 +0900 Subject: [PATCH 05/22] =?UTF-8?q?fix:=20=EC=BA=98=EB=A6=B0=EB=8D=94=20?= =?UTF-8?q?=EC=97=94=ED=8B=B0=ED=8B=B0=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/CalendarEventResponse.java | 9 ++++++++- .../application/mapper/CalendarEventMapper.java | 2 ++ .../calendar/domain/entity/CalendarEvent.java | 15 +++++++++++++++ .../calendar/domain/entity/CalendarEventType.java | 8 ++++++++ .../domain/room/domain/service/RoomService.java | 3 +-- 5 files changed, 34 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/project/dorumdorum/domain/calendar/domain/entity/CalendarEventType.java 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 4f67c14..8a4ca6c 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 acdcb87..33b6096 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 5f352e2..ea7e5d9 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 0000000..ed0674e --- /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 da9c00c..ad6934a 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) { From 765d82bf205c3ddaea80d0db12f9589a83912c2b Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:18:48 +0900 Subject: [PATCH 06/22] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EC=9E=85=EB=A0=A5=EA=B0=92=20=EC=A0=9C=ED=95=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/application/dto/request/ResetPasswordRequest.java | 4 +++- .../domain/user/application/dto/request/SignUpRequest.java | 4 +++- 2 files changed, 6 insertions(+), 2 deletions(-) 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 index 646c1da..2cb9bea 100644 --- 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 @@ -2,10 +2,12 @@ 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 String newPassword, + @NotBlank @Size(min = 8, message = "비밀번호는 8자 이상이어야 합니다.") @Pattern(regexp = "^[A-Za-z0-9!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>\\/?]+$", message = "비밀번호는 영문, 숫자, 특수문자만 사용할 수 있습니다.") String newPassword, @NotBlank String newPasswordCheck ) { public boolean isPasswordMatch() { 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 16bd329..8bf199f 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, From d9c024795c3878b8165d356bb804e438af2417a3 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:22:57 +0900 Subject: [PATCH 07/22] =?UTF-8?q?fix:=20=EB=B9=84=EB=B0=80=EB=B2=88?= =?UTF-8?q?=ED=98=B8=20=EB=B3=80=EA=B2=BD=20=EB=A1=9C=EC=A7=81=20=EC=88=98?= =?UTF-8?q?=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/application/usecase/ResetPasswordUseCase.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index 88c4e38..d9b37f1 100644 --- 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 @@ -38,7 +38,7 @@ public void execute(ResetPasswordRequest request) { } User user = userService.findByEmail(request.email()); - passwordResetVerifiedRepository.delete(request.email()); user.updatePassword(passwordEncoder.encode(request.newPassword())); + passwordResetVerifiedRepository.delete(request.email()); } } From 24e0de49495b8f7502be33eccf96bd44895e19a8 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:25:34 +0900 Subject: [PATCH 08/22] =?UTF-8?q?fix:=20=EC=82=AC=EC=9A=A9=EC=9E=90=20?= =?UTF-8?q?=EC=97=B4=EA=B1=B0=20=EA=B3=B5=EA=B2=A9=20=EB=B0=A9=EC=96=B4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/SendPasswordResetEmailUseCase.java | 12 +++++------- 1 file changed, 5 insertions(+), 7 deletions(-) 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 index e40821d..eb0fa78 100644 --- 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 @@ -8,7 +8,6 @@ import org.springframework.stereotype.Service; import static com.project.dorumdorum.global.exception.code.status.AuthErrorStatus.INVALID_EMAIL_DOMAIN; -import static com.project.dorumdorum.global.exception.code.status.UserErrorStatus.EMAIL_NOT_FOUND; @Service @RequiredArgsConstructor @@ -20,19 +19,18 @@ public class SendPasswordResetEmailUseCase { /** * 비밀번호 재설정 인증 코드 발송 - * - 가입된 이메일인지 확인 * - 허용된 대학 이메일 도메인인지 검증 - * - 인증 코드를 생성해 메일로 발송 + * - 가입된 이메일인 경우에만 실제로 코드를 발송 (미가입 이메일도 동일한 200 응답 반환) */ public void send(String email) { - if (!userService.isAlreadyRegistered(email)) { - throw new RestApiException(EMAIL_NOT_FOUND); - } - if (!emailVerificationService.isAllowedUniversityEmail(email)) { throw new RestApiException(INVALID_EMAIL_DOMAIN); } + if (!userService.isAlreadyRegistered(email)) { + return; + } + String code = secureRandomGenerator.generate(); emailVerificationService.sendCode(email, code); } From 3729f0954e09fab310d934376e6f4eaa462cd85f Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:28:07 +0900 Subject: [PATCH 09/22] =?UTF-8?q?feat:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=ED=8A=B8=EB=A6=AC=EB=B0=8B=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../usecase/SendPasswordResetEmailUseCase.java | 2 ++ .../application/usecase/SendVerificationEmailUseCase.java | 2 ++ src/main/resources/application.yml | 8 ++++++++ 3 files changed, 12 insertions(+) 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 index eb0fa78..896bf0d 100644 --- 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 @@ -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; @@ -22,6 +23,7 @@ public class SendPasswordResetEmailUseCase { * - 허용된 대학 이메일 도메인인지 검증 * - 가입된 이메일인 경우에만 실제로 코드를 발송 (미가입 이메일도 동일한 200 응답 반환) */ + @RateLimited(tag = "password-reset-email", key = "#email") public void send(String email) { if (!emailVerificationService.isAllowedUniversityEmail(email)) { throw new RestApiException(INVALID_EMAIL_DOMAIN); 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 08d2308..835c85a 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/resources/application.yml b/src/main/resources/application.yml index 15e169e..12cd74f 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -211,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 From e38de9ac74ede760785f1b8198dd453ce3e61bea Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:32:04 +0900 Subject: [PATCH 10/22] =?UTF-8?q?fix:=20=ED=9A=8C=EC=9B=90=EA=B0=80?= =?UTF-8?q?=EC=9E=85=20=EC=B2=B4=EB=A5=98=20=EC=8B=9C=EA=B0=84=20=EC=97=B0?= =?UTF-8?q?=EC=9E=A5?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../user/infra/repository/RedisEmailVerifiedRepository.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 index e9d3289..d98654b 100644 --- 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 @@ -17,7 +17,7 @@ public class RedisEmailVerifiedRepository implements EmailVerifiedRepository { @Override public void save(String email) { - redisTemplate.opsForValue().set(EMAIL_VERIFIED + email, VERIFIED_VALUE, Duration.ofMinutes(10)); + redisTemplate.opsForValue().set(EMAIL_VERIFIED + email, VERIFIED_VALUE, Duration.ofMinutes(30)); } @Override From 9a03a5163a175c163be40c223fd910aeb5e239cc Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:39:59 +0900 Subject: [PATCH 11/22] =?UTF-8?q?fix:=20=EC=9D=B4=EB=A9=94=EC=9D=BC=20?= =?UTF-8?q?=EC=A0=84=EC=86=A1=20=EB=A1=9C=EC=A7=81=20=EA=B2=80=EC=A6=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/user/ui/SendPasswordResetEmailController.java | 5 ++++- .../domain/user/ui/SendVerificationEmailController.java | 5 ++++- .../dorumdorum/domain/user/ui/VerifyEmailController.java | 7 +++++-- .../domain/user/ui/VerifyPasswordResetCodeController.java | 7 +++++-- 4 files changed, 18 insertions(+), 6 deletions(-) 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 index 9958daf..e74ba11 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/SendPasswordResetEmailController.java @@ -2,11 +2,14 @@ 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 { @@ -15,7 +18,7 @@ public class SendPasswordResetEmailController implements SendPasswordResetEmailA @Override public ResponseEntity send( - @RequestParam String email + @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 0c57548..a6dc007 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 8705963..ca21f6e 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 index d4324e3..d1a206d 100644 --- a/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java +++ b/src/main/java/com/project/dorumdorum/domain/user/ui/VerifyPasswordResetCodeController.java @@ -2,11 +2,14 @@ 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 { @@ -15,8 +18,8 @@ public class VerifyPasswordResetCodeController implements VerifyPasswordResetCod @Override public ResponseEntity verify( - @RequestParam String email, - @RequestParam String code + @NotBlank @RequestParam String email, + @NotBlank @RequestParam String code ) { verifyPasswordResetCodeUseCase.execute(email, code); return ResponseEntity.ok().build(); From 97eba3c5da395cd694d80fad6ee1c71a62f2bdd1 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:42:01 +0900 Subject: [PATCH 12/22] =?UTF-8?q?fix:=20Discord=20=EC=9B=B9=ED=9B=85=20?= =?UTF-8?q?=EC=98=A4=EB=A5=98=20=EB=B6=84=EA=B8=B0=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dorumdorum/global/alert/DiscordAlertSender.java | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) 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 1528471..976b13a 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; } From 194ca802d91510f09bbb9756c4434fd9ed620647 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:44:31 +0900 Subject: [PATCH 13/22] =?UTF-8?q?test:=20=EA=B8=B0=EC=A1=B4=20=ED=85=8C?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../calendar/unit/response/CalendarEventResponseTest.java | 2 +- .../calendar/unit/ui/LoadCalendarEventsControllerTest.java | 2 +- .../calendar/unit/usecase/LoadCalendarEventsUseCaseTest.java | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) 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 49b06e3..f24240e 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 befbe66..5e7cdc3 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 2dbae3c..10ed4ec 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); From ab9fd8105f7fbb769840b4dfac7a489772794439 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:52:40 +0900 Subject: [PATCH 14/22] =?UTF-8?q?fix:=20CI=20=ED=85=8C=EC=8A=A4=ED=8A=B8?= =?UTF-8?q?=20=ED=99=98=EA=B2=BD=EC=97=90=20Redis=20=EC=84=9C=EB=B9=84?= =?UTF-8?q?=EC=8A=A4=20=EC=BB=A8=ED=85=8C=EC=9D=B4=EB=84=88=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 --- .github/workflows/cicd.yml | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/.github/workflows/cicd.yml b/.github/workflows/cicd.yml index d5a6a93..c19616b 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 From 4a17361818d2974681e813f2e797c345a5b54a83 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 15:59:29 +0900 Subject: [PATCH 15/22] =?UTF-8?q?fix:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/room/unit/service/RoomServiceTest.java | 6 +++--- .../domain/user/unit/usecase/SignUpUseCaseTest.java | 6 ++++++ .../domain/user/unit/usecase/VerifyEmailUseCaseTest.java | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) 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 4aa5939..7f4441f 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 81e376e..1be8a1a 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 4357f65..0dd03ad 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 From 5664538f74db60fbbfd395599bdbb37facb20d04 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 16:07:45 +0900 Subject: [PATCH 16/22] =?UTF-8?q?fix:=20=EC=9D=BC=EC=A0=95=20=EC=8B=9C?= =?UTF-8?q?=EA=B0=84=20=EB=84=90=20=ED=97=88=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dorumdorum/domain/calendar/domain/entity/CalendarEvent.java | 1 - 1 file changed, 1 deletion(-) 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 ea7e5d9..dfe6741 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 @@ -36,7 +36,6 @@ public class CalendarEvent extends BaseEntity { @Column(nullable = false) private String content; - @Column(nullable = false) private LocalTime eventTime; @Enumerated(EnumType.STRING) From 17e82f108373d89177a2052c6b63172aac6e06ca Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 18:13:30 +0900 Subject: [PATCH 17/22] =?UTF-8?q?fix:=20=EB=B0=A9=20=EC=B0=BE=EA=B8=B0=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/FindRoomsPageResponse.java | 16 ++ .../dto/response/FindRoomsResponse.java | 5 +- .../application/usecase/FindRoomsUseCase.java | 11 +- .../domain/room/domain/entity/RoomType.java | 2 +- .../repository/RoomQueryRepository.java | 2 + .../room/domain/service/RoomService.java | 5 + .../infra/repository/RoomRepositoryImpl.java | 180 ++++++++++++++---- .../domain/room/ui/FindRoomsController.java | 5 +- .../domain/room/ui/spec/FindRoomsApiSpec.java | 6 +- .../repository/RoomRepositoryImplTest.java | 86 ++++++++- .../room/unit/service/RoomServiceTest.java | 19 +- .../room/unit/ui/FindRoomsControllerTest.java | 6 +- .../unit/usecase/FindRoomsUseCaseTest.java | 17 +- 13 files changed, 297 insertions(+), 63 deletions(-) create mode 100644 src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsPageResponse.java diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsPageResponse.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsPageResponse.java new file mode 100644 index 0000000..4226233 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsPageResponse.java @@ -0,0 +1,16 @@ +package com.project.dorumdorum.domain.room.application.dto.response; + +import com.project.dorumdorum.global.pagination.CursorPage; + +import java.util.List; + +public record FindRoomsPageResponse( + List items, + String nextCursor, + boolean hasNext, + Long totalCount +) { + public static FindRoomsPageResponse of(CursorPage page, Long totalCount) { + return new FindRoomsPageResponse(page.items(), page.nextCursor(), page.hasNext(), totalCount); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java index df468f7..ca3aca2 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/FindRoomsResponse.java @@ -14,8 +14,11 @@ public record FindRoomsResponse( Integer currentMateCount, LocalDateTime createdAt, String title, + String notes, String hostNickname, + String hostMajor, + String hostStudentYear, RoomStatus roomStatus, String residencePeriod, // 거주기간 (예: "학기(16주)", "반기(24주)", "계절학기") Integer remaining -) {} \ No newline at end of file +) {} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java index a258543..6d8bf54 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/FindRoomsUseCase.java @@ -1,6 +1,7 @@ package com.project.dorumdorum.domain.room.application.usecase; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; +import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsPageResponse; import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; import com.project.dorumdorum.domain.room.domain.service.RoomService; import com.project.dorumdorum.domain.user.domain.entity.Gender; @@ -29,7 +30,7 @@ public class FindRoomsUseCase { * - 필터와 커서 기준으로 방 목록을 조회 * - 다음 페이지 커서를 포함한 결과를 반환 */ - public CursorPage execute(String userNo, ChecklistFilterRequest request) { + public FindRoomsPageResponse execute(String userNo, ChecklistFilterRequest request) { Gender gender = userService.findById(userNo).getGender(); CursorQueryParams params = PaginationHelper.prepareCursorQuery(request.cursor(), LIMIT); @@ -42,12 +43,18 @@ public CursorPage execute(String userNo, ChecklistFilterReque params.limitPlusOne() ); - return PaginationHelper.buildCursorPage( + CursorPage page = PaginationHelper.buildCursorPage( responses, LIMIT, last -> request.sortType() == ChecklistFilterRequest.SortType.REMAINING ? CursorCodec.encode(last.remaining(), last.createdAt(), last.roomNo()) : CursorCodec.encode(last.createdAt(), last.roomNo()) ); + + Long totalCount = request.cursor() == null + ? roomService.countSearchResults(gender, request) + : null; + + return FindRoomsPageResponse.of(page, totalCount); } } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomType.java b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomType.java index 4af1a96..3696136 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomType.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/RoomType.java @@ -1,5 +1,5 @@ package com.project.dorumdorum.domain.room.domain.entity; public enum RoomType { - TYPE_1, TYPE_2, TYPE_MEDICAL + TYPE_1, TYPE_2, TYPE_3, TYPE_MEDICAL } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java index a124acc..12f9d16 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/repository/RoomQueryRepository.java @@ -19,6 +19,8 @@ List findByCursor( int limitPlusOne ); + long countByFilter(Gender gender, ChecklistFilterRequest request); + Optional findMyRoom(String userNo); List findLikedRooms(String userNo); 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 ad6934a..48ef347 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 @@ -28,6 +28,7 @@ public Room create(String userNo, Gender gender, RoomCreateRequest request) { .roomType(request.roomType()) .residencePeriod(request.residencePeriod()) .title(request.title()) + .notes(request.notes()) .hostUserNo(userNo) .gender(gender) .build(); @@ -56,6 +57,10 @@ public List searchByCursor( return roomRepository.findByCursor(gender, request, cursorCreatedAt, cursorId, cursorRemaining, limitPlusOne); } + public long countSearchResults(Gender gender, ChecklistFilterRequest request) { + return roomRepository.countByFilter(gender, request); + } + public FindRoomsResponse findMyRoom(String userNo) { return roomRepository.findMyRoom(userNo).orElse(null); } diff --git a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java index 9556e24..9bc8164 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java @@ -7,6 +7,7 @@ import com.project.dorumdorum.domain.room.domain.entity.RoomStatus; import com.project.dorumdorum.domain.room.domain.repository.RoomQueryRepository; import com.project.dorumdorum.domain.user.domain.entity.Gender; +import com.querydsl.core.types.dsl.CaseBuilder; import com.querydsl.core.types.dsl.BooleanExpression; import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQuery; @@ -60,6 +61,8 @@ public List findByCursor( room.createdAt, room.title, user.nickname, + user.major, + user.studentNo.substring(2, 4), room.roomStatus, room.residencePeriod.stringValue(), room.remaining @@ -80,7 +83,12 @@ public List findByCursor( ); if (request.sortType() == ChecklistFilterRequest.SortType.REMAINING) { - q.orderBy(room.remaining.asc(), room.createdAt.desc(), room.roomNo.desc()); + q.orderBy( + new CaseBuilder().when(room.remaining.eq(0)).then(1).otherwise(0).asc(), + room.remaining.asc(), + room.createdAt.desc(), + room.roomNo.desc() + ); } else { q.orderBy(room.createdAt.desc(), room.roomNo.desc()); } @@ -88,6 +96,60 @@ public List findByCursor( return q.limit(limitPlusOne).fetch(); } + @Override + public long countByFilter(Gender gender, ChecklistFilterRequest request) { + if (hasChecklistFilters(request)) { + return countByFilterWithChecklist(gender, request); + } + + Long count = query + .select(room.count()) + .from(room) + .where( + room.roomStatus.eq(RoomStatus.CONFIRM_PENDING), + room.deletedAt.isNull(), + room.gender.eq(gender), + eqRoomType(request), + eqResidencePeriod(request), + eqCapacity(request) + ) + .fetchOne(); + + return count == null ? 0L : count; + } + + private long countByFilterWithChecklist(Gender gender, ChecklistFilterRequest request) { + StringBuilder sql = new StringBuilder(""" + SELECT COUNT(*) + FROM room r + WHERE r.room_status = :roomStatus + AND r.deleted_at IS NULL + AND r.gender = :gender + """); + + Map params = new LinkedHashMap<>(); + params.put("roomStatus", RoomStatus.CONFIRM_PENDING.name()); + params.put("gender", gender.name()); + + appendCondition(sql, params, "r.room_type", "roomType", enumName(request.roomType())); + appendCondition(sql, params, "r.residence_period", "residencePeriod", enumName(request.residencePeriod())); + appendCondition(sql, params, "r.capacity", "capacity", request.capacity()); + sql.append(""" + + AND EXISTS ( + SELECT 1 + FROM room_rule rr + WHERE rr.room_no = r.room_no + """); + appendRoomRuleConditions(sql, params, request); + sql.append("\n )"); + + Query nativeQuery = entityManager.createNativeQuery(sql.toString()); + params.forEach(nativeQuery::setParameter); + + return ((Number) nativeQuery.getSingleResult()).longValue(); + } + private List findByCursorWithLateral( Gender gender, ChecklistFilterRequest request, @@ -105,6 +167,8 @@ private List findByCursorWithLateral( r.created_at, r.title, u.nickname, + u.major, + SUBSTRING(u.student_no FROM 3 FOR 2), r.room_status, r.residence_period::text, r.remaining @@ -117,30 +181,7 @@ JOIN LATERAL ( Map params = new LinkedHashMap<>(); - appendCondition(sql, params, "rr.bedtime", "bedtime", request.bedtime()); - appendCondition(sql, params, "rr.wake_up", "wakeUp", request.wakeUp()); - appendCondition(sql, params, "rr.return_home", "returnHome", enumName(request.returnHome())); - appendCondition(sql, params, "rr.return_home_time", "returnHomeTime", request.returnHomeTime()); - appendCondition(sql, params, "rr.cleaning", "cleaning", enumName(request.cleaning())); - appendCondition(sql, params, "rr.phone_call", "phoneCall", enumName(request.phoneCall())); - appendCondition(sql, params, "rr.sleep_light", "sleepLight", enumName(request.sleepLight())); - appendCondition(sql, params, "rr.sleep_habit", "sleepHabit", enumName(request.sleepHabit())); - appendCondition(sql, params, "rr.snoring", "snoring", enumName(request.snoring())); - appendCondition(sql, params, "rr.shower_time", "showerTime", enumName(request.showerTime())); - appendCondition(sql, params, "rr.eating", "eating", enumName(request.eating())); - appendCondition(sql, params, "rr.lights_out", "lightsOut", enumName(request.lightsOut())); - appendCondition(sql, params, "rr.lights_out_time", "lightsOutTime", request.lightsOutTime()); - appendCondition(sql, params, "rr.home_visit", "homeVisit", enumName(request.homeVisit())); - appendCondition(sql, params, "rr.smoking", "smoking", enumName(request.smoking())); - appendCondition(sql, params, "rr.refrigerator", "refrigerator", enumName(request.refrigerator())); - appendCondition(sql, params, "rr.hair_dryer", "hairDryer", request.hairDryer()); - appendCondition(sql, params, "rr.alarm", "alarm", enumName(request.alarm())); - appendCondition(sql, params, "rr.earphone", "earphone", enumName(request.earphone())); - appendCondition(sql, params, "rr.keyskin", "keyskin", enumName(request.keyskin())); - appendCondition(sql, params, "rr.heat", "heat", enumName(request.heat())); - appendCondition(sql, params, "rr.cold", "cold", enumName(request.cold())); - appendCondition(sql, params, "rr.study", "study", enumName(request.study())); - appendCondition(sql, params, "rr.trash_can", "trashCan", enumName(request.trashCan())); + appendRoomRuleConditions(sql, params, request); sql.append(""" LIMIT 1 @@ -187,6 +228,8 @@ public Optional findMyRoom(String userNo) { room.createdAt, room.title, user.nickname, + user.major, + user.studentNo.substring(2, 4), room.roomStatus, room.residencePeriod.stringValue(), room.remaining @@ -218,6 +261,8 @@ public List findLikedRooms(String userNo) { room.createdAt, room.title, user.nickname, + user.major, + user.studentNo.substring(2, 4), room.roomStatus, room.residencePeriod.stringValue(), room.remaining @@ -246,6 +291,8 @@ public List findAppliedRooms(String userNo) { room.createdAt, room.title, user.nickname, + user.major, + user.studentNo.substring(2, 4), room.roomStatus, room.residencePeriod.stringValue(), room.remaining @@ -271,9 +318,16 @@ private BooleanExpression cursorPredicate(LocalDateTime cursorCreatedAt, String private BooleanExpression remainingCursorPredicate(Integer cursorRemaining, LocalDateTime cursorCreatedAt, String cursorId) { if (cursorRemaining == null || cursorCreatedAt == null || cursorId == null) return null; - return room.remaining.gt(cursorRemaining) - .or(room.remaining.eq(cursorRemaining).and(room.createdAt.lt(cursorCreatedAt))) + BooleanExpression sameRemainingNext = room.remaining.eq(cursorRemaining).and(room.createdAt.lt(cursorCreatedAt)) .or(room.remaining.eq(cursorRemaining).and(room.createdAt.eq(cursorCreatedAt)).and(room.roomNo.lt(cursorId))); + + if (cursorRemaining == 0) { + return sameRemainingNext; + } + + return room.remaining.gt(cursorRemaining).and(room.remaining.ne(0)) + .or(room.remaining.eq(0)) + .or(sameRemainingNext); } private BooleanExpression eqRoomType(ChecklistFilterRequest request) { @@ -330,6 +384,37 @@ private void appendCondition( params.put(parameterName, value); } + private void appendRoomRuleConditions( + StringBuilder sql, + Map params, + ChecklistFilterRequest request + ) { + appendCondition(sql, params, "rr.bedtime", "bedtime", request.bedtime()); + appendCondition(sql, params, "rr.wake_up", "wakeUp", request.wakeUp()); + appendCondition(sql, params, "rr.return_home", "returnHome", enumName(request.returnHome())); + appendCondition(sql, params, "rr.return_home_time", "returnHomeTime", request.returnHomeTime()); + appendCondition(sql, params, "rr.cleaning", "cleaning", enumName(request.cleaning())); + appendCondition(sql, params, "rr.phone_call", "phoneCall", enumName(request.phoneCall())); + appendCondition(sql, params, "rr.sleep_light", "sleepLight", enumName(request.sleepLight())); + appendCondition(sql, params, "rr.sleep_habit", "sleepHabit", enumName(request.sleepHabit())); + appendCondition(sql, params, "rr.snoring", "snoring", enumName(request.snoring())); + appendCondition(sql, params, "rr.shower_time", "showerTime", enumName(request.showerTime())); + appendCondition(sql, params, "rr.eating", "eating", enumName(request.eating())); + appendCondition(sql, params, "rr.lights_out", "lightsOut", enumName(request.lightsOut())); + appendCondition(sql, params, "rr.lights_out_time", "lightsOutTime", request.lightsOutTime()); + appendCondition(sql, params, "rr.home_visit", "homeVisit", enumName(request.homeVisit())); + appendCondition(sql, params, "rr.smoking", "smoking", enumName(request.smoking())); + appendCondition(sql, params, "rr.refrigerator", "refrigerator", enumName(request.refrigerator())); + appendCondition(sql, params, "rr.hair_dryer", "hairDryer", request.hairDryer()); + appendCondition(sql, params, "rr.alarm", "alarm", enumName(request.alarm())); + appendCondition(sql, params, "rr.earphone", "earphone", enumName(request.earphone())); + appendCondition(sql, params, "rr.keyskin", "keyskin", enumName(request.keyskin())); + appendCondition(sql, params, "rr.heat", "heat", enumName(request.heat())); + appendCondition(sql, params, "rr.cold", "cold", enumName(request.cold())); + appendCondition(sql, params, "rr.study", "study", enumName(request.study())); + appendCondition(sql, params, "rr.trash_can", "trashCan", enumName(request.trashCan())); + } + private void appendCursorCondition( StringBuilder sql, Map params, @@ -343,15 +428,29 @@ private void appendCursorCondition( return; } - sql.append(""" - - AND ( - r.remaining > :cursorRemaining - OR (r.remaining = :cursorRemaining AND r.created_at < :cursorCreatedAt) - OR (r.remaining = :cursorRemaining AND r.created_at = :cursorCreatedAt AND r.room_no < :cursorId) - ) - """); - params.put("cursorRemaining", cursorRemaining); + if (cursorRemaining == 0) { + sql.append(""" + + AND ( + r.remaining = 0 + AND ( + r.created_at < :cursorCreatedAt + OR (r.created_at = :cursorCreatedAt AND r.room_no < :cursorId) + ) + ) + """); + } else { + sql.append(""" + + AND ( + (r.remaining > :cursorRemaining AND r.remaining <> 0) + OR r.remaining = 0 + OR (r.remaining = :cursorRemaining AND r.created_at < :cursorCreatedAt) + OR (r.remaining = :cursorRemaining AND r.created_at = :cursorCreatedAt AND r.room_no < :cursorId) + ) + """); + params.put("cursorRemaining", cursorRemaining); + } params.put("cursorCreatedAt", cursorCreatedAt); params.put("cursorId", cursorId); return; @@ -374,7 +473,8 @@ private void appendCursorCondition( private void appendOrderBy(StringBuilder sql, String alias, ChecklistFilterRequest.SortType sortType) { if (sortType == ChecklistFilterRequest.SortType.REMAINING) { - sql.append(alias).append(".remaining ASC, ") + sql.append("CASE WHEN ").append(alias).append(".remaining = 0 THEN 1 ELSE 0 END ASC, ") + .append(alias).append(".remaining ASC, ") .append(alias).append(".created_at DESC, ") .append(alias).append(".room_no DESC"); return; @@ -393,9 +493,11 @@ private FindRoomsResponse toFindRoomsResponse(Object[] row) { localDateTimeValue(row[4]), stringValue(row[5]), stringValue(row[6]), - enumValue(RoomStatus.class, row[7]), + stringValue(row[7]), stringValue(row[8]), - intValue(row[9]) + enumValue(RoomStatus.class, row[9]), + stringValue(row[10]), + intValue(row[11]) ); } diff --git a/src/main/java/com/project/dorumdorum/domain/room/ui/FindRoomsController.java b/src/main/java/com/project/dorumdorum/domain/room/ui/FindRoomsController.java index b9f352a..6ecca1b 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/ui/FindRoomsController.java +++ b/src/main/java/com/project/dorumdorum/domain/room/ui/FindRoomsController.java @@ -1,12 +1,11 @@ package com.project.dorumdorum.domain.room.ui; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; -import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; +import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsPageResponse; import com.project.dorumdorum.domain.room.application.usecase.FindRoomsUseCase; import com.project.dorumdorum.domain.room.ui.spec.FindRoomsApiSpec; import com.project.dorumdorum.global.annotation.CurrentUser; import org.springframework.http.ResponseEntity; -import com.project.dorumdorum.global.pagination.CursorPage; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.RequestBody; @@ -19,7 +18,7 @@ public class FindRoomsController implements FindRoomsApiSpec { private final FindRoomsUseCase findRoomsUseCase; @Override - public ResponseEntity> loadAll( + public ResponseEntity loadAll( @CurrentUser String userNo, @Valid @RequestBody ChecklistFilterRequest request ) { diff --git a/src/main/java/com/project/dorumdorum/domain/room/ui/spec/FindRoomsApiSpec.java b/src/main/java/com/project/dorumdorum/domain/room/ui/spec/FindRoomsApiSpec.java index 944bc08..866cbb0 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/ui/spec/FindRoomsApiSpec.java +++ b/src/main/java/com/project/dorumdorum/domain/room/ui/spec/FindRoomsApiSpec.java @@ -1,8 +1,7 @@ package com.project.dorumdorum.domain.room.ui.spec; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; -import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; -import com.project.dorumdorum.global.pagination.CursorPage; +import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsPageResponse; import jakarta.validation.Valid; import org.springframework.http.ResponseEntity; import io.swagger.v3.oas.annotations.Operation; @@ -17,10 +16,11 @@ public interface FindRoomsApiSpec { summary = "방 목록 조회 API", description = "체크리스트 및 방 조건을 기준으로 방 목록을 조회합니다. " + "커서 기반 페이지네이션을 지원합니다. " + + "첫 페이지에서는 검색 조건에 맞는 전체 방 개수를 반환합니다. " + "로그인한 사용자의 성별과 일치하는 방만 반환합니다." ) @PostMapping("/api/rooms/search") - ResponseEntity> loadAll( + ResponseEntity loadAll( @Parameter(hidden = true) String userNo, @Parameter(description = "체크리스트 기반 방 검색 조건", required = true) @Valid @RequestBody ChecklistFilterRequest request diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java index 9338808..6798b48 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java @@ -16,6 +16,7 @@ import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; @@ -55,7 +56,7 @@ void findByCursor_WhenRecruiting_FetchesWithLimit() { JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -163,6 +164,8 @@ void findByCursor_WithChecklistFilters_UsesNativeLateralQuery() { createdAt, "title", "host", + "경영", + "22", RoomStatus.CONFIRM_PENDING.name(), ResidencePeriod.SEMESTER.name(), 1 @@ -178,13 +181,88 @@ void findByCursor_WithChecklistFilters_UsesNativeLateralQuery() { assertThat(result).containsExactly( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, createdAt, - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); - verify(entityManager).createNativeQuery(anyString()); + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .contains("CASE WHEN r.remaining = 0 THEN 1 ELSE 0 END ASC") + .contains("OR r.remaining = 0"); verify(nativeQuery).getResultList(); verifyNoInteractions(queryFactory); } + @Test + @DisplayName("Should paginate within full rooms without unused remaining parameter") + void findByCursor_WithChecklistFiltersAndFullRoomCursor_UsesFullRoomCursor() { + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); + Query nativeQuery = mock(Query.class); + ChecklistFilterRequest request = new ChecklistFilterRequest( + ChecklistFilterRequest.SortType.REMAINING, + null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + SmokingType.NON_SMOKER, + null, null, null, null, null, null, null, null, null + ); + + when(entityManager.createNativeQuery(anyString())).thenReturn(nativeQuery); + when(nativeQuery.setParameter(anyString(), any())).thenReturn(nativeQuery); + when(nativeQuery.getResultList()).thenReturn(List.of()); + + repository.findByCursor(DEFAULT_GENDER, request, LocalDateTime.now(), "r1", 0, 51); + + ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); + verify(entityManager).createNativeQuery(sqlCaptor.capture()); + assertThat(sqlCaptor.getValue()) + .contains("r.remaining = 0") + .doesNotContain(":cursorRemaining"); + verify(nativeQuery, never()).setParameter("cursorRemaining", 0); + } + + @SuppressWarnings("unchecked") + @Test + @DisplayName("Should count rooms with standard filters") + void countByFilter_WithStandardFilters_UsesQueryDsl() { + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); + JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); + ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST); + + when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); + when(jpaQuery.fetchOne()).thenReturn(42L); + + long result = repository.countByFilter(DEFAULT_GENDER, request); + + assertThat(result).isEqualTo(42L); + verify(jpaQuery).fetchOne(); + verifyNoInteractions(entityManager); + } + + @Test + @DisplayName("Should count rooms with checklist filters using native query") + void countByFilter_WithChecklistFilters_UsesNativeQuery() { + RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); + Query nativeQuery = mock(Query.class); + ChecklistFilterRequest request = new ChecklistFilterRequest( + ChecklistFilterRequest.SortType.LATEST, + null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, + SmokingType.NON_SMOKER, + null, null, null, null, null, null, null, null, null + ); + + when(entityManager.createNativeQuery(anyString())).thenReturn(nativeQuery); + when(nativeQuery.setParameter(anyString(), any())).thenReturn(nativeQuery); + when(nativeQuery.getSingleResult()).thenReturn(7L); + + long result = repository.countByFilter(DEFAULT_GENDER, request); + + assertThat(result).isEqualTo(7L); + verify(nativeQuery).getSingleResult(); + verifyNoInteractions(queryFactory); + } + @SuppressWarnings("unchecked") @Test @DisplayName("Should return optional result for my room query") @@ -192,7 +270,7 @@ void findMyRoom_ReturnsOptional() { RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); FindRoomsResponse response = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); + "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); when(jpaQuery.fetchOne()).thenReturn(response); 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 7f4441f..4864de4 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 @@ -61,7 +61,7 @@ void findById_WhenMissing_Throws() { void searchByCursor_DelegatesToRepository() { List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); ChecklistFilterRequest request = new ChecklistFilterRequest( ChecklistFilterRequest.SortType.LATEST, null, null, null, null, @@ -79,6 +79,23 @@ void searchByCursor_DelegatesToRepository() { assertThat(result).isEqualTo(expected); } + @Test + @DisplayName("Should delegate filtered room count to repository") + void countSearchResults_DelegatesToRepository() { + ChecklistFilterRequest request = new ChecklistFilterRequest( + ChecklistFilterRequest.SortType.LATEST, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null, null, null, null, null, null, null, + null, null, null, null + ); + when(roomRepository.countByFilter(Gender.MALE, request)).thenReturn(42L); + + long result = service.countSearchResults(Gender.MALE, request); + + assertThat(result).isEqualTo(42L); + verify(roomRepository).countByFilter(Gender.MALE, request); + } + @Test @DisplayName("Should return null when my room is not found") void findMyRoom_WhenMissing_ReturnsNull() { diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/FindRoomsControllerTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/FindRoomsControllerTest.java index da15bfc..83ca270 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/FindRoomsControllerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/FindRoomsControllerTest.java @@ -1,10 +1,10 @@ package com.project.dorumdorum.domain.room.unit.ui; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; +import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsPageResponse; import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; import com.project.dorumdorum.domain.room.application.usecase.FindRoomsUseCase; import com.project.dorumdorum.domain.room.ui.FindRoomsController; -import com.project.dorumdorum.global.pagination.CursorPage; import org.springframework.http.ResponseEntity; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -36,10 +36,10 @@ void loadAll_ReturnsUseCaseResult() { null, null, null, null, null, null, null, null, null, null, null, null, null, null ); - CursorPage page = new CursorPage<>(List.of(), null, false); + FindRoomsPageResponse page = new FindRoomsPageResponse(List.of(), null, false, 0L); when(useCase.execute(USER_NO, request)).thenReturn(page); - ResponseEntity> response = + ResponseEntity response = controller.loadAll(USER_NO, request); verify(useCase).execute(USER_NO, request); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java index 043e545..9f2cc4d 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java @@ -1,6 +1,7 @@ package com.project.dorumdorum.domain.room.unit.usecase; import com.project.dorumdorum.domain.room.application.dto.request.ChecklistFilterRequest; +import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsPageResponse; import com.project.dorumdorum.domain.room.application.dto.response.FindRoomsResponse; import com.project.dorumdorum.domain.room.application.usecase.FindRoomsUseCase; import com.project.dorumdorum.domain.room.domain.entity.ResidencePeriod; @@ -10,7 +11,6 @@ import com.project.dorumdorum.domain.user.domain.entity.Gender; import com.project.dorumdorum.domain.user.domain.entity.User; import com.project.dorumdorum.domain.user.domain.service.UserService; -import com.project.dorumdorum.global.pagination.CursorPage; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -25,6 +25,7 @@ import static org.assertj.core.api.Assertions.assertThat; import static org.mockito.ArgumentMatchers.any; import static org.mockito.ArgumentMatchers.anyInt; +import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; @@ -46,7 +47,7 @@ void setUp() { private FindRoomsResponse room(String roomNo, int capacity, int current, LocalDateTime createdAt) { return new FindRoomsResponse(roomNo, RoomType.TYPE_1, capacity, current, createdAt, "title", - "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), capacity - current); + "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), capacity - current); } private ChecklistFilterRequest request(ChecklistFilterRequest.SortType sortType, String cursor) { @@ -68,12 +69,14 @@ void execute_WhenResponsesExceedLimit_ReturnsHasNextTrue() { ChecklistFilterRequest request = request(ChecklistFilterRequest.SortType.LATEST, null); when(roomService.searchByCursor(any(), any(), any(), any(), any(), anyInt())) .thenReturn(responses); + when(roomService.countSearchResults(any(), any())).thenReturn(120L); - CursorPage result = useCase.execute(USER_NO, request); + FindRoomsPageResponse result = useCase.execute(USER_NO, request); assertThat(result.items()).hasSize(50); assertThat(result.hasNext()).isTrue(); assertThat(result.nextCursor()).isNotBlank(); + assertThat(result.totalCount()).isEqualTo(120L); verify(roomService).searchByCursor(any(), any(), any(), any(), any(), anyInt()); } @@ -85,7 +88,7 @@ void execute_WhenResponsesWithinLimit_ReturnsHasNextFalse() { when(roomService.searchByCursor(any(), any(), any(), any(), any(), anyInt())) .thenReturn(responses); - CursorPage result = useCase.execute(USER_NO, request); + FindRoomsPageResponse result = useCase.execute(USER_NO, request); assertThat(result.items()).hasSize(1); assertThat(result.hasNext()).isFalse(); @@ -99,7 +102,7 @@ void execute_WhenNoResponse_ReturnsNullCursor() { when(roomService.searchByCursor(any(), any(), any(), any(), any(), anyInt())) .thenReturn(List.of()); - CursorPage result = useCase.execute(USER_NO, request); + FindRoomsPageResponse result = useCase.execute(USER_NO, request); assertThat(result.items()).isEmpty(); assertThat(result.nextCursor()).isNull(); @@ -115,9 +118,11 @@ void execute_WithCursor_UsesDecodedCursorPath() { when(roomService.searchByCursor(any(), any(), any(), any(), any(), anyInt())) .thenReturn(responses); - CursorPage result = useCase.execute(USER_NO, request); + FindRoomsPageResponse result = useCase.execute(USER_NO, request); assertThat(result.items()).hasSize(1); + assertThat(result.totalCount()).isNull(); verify(roomService).searchByCursor(any(), any(), any(), any(), any(), anyInt()); + verify(roomService, never()).countSearchResults(any(), any()); } } From 28d29dd0a34785de8b978d502aabeb7bc20cda99 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 18:13:47 +0900 Subject: [PATCH 18/22] =?UTF-8?q?fix:=20=EB=B0=A9=EC=9E=A5=EC=9D=98=20?= =?UTF-8?q?=ED=95=9C=EB=A7=88=EB=94=94=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/application/dto/request/RoomCreateRequest.java | 1 + .../application/dto/request/UpdateRoomTitleRequest.java | 3 ++- .../room/application/usecase/UpdateRoomTitleUseCase.java | 1 + .../project/dorumdorum/domain/room/domain/entity/Room.java | 7 +++++++ 4 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/RoomCreateRequest.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/RoomCreateRequest.java index 2766b24..e458830 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/RoomCreateRequest.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/RoomCreateRequest.java @@ -12,5 +12,6 @@ public record RoomCreateRequest( @NotNull Integer capacity, @NotNull ResidencePeriod residencePeriod, @NotBlank String title, + String notes, @Valid @NotNull CreateRoomRuleRequest rule ) {} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/UpdateRoomTitleRequest.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/UpdateRoomTitleRequest.java index 3dbca70..71dc9dc 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/UpdateRoomTitleRequest.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/request/UpdateRoomTitleRequest.java @@ -3,5 +3,6 @@ import jakarta.validation.constraints.NotBlank; public record UpdateRoomTitleRequest( - @NotBlank String title + @NotBlank String title, + String notes ) {} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java index e09b283..db0af93 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/UpdateRoomTitleUseCase.java @@ -31,5 +31,6 @@ public void execute(String userNo, String roomNo, UpdateRoomTitleRequest request throw new RestApiException(NO_PERMISSION_ON_ROOM); room.updateTitle(request.title().trim()); + room.updateNotes(request.notes()); } } diff --git a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java index 7596aea..545ed2b 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java +++ b/src/main/java/com/project/dorumdorum/domain/room/domain/entity/Room.java @@ -47,6 +47,9 @@ public class Room extends BaseEntity { @Column(nullable = false) private String title; + @Column(columnDefinition = "TEXT") + private String notes; + @Column(nullable = false) private String hostUserNo; @@ -127,4 +130,8 @@ public void updateResidencePeriod(ResidencePeriod residencePeriod) { public void updateTitle(String title) { this.title = title; } + + public void updateNotes(String notes) { + this.notes = notes; + } } From 422a975d8a9ea182cc62bc07c1df07732982132d Mon Sep 17 00:00:00 2001 From: KoungQ Date: Sun, 31 May 2026 18:42:16 +0900 Subject: [PATCH 19/22] =?UTF-8?q?feat:=20=EC=B2=B4=ED=81=AC=EB=A6=AC?= =?UTF-8?q?=EC=8A=A4=ED=8A=B8=20=EA=B8=B0=EB=B0=98=20=EC=B6=94=EC=B2=9C=20?= =?UTF-8?q?=EA=B8=B0=EB=8A=A5=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/repository/RoomRuleRepository.java | 4 + .../repository/UserChecklistRepository.java | 1 + .../dto/response/RecommendedRoomResponse.java | 24 ++++ .../RecommendedRoomsPageResponse.java | 8 ++ .../usecase/LoadRecommendedRoomsUseCase.java | 125 ++++++++++++++++++ .../infra/repository/RoomRepositoryImpl.java | 12 +- .../ui/LoadRecommendedRoomsController.java | 23 ++++ .../ui/spec/LoadRecommendedRoomsApiSpec.java | 21 +++ .../unit/usecase/LoadMyRoomsUseCaseTest.java | 2 +- 9 files changed, 216 insertions(+), 4 deletions(-) create mode 100644 src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomResponse.java create mode 100644 src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomsPageResponse.java create mode 100644 src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRecommendedRoomsUseCase.java create mode 100644 src/main/java/com/project/dorumdorum/domain/room/ui/LoadRecommendedRoomsController.java create mode 100644 src/main/java/com/project/dorumdorum/domain/room/ui/spec/LoadRecommendedRoomsApiSpec.java diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/RoomRuleRepository.java b/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/RoomRuleRepository.java index 4b7eeab..d3bd376 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/RoomRuleRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/RoomRuleRepository.java @@ -3,6 +3,7 @@ import com.project.dorumdorum.domain.checklist.domain.entity.RoomRule; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; +import java.util.List; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -16,4 +17,7 @@ public interface RoomRuleRepository extends JpaRepository { @Modifying(clearAutomatically = true, flushAutomatically = true) @Query("UPDATE RoomRule rr SET rr.deletedAt = CURRENT_TIMESTAMP WHERE rr.room.roomNo = :roomNo AND rr.deletedAt IS NULL") void deleteByRoomNo(@Param("roomNo") String roomNo); + + @Query("SELECT rr FROM RoomRule rr JOIN FETCH rr.room r WHERE r.roomStatus = 'CONFIRM_PENDING' AND r.deletedAt IS NULL AND rr.deletedAt IS NULL") + List findAllActiveWithRoom(); } diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/UserChecklistRepository.java b/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/UserChecklistRepository.java index 36350ce..4d2b7d6 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/UserChecklistRepository.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/domain/repository/UserChecklistRepository.java @@ -9,4 +9,5 @@ public interface UserChecklistRepository extends JpaRepository findByUserNo(String userNo); + boolean existsByUserNo(String userNo); } diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomResponse.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomResponse.java new file mode 100644 index 0000000..e471f58 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomResponse.java @@ -0,0 +1,24 @@ +package com.project.dorumdorum.domain.room.application.dto.response; + +import com.project.dorumdorum.domain.room.domain.entity.RoomStatus; +import com.project.dorumdorum.domain.room.domain.entity.RoomType; +import lombok.Builder; + +import java.util.List; + +@Builder +public record RecommendedRoomResponse( + String roomNo, + String title, + RoomType roomType, + Integer capacity, + Integer currentMateCount, + Integer remaining, + RoomStatus roomStatus, + String residencePeriod, + String hostNickname, + String matchLabel, + int matchedCount, + int totalCount, + List highlights +) {} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomsPageResponse.java b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomsPageResponse.java new file mode 100644 index 0000000..b83a0f8 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/application/dto/response/RecommendedRoomsPageResponse.java @@ -0,0 +1,8 @@ +package com.project.dorumdorum.domain.room.application.dto.response; + +import java.util.List; + +public record RecommendedRoomsPageResponse( + boolean hasChecklist, + List items +) {} diff --git a/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRecommendedRoomsUseCase.java b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRecommendedRoomsUseCase.java new file mode 100644 index 0000000..44c7910 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/application/usecase/LoadRecommendedRoomsUseCase.java @@ -0,0 +1,125 @@ +package com.project.dorumdorum.domain.room.application.usecase; + +import com.project.dorumdorum.domain.checklist.domain.entity.ChecklistBase; +import com.project.dorumdorum.domain.checklist.domain.entity.RoomRule; +import com.project.dorumdorum.domain.checklist.domain.entity.UserChecklist; +import com.project.dorumdorum.domain.checklist.domain.repository.RoomRuleRepository; +import com.project.dorumdorum.domain.checklist.domain.repository.UserChecklistRepository; +import com.project.dorumdorum.domain.checklist.domain.service.UserChecklistService; +import com.project.dorumdorum.domain.room.application.dto.response.RecommendedRoomResponse; +import com.project.dorumdorum.domain.room.application.dto.response.RecommendedRoomsPageResponse; +import com.project.dorumdorum.domain.room.domain.entity.Room; +import com.project.dorumdorum.domain.user.domain.service.UserService; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Objects; + +@Service +@Transactional(readOnly = true) +@RequiredArgsConstructor +public class LoadRecommendedRoomsUseCase { + + private static final double STRONG_MATCH_THRESHOLD = 0.85; + private static final double MATCH_THRESHOLD = 0.65; + + private final UserChecklistService userChecklistService; + private final UserChecklistRepository userChecklistRepository; + private final RoomRuleRepository roomRuleRepository; + private final UserService userService; + + public RecommendedRoomsPageResponse execute(String userNo) { + if (!userChecklistRepository.existsByUserNo(userNo)) { + return new RecommendedRoomsPageResponse(false, List.of()); + } + + UserChecklist my = userChecklistService.findByUserNo(userNo); + List roomRules = roomRuleRepository.findAllActiveWithRoom(); + + List items = roomRules.stream() + .map(rr -> buildResponse(my, rr)) + .filter(r -> r != null) + .sorted(Comparator.comparingDouble(r -> -(double) r.matchedCount() / Math.max(r.totalCount(), 1))) + .toList(); + + return new RecommendedRoomsPageResponse(true, items); + } + + private RecommendedRoomResponse buildResponse(UserChecklist my, RoomRule rr) { + List fields = compareFields(my, rr); + + int totalCount = (int) fields.stream().filter(FieldResult::filled).count(); + if (totalCount == 0) return null; + + int matchedCount = (int) fields.stream().filter(f -> f.filled() && f.match()).count(); + double score = (double) matchedCount / totalCount; + + String matchLabel; + if (score >= STRONG_MATCH_THRESHOLD) matchLabel = "잘 맞아요"; + else if (score >= MATCH_THRESHOLD) matchLabel = "괜찮아요"; + else return null; + + List highlights = fields.stream() + .filter(f -> f.filled() && f.match()) + .limit(3) + .map(FieldResult::label) + .toList(); + + Room room = rr.getRoom(); + String hostNickname = userService.findById(room.getHostUserNo()).getNickname(); + + return RecommendedRoomResponse.builder() + .roomNo(room.getRoomNo()) + .title(room.getTitle()) + .roomType(room.getRoomType()) + .capacity(room.getCapacity()) + .currentMateCount(room.getCurrentMateCount()) + .remaining(room.getRemaining()) + .roomStatus(room.getRoomStatus()) + .residencePeriod(room.getResidencePeriod().name()) + .hostNickname(hostNickname) + .matchLabel(matchLabel) + .matchedCount(matchedCount) + .totalCount(totalCount) + .highlights(highlights) + .build(); + } + + private List compareFields(UserChecklist my, ChecklistBase room) { + List results = new ArrayList<>(); + addField(results, "취침", my.getBedtime(), room.getBedtime()); + addField(results, "기상", my.getWakeUp(), room.getWakeUp()); + addField(results, "귀가", my.getReturnHome(), room.getReturnHome()); + addField(results, "청소", my.getCleaning(), room.getCleaning()); + addField(results, "방에서 전화", my.getPhoneCall(), room.getPhoneCall()); + addField(results, "잠귀", my.getSleepLight(), room.getSleepLight()); + addField(results, "잠버릇", my.getSleepHabit(), room.getSleepHabit()); + addField(results, "코골이", my.getSnoring(), room.getSnoring()); + addField(results, "샤워 시간", my.getShowerTime(), room.getShowerTime()); + addField(results, "방에서 취식", my.getEating(), room.getEating()); + addField(results, "소등", my.getLightsOut(), room.getLightsOut()); + addField(results, "본가 주기", my.getHomeVisit(), room.getHomeVisit()); + addField(results, "흡연", my.getSmoking(), room.getSmoking()); + addField(results, "냉장고", my.getRefrigerator(), room.getRefrigerator()); + addField(results, "알람", my.getAlarm(), room.getAlarm()); + addField(results, "이어폰", my.getEarphone(), room.getEarphone()); + addField(results, "키스킨", my.getKeyskin(), room.getKeyskin()); + addField(results, "더위", my.getHeat(), room.getHeat()); + addField(results, "추위", my.getCold(), room.getCold()); + addField(results, "공부", my.getStudy(), room.getStudy()); + addField(results, "쓰레기통", my.getTrashCan(), room.getTrashCan()); + return results; + } + + private void addField(List results, String label, Object myValue, Object roomValue) { + boolean filled = myValue != null && !(myValue instanceof String s && s.isBlank()); + boolean match = filled && Objects.equals(myValue, roomValue); + results.add(new FieldResult(label, filled, match)); + } + + private record FieldResult(String label, boolean filled, boolean match) {} +} diff --git a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java index 9bc8164..a824c86 100644 --- a/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java +++ b/src/main/java/com/project/dorumdorum/domain/room/infra/repository/RoomRepositoryImpl.java @@ -60,6 +60,7 @@ public List findByCursor( room.currentMateCount, room.createdAt, room.title, + room.notes, user.nickname, user.major, user.studentNo.substring(2, 4), @@ -166,6 +167,7 @@ private List findByCursorWithLateral( r.current_mate_count, r.created_at, r.title, + r.notes, u.nickname, u.major, SUBSTRING(u.student_no FROM 3 FOR 2), @@ -227,6 +229,7 @@ public Optional findMyRoom(String userNo) { room.currentMateCount, room.createdAt, room.title, + room.notes, user.nickname, user.major, user.studentNo.substring(2, 4), @@ -260,6 +263,7 @@ public List findLikedRooms(String userNo) { room.currentMateCount, room.createdAt, room.title, + room.notes, user.nickname, user.major, user.studentNo.substring(2, 4), @@ -290,6 +294,7 @@ public List findAppliedRooms(String userNo) { room.currentMateCount, room.createdAt, room.title, + room.notes, user.nickname, user.major, user.studentNo.substring(2, 4), @@ -495,9 +500,10 @@ private FindRoomsResponse toFindRoomsResponse(Object[] row) { stringValue(row[6]), stringValue(row[7]), stringValue(row[8]), - enumValue(RoomStatus.class, row[9]), - stringValue(row[10]), - intValue(row[11]) + stringValue(row[9]), + enumValue(RoomStatus.class, row[10]), + stringValue(row[11]), + intValue(row[12]) ); } diff --git a/src/main/java/com/project/dorumdorum/domain/room/ui/LoadRecommendedRoomsController.java b/src/main/java/com/project/dorumdorum/domain/room/ui/LoadRecommendedRoomsController.java new file mode 100644 index 0000000..8b57554 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/ui/LoadRecommendedRoomsController.java @@ -0,0 +1,23 @@ +package com.project.dorumdorum.domain.room.ui; + +import com.project.dorumdorum.domain.room.application.dto.response.RecommendedRoomsPageResponse; +import com.project.dorumdorum.domain.room.application.usecase.LoadRecommendedRoomsUseCase; +import com.project.dorumdorum.domain.room.ui.spec.LoadRecommendedRoomsApiSpec; +import com.project.dorumdorum.global.annotation.CurrentUser; +import lombok.RequiredArgsConstructor; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +public class LoadRecommendedRoomsController implements LoadRecommendedRoomsApiSpec { + + private final LoadRecommendedRoomsUseCase loadRecommendedRoomsUseCase; + + @Override + public ResponseEntity loadRecommended( + @CurrentUser String userNo + ) { + return ResponseEntity.ok(loadRecommendedRoomsUseCase.execute(userNo)); + } +} diff --git a/src/main/java/com/project/dorumdorum/domain/room/ui/spec/LoadRecommendedRoomsApiSpec.java b/src/main/java/com/project/dorumdorum/domain/room/ui/spec/LoadRecommendedRoomsApiSpec.java new file mode 100644 index 0000000..3f902e6 --- /dev/null +++ b/src/main/java/com/project/dorumdorum/domain/room/ui/spec/LoadRecommendedRoomsApiSpec.java @@ -0,0 +1,21 @@ +package com.project.dorumdorum.domain.room.ui.spec; + +import com.project.dorumdorum.domain.room.application.dto.response.RecommendedRoomsPageResponse; +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.GetMapping; + +@Tag(name = "Room") +public interface LoadRecommendedRoomsApiSpec { + + @Operation( + summary = "나와 잘 맞는 방 목록 조회 API", + description = "내 체크리스트와 비교해 65% 이상 일치하는 방을 매칭률 순으로 반환합니다. hasChecklist가 false면 체크리스트 미작성 상태입니다." + ) + @GetMapping("/api/rooms/recommended") + ResponseEntity loadRecommended( + @Parameter(hidden = true) String userNo + ); +} diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java index 5f7d0b5..c0a9dac 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java @@ -30,7 +30,7 @@ class LoadMyRoomsUseCaseTest { @DisplayName("Should return my room from room service") void execute_ReturnsMyRoom() { FindRoomsResponse expected = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); + "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(roomService.findMyRoom("u1")).thenReturn(expected); FindRoomsResponse result = useCase.execute("u1"); From 78e6164a96b1f806569d7a8b82905878dd9fcf1f Mon Sep 17 00:00:00 2001 From: KoungQ Date: Tue, 2 Jun 2026 17:10:45 +0900 Subject: [PATCH 20/22] =?UTF-8?q?test:=20=ED=85=8C=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../room/unit/infra/repository/RoomRepositoryImplTest.java | 7 ++++--- .../domain/room/unit/service/RoomServiceTest.java | 4 ++-- .../domain/room/unit/ui/CreateRoomControllerTest.java | 2 +- .../domain/room/unit/ui/UpdateRoomTitleControllerTest.java | 2 +- .../domain/room/unit/usecase/CreateRoomUseCaseTest.java | 4 ++-- .../domain/room/unit/usecase/FindRoomsUseCaseTest.java | 2 +- .../domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java | 2 +- .../room/unit/usecase/UpdateRoomTitleUseCaseTest.java | 5 +++-- 8 files changed, 15 insertions(+), 13 deletions(-) diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java index 6798b48..88e98c0 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/infra/repository/RoomRepositoryImplTest.java @@ -56,7 +56,7 @@ void findByCursor_WhenRecruiting_FetchesWithLimit() { JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); @@ -163,6 +163,7 @@ void findByCursor_WithChecklistFilters_UsesNativeLateralQuery() { 1, createdAt, "title", + null, "host", "경영", "22", @@ -181,7 +182,7 @@ void findByCursor_WithChecklistFilters_UsesNativeLateralQuery() { assertThat(result).containsExactly( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, createdAt, - "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); ArgumentCaptor sqlCaptor = ArgumentCaptor.forClass(String.class); verify(entityManager).createNativeQuery(sqlCaptor.capture()); @@ -270,7 +271,7 @@ void findMyRoom_ReturnsOptional() { RoomRepositoryImpl repository = new RoomRepositoryImpl(queryFactory, entityManager); JPAQuery jpaQuery = mock(JPAQuery.class, RETURNS_SELF); FindRoomsResponse response = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); + "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(queryFactory.select(org.mockito.ArgumentMatchers.>any())).thenReturn(jpaQuery); when(jpaQuery.fetchOne()).thenReturn(response); 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 4864de4..d923a48 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 @@ -39,7 +39,7 @@ class RoomServiceTest { @Test @DisplayName("Should create and save room with gender from request") void create_SavesRoom() { - RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", null); + RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", null, null); Room saved = Room.builder().roomNo("r1").hostUserNo("u1").gender(Gender.MALE).build(); when(roomRepository.save(any(Room.class))).thenReturn(saved); @@ -61,7 +61,7 @@ void findById_WhenMissing_Throws() { void searchByCursor_DelegatesToRepository() { List expected = List.of( new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) + "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1) ); ChecklistFilterRequest request = new ChecklistFilterRequest( ChecklistFilterRequest.SortType.LATEST, null, null, null, null, diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/CreateRoomControllerTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/CreateRoomControllerTest.java index d82b274..4c5543c 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/CreateRoomControllerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/CreateRoomControllerTest.java @@ -30,7 +30,7 @@ class CreateRoomControllerTest { @Test void create_CallsUseCase() { - RoomCreateRequest req = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", + RoomCreateRequest req = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", null, mock(CreateRoomRuleRequest.class)); when(useCase.execute("u1", req)).thenReturn("r1"); ResponseEntity response = controller.create("u1", req); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/UpdateRoomTitleControllerTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/UpdateRoomTitleControllerTest.java index 70dc7f7..995a235 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/ui/UpdateRoomTitleControllerTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/ui/UpdateRoomTitleControllerTest.java @@ -23,7 +23,7 @@ class UpdateRoomTitleControllerTest { @Test void update_CallsUseCase() { - UpdateRoomTitleRequest req = new UpdateRoomTitleRequest("new"); + UpdateRoomTitleRequest req = new UpdateRoomTitleRequest("new", null); ResponseEntity response = controller.update("u1", "r1", req); verify(useCase).execute("u1", "r1", req); assertThat(response.getStatusCode().is2xxSuccessful()).isTrue(); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/CreateRoomUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/CreateRoomUseCaseTest.java index 9a41445..e10aede 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/CreateRoomUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/CreateRoomUseCaseTest.java @@ -43,7 +43,7 @@ class CreateRoomUseCaseTest { void execute_WhenUserAlreadyInRoom_Throws() { String userNo = "u1"; CreateRoomRuleRequest ruleRequest = mock(CreateRoomRuleRequest.class); - RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", ruleRequest); + RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", null, ruleRequest); when(roommateService.existsByUserNo(userNo)).thenReturn(true); @@ -59,7 +59,7 @@ void execute_CreatesRoomAndHostAndRule() { String userNo = "u1"; User host = User.builder().userNo(userNo).gender(Gender.MALE).build(); CreateRoomRuleRequest ruleRequest = mock(CreateRoomRuleRequest.class); - RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", ruleRequest); + RoomCreateRequest request = new RoomCreateRequest(RoomType.TYPE_1, 2, ResidencePeriod.SEMESTER, "title", null, ruleRequest); Room room = Room.builder().roomNo("r1").gender(Gender.MALE).build(); RoomRule roomRule = mock(RoomRule.class); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java index 9f2cc4d..e2bc389 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/FindRoomsUseCaseTest.java @@ -46,7 +46,7 @@ void setUp() { } private FindRoomsResponse room(String roomNo, int capacity, int current, LocalDateTime createdAt) { - return new FindRoomsResponse(roomNo, RoomType.TYPE_1, capacity, current, createdAt, "title", + return new FindRoomsResponse(roomNo, RoomType.TYPE_1, capacity, current, createdAt, "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), capacity - current); } diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java index c0a9dac..d1cf250 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/LoadMyRoomsUseCaseTest.java @@ -30,7 +30,7 @@ class LoadMyRoomsUseCaseTest { @DisplayName("Should return my room from room service") void execute_ReturnsMyRoom() { FindRoomsResponse expected = new FindRoomsResponse("r1", RoomType.TYPE_1, 2, 1, LocalDateTime.now(), - "title", "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); + "title", null, "host", "경영", "22", RoomStatus.CONFIRM_PENDING, ResidencePeriod.SEMESTER.name(), 1); when(roomService.findMyRoom("u1")).thenReturn(expected); FindRoomsResponse result = useCase.execute("u1"); diff --git a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/UpdateRoomTitleUseCaseTest.java b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/UpdateRoomTitleUseCaseTest.java index d9b3b79..bdbef1f 100644 --- a/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/UpdateRoomTitleUseCaseTest.java +++ b/src/test/java/com/project/dorumdorum/domain/room/unit/usecase/UpdateRoomTitleUseCaseTest.java @@ -32,9 +32,10 @@ void execute_WhenHost_UpdatesTitle() { when(roomService.findById("r1")).thenReturn(room); when(roommateService.isHost("u1", room)).thenReturn(true); - useCase.execute("u1", "r1", new UpdateRoomTitleRequest(" new title ")); + useCase.execute("u1", "r1", new UpdateRoomTitleRequest(" new title ", "notes")); verify(room).updateTitle("new title"); + verify(room).updateNotes("notes"); } @Test @@ -44,7 +45,7 @@ void execute_WhenNotHost_Throws() { when(roomService.findById("r1")).thenReturn(room); when(roommateService.isHost("u1", room)).thenReturn(false); - assertThatThrownBy(() -> useCase.execute("u1", "r1", new UpdateRoomTitleRequest("title"))) + assertThatThrownBy(() -> useCase.execute("u1", "r1", new UpdateRoomTitleRequest("title", null))) .isInstanceOf(RestApiException.class); } } From d01059ca0b232f2b2099c70b62c3da75bb644847 Mon Sep 17 00:00:00 2001 From: KoungQ Date: Tue, 2 Jun 2026 17:11:00 +0900 Subject: [PATCH 21/22] =?UTF-8?q?fix:=20mapper=20=EC=98=A4=EB=A5=98=20?= =?UTF-8?q?=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/checklist/application/mapper/UserChecklistMapper.java | 1 + 1 file changed, 1 insertion(+) diff --git a/src/main/java/com/project/dorumdorum/domain/checklist/application/mapper/UserChecklistMapper.java b/src/main/java/com/project/dorumdorum/domain/checklist/application/mapper/UserChecklistMapper.java index f5a2115..400aaf3 100644 --- a/src/main/java/com/project/dorumdorum/domain/checklist/application/mapper/UserChecklistMapper.java +++ b/src/main/java/com/project/dorumdorum/domain/checklist/application/mapper/UserChecklistMapper.java @@ -15,6 +15,7 @@ public interface UserChecklistMapper { @Mapping(target = "userNo", source = "userNo") @Mapping(target = "createdAt", ignore = true) @Mapping(target = "updatedAt", ignore = true) + @Mapping(target = "deletedAt", ignore = true) UserChecklist toUserChecklist(String userNo, CreateUserChecklistRequest request); @Mapping(target = "userChecklistNo", ignore = true) From 07ca492dc4f8110112ae78e72ad0ec086b1a4607 Mon Sep 17 00:00:00 2001 From: ydking0911 Date: Thu, 25 Jun 2026 20:27:02 +0900 Subject: [PATCH 22/22] hotfix: ddl-auto validate -> create --- src/main/resources/application-prod.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index f204d39..2e460be 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -27,7 +27,7 @@ spring: port: ${REDIS_PORT} jpa: hibernate: - ddl-auto: validate + ddl-auto: create show-sql: false jwt: