diff --git a/.github/workflows/docker-depoly.yaml b/.github/workflows/docker-depoly.yaml index fb544e6e..e0f4ca14 100644 --- a/.github/workflows/docker-depoly.yaml +++ b/.github/workflows/docker-depoly.yaml @@ -22,9 +22,16 @@ jobs: script: | cd /home/ubuntu/mosu - echo "${{ secrets.ENV_BLUE }}" > .env.blue - echo "${{ secrets.ENV_GREEN }}" > .env.green + echo "${{ secrets.ENV_BASE }}" > .env + echo "${{ secrets.ENV_BASE }}" > .env.blue + echo "${{ secrets.ENV_BASE }}" > .env.green + echo "${{ secrets.ENV }}" >> .env + echo "${{ secrets.ENV }}" >> .env.blue + echo "${{ secrets.ENV }}" >> .env.green + + echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.blue echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.green + ./deploy.sh diff --git a/.github/workflows/self-depoly.yaml b/.github/workflows/self-depoly.yaml index 20a1ebf8..56beca69 100644 --- a/.github/workflows/self-depoly.yaml +++ b/.github/workflows/self-depoly.yaml @@ -3,20 +3,68 @@ name: Docker CI/CD - Deploy on: workflow_dispatch: branches: - - test + - develop jobs: deploy: runs-on: self-hosted steps: + - name: Checkout source + uses: actions/checkout@v3 + + - name: Set up JDK 21 + uses: actions/setup-java@v3 + with: + java-version: '21' + distribution: 'temurin' + + - name: Cache Gradle files + uses: actions/cache@v3 + with: + path: | + ~/.gradle/caches + ~/.gradle/wrapper + key: gradle-${{ runner.os }}-${{ hashFiles('**/*.gradle*', '**/gradle-wrapper.properties') }} + restore-keys: | + gradle-${{ runner.os }}- + + - name: Clone external repo with jar into libs/ + run: | + mkdir -p libs + git clone https://x-access-token:${{ secrets.GH_PAT }}@github.com/mosu-dev/mosu-kmc-jar.git temp-jar + cp temp-jar/*.jar libs/ + + - name: Build with Gradle + run: ./gradlew build -x test + + - name: Log in to DockerHub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} + + - name: Build Docker image + run: sudo docker build -t kangtaehyun1107/mosu-server:${{ github.sha }} . + working-directory: + - name: Push Docker image + run: sudo docker push kangtaehyun1107/mosu-server:${{ github.sha }} + - name: Deploy via SSH run: | cd ~/mosu-server - echo "${{ secrets.TEST_ENV_BLUE }}" > .env.blue - echo "${{ secrets.TEST_ENV_GREEN }}" > .env.green + echo "${{ secrets.ENV_BASE }}" > .env + echo "${{ secrets.ENV_BASE }}" > .env.blue + echo "${{ secrets.ENV_BASE }}" > .env.green + + echo "${{ secrets.ENV_TEST }}" >> .env + echo "${{ secrets.ENV_TEST }}" >> .env.blue + echo "${{ secrets.ENV_TEST }}" >> .env.green + + echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.blue echo "APP_IMAGE_VERSION=${{ github.sha }}" >> .env.green + sudo docker stop $(sudo docker ps -aq) || true sudo docker rm $(sudo docker ps -aq) || true echo "Stopping all containers..." diff --git a/build.gradle b/build.gradle index 99ddb470..55d7bc5e 100644 --- a/build.gradle +++ b/build.gradle @@ -29,16 +29,17 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.boot:spring-boot-starter-validation' - testImplementation 'org.projectlombok:lombok' - testImplementation 'org.projectlombok:lombok' - testCompileOnly 'org.projectlombok:lombok' - testAnnotationProcessor 'org.projectlombok:lombok' developmentOnly 'org.springframework.boot:spring-boot-devtools' - compileOnly 'org.projectlombok:lombok' - annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' testRuntimeOnly 'org.junit.platform:junit-platform-launcher' + // Lombok + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' + testCompileOnly 'org.projectlombok:lombok' + testImplementation 'org.projectlombok:lombok' + testAnnotationProcessor 'org.projectlombok:lombok' + // 인증사 관련 의존성 implementation 'javax.servlet:jstl:1.2' implementation "org.apache.tomcat.embed:tomcat-embed-jasper" @@ -69,7 +70,6 @@ dependencies { testImplementation 'org.testcontainers:junit-jupiter:1.19.3' testImplementation 'org.testcontainers:mysql:1.20.0' - // security implementation 'org.springframework.boot:spring-boot-starter-security' implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' @@ -103,11 +103,6 @@ dependencies { runtimeOnly 'com.h2database:h2' - testImplementation 'org.springframework.boot:spring-boot-testcontainers:3.3.5' - testImplementation 'org.testcontainers:testcontainers:1.19.3' - testImplementation 'org.testcontainers:junit-jupiter:1.19.3' - testImplementation 'org.testcoscntainers:mysql:1.20.0' - annotationProcessor "org.springframework.boot:spring-boot-configuration-processor" implementation 'org.apache.commons:commons-pool2:2.12.1' diff --git a/src/main/java/life/mosu/mosuserver/application/admin/AdminDashboardService.java b/src/main/java/life/mosu/mosuserver/application/admin/AdminDashboardService.java index e772217f..90a6fa96 100644 --- a/src/main/java/life/mosu/mosuserver/application/admin/AdminDashboardService.java +++ b/src/main/java/life/mosu/mosuserver/application/admin/AdminDashboardService.java @@ -1,7 +1,8 @@ package life.mosu.mosuserver.application.admin; import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; -import life.mosu.mosuserver.domain.refund.repository.RefundJpaRepository; +import life.mosu.mosuserver.domain.refund.repository.RefundFailureLogJpaRepository; +import life.mosu.mosuserver.domain.user.entity.UserRole; import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; import life.mosu.mosuserver.presentation.admin.dto.DashBoardResponse; import lombok.RequiredArgsConstructor; @@ -13,15 +14,19 @@ public class AdminDashboardService { private final ExamApplicationJpaRepository examApplicationJpaRepository; private final UserJpaRepository userJpaRepository; - private final RefundJpaRepository refundJpaRepository; + private final RefundFailureLogJpaRepository refundFailureLogJpaRepository; // 대시보드 정보 조회 public DashBoardResponse getAll() { - Long applicationCounts = examApplicationJpaRepository.count(); - Long refundCounts = refundJpaRepository.count(); - Long userCounts = userJpaRepository.count(); - return new DashBoardResponse(applicationCounts, refundCounts, userCounts); - } + Long applicationCounts = examApplicationJpaRepository.countAll(); + Long refundAbortedCounts = refundFailureLogJpaRepository.count(); + Long userCounts = userJpaRepository.countByUserRoleNot(UserRole.ROLE_ADMIN); + return DashBoardResponse.of( + applicationCounts, + refundAbortedCounts, + userCounts + ); + } } diff --git a/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java b/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java index 84198f13..09362530 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java @@ -46,6 +46,9 @@ public CreateApplicationResponse apply(Long userId, ApplicationRequest request) List examIds = request.examApplication().stream() .map(ExamApplicationRequest::examId) .toList(); + + validator.agreedToTerms(request); + validator.requestNoDuplicateExams(examIds); return handleApplication( userId, examIds, @@ -78,7 +81,6 @@ private CreateApplicationResponse handleApplication( List examApplications, FileRequest admissionTicket ) { - validator.requestNoDuplicateExams(examIds); List exams = examJpaRepository.findAllById(examIds); validator.examDateNotPassed(exams); validator.examNotFull(exams); diff --git a/src/main/java/life/mosu/mosuserver/application/application/processor/GetApplicationsStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/application/processor/GetApplicationsStepProcessor.java index e664b43b..ee282f6b 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/processor/GetApplicationsStepProcessor.java +++ b/src/main/java/life/mosu/mosuserver/application/application/processor/GetApplicationsStepProcessor.java @@ -13,10 +13,8 @@ import life.mosu.mosuserver.presentation.application.dto.ApplicationResponse; import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationWithStatus; import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; -@Slf4j @Component @RequiredArgsConstructor public class GetApplicationsStepProcessor implements @@ -33,7 +31,6 @@ public class GetApplicationsStepProcessor implements public List process(Long userId) { List applications = applicationJpaRepository.findAllByUserId(userId); - log.info("applications info: {}", applications.size()); if (applications.isEmpty()) { return List.of(); } diff --git a/src/main/java/life/mosu/mosuserver/application/application/vaildator/ApplicationValidator.java b/src/main/java/life/mosu/mosuserver/application/application/vaildator/ApplicationValidator.java index 4df221c6..e6f450ab 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/vaildator/ApplicationValidator.java +++ b/src/main/java/life/mosu/mosuserver/application/application/vaildator/ApplicationValidator.java @@ -6,29 +6,41 @@ import java.util.Set; import java.util.stream.Collectors; import life.mosu.mosuserver.application.exam.cache.ExamQuotaCacheManager; -import life.mosu.mosuserver.domain.application.repository.ApplicationJpaRepository; import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; import life.mosu.mosuserver.domain.exam.entity.ExamJpaRepository; import life.mosu.mosuserver.domain.exam.entity.ExamStatus; +import life.mosu.mosuserver.domain.examapplication.repository.ExamApplicationJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; +import life.mosu.mosuserver.presentation.application.dto.ApplicationRequest; import life.mosu.mosuserver.presentation.application.dto.ExamApplicationRequest; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; +@Slf4j @Component @RequiredArgsConstructor public class ApplicationValidator { private final ExamJpaRepository examJpaRepository; - private final ApplicationJpaRepository applicationJpaRepository; + private final ExamApplicationJpaRepository examApplicationJpaRepository; private final ExamQuotaCacheManager examQuotaCacheManager; + public void agreedToTerms(ApplicationRequest request) { + if (!request.agreement().validateAgreement()) { + throw new CustomRuntimeException(ErrorCode.NOT_AGREED_TO_TERMS); + } + } + public void requestNoDuplicateExams(List examIds) { Set examIdSet = new HashSet<>(examIds); if (examIds.size() != examIdSet.size()) { throw new CustomRuntimeException(ErrorCode.EXAM_DUPLICATED); } + if (examIdSet.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.EXAM_NOT_APPLIED); + } } public void examIdsAndLunchSelection(List requests) { @@ -39,12 +51,9 @@ public void examIdsAndLunchSelection(List requests) { List requestedExamIds = requests.stream() .map(ExamApplicationRequest::examId) .toList(); + Set examIdSet = new HashSet<>(requestedExamIds); - List existingExams = examJpaRepository.findAllById(requestedExamIds); - - if (existingExams.size() != requestedExamIds.size()) { - throw new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND); - } + List existingExams = examJpaRepository.findAllById(examIdSet); lunchSelection(requests, existingExams); } @@ -56,16 +65,18 @@ private void lunchSelection(List requests, .map(ExamJpaEntity::getId) .collect(Collectors.toSet()); - boolean hasInvalidLunchRequest = requests.stream() - .anyMatch(req -> examsWithoutLunch.contains(req.examId()) && req.isLunchChecked()); + requests.stream() + .filter(req -> req.isLunchChecked() && examsWithoutLunch.contains(req.examId())) + .findFirst() + .ifPresent(req -> { + throw new CustomRuntimeException(ErrorCode.LUNCH_SELECTION_INVALID); + }); - if (hasInvalidLunchRequest) { - throw new CustomRuntimeException(ErrorCode.LUNCH_SELECTION_INVALID); - } } public void noDuplicateApplication(Long userId, List examIds) { - boolean alreadyApplied = applicationJpaRepository.existsByUserIdAndExamIds(userId, examIds); + boolean alreadyApplied = examApplicationJpaRepository.existsByUserIdAndExamIds(userId, + examIds); if (alreadyApplied) { throw new CustomRuntimeException(ErrorCode.APPLICATION_SCHOOL_DUPLICATED); } diff --git a/src/main/java/life/mosu/mosuserver/application/auth/processor/SignUpAccountStepProcessor.java b/src/main/java/life/mosu/mosuserver/application/auth/processor/SignUpAccountStepProcessor.java index bab8cb99..be712eff 100644 --- a/src/main/java/life/mosu/mosuserver/application/auth/processor/SignUpAccountStepProcessor.java +++ b/src/main/java/life/mosu/mosuserver/application/auth/processor/SignUpAccountStepProcessor.java @@ -18,7 +18,9 @@ public class SignUpAccountStepProcessor implements StepProcessor caches = Arrays.stream(CacheGroup.values()) + .filter(g -> g.getCacheType() == CacheType.LOCAL + || g.getCacheType() == CacheType.COMPOSITE) + .map(g -> new CaffeineCache( + g.getCacheName(), + Caffeine.newBuilder() + .recordStats() + .expireAfterWrite(g.getExpiredAfterWrite()) + .build() + )).collect(Collectors.toList()); + + return new LocalCacheManager(caches); + } + + @Bean + @Primary + public CacheManager appCacheManager(LocalCacheManager localCacheManager) { + return localCacheManager; + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java new file mode 100644 index 00000000..e800e56d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/LocalCacheManager.java @@ -0,0 +1,59 @@ +package life.mosu.mosuserver.application.caffeine; + +import jakarta.annotation.Nullable; +import jakarta.annotation.PostConstruct; +import java.util.Collection; +import java.util.Collections; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import java.util.concurrent.ConcurrentHashMap; +import org.springframework.cache.Cache; +import org.springframework.cache.CacheManager; + +public class LocalCacheManager implements CacheManager, UpdatableCacheManager { + + private final List caches; + private Map cacheMap = new ConcurrentHashMap<>(); + private volatile Set cacheNames = Collections.emptySet(); + + public LocalCacheManager(List caches) { + this.caches = (caches != null) ? caches : Collections.emptyList(); + } + + @PostConstruct + public void init() { + Set cacheNamesSet = new LinkedHashSet<>(caches.size()); + Map cacheMapTemp = new ConcurrentHashMap<>(16); + + for (Cache cache : caches) { + String name = cache.getName(); + cacheNamesSet.add(name); + cacheMapTemp.put(name, cache); + } + this.cacheMap = cacheMapTemp; + this.cacheNames = cacheNamesSet; + } + + @Override + @Nullable + public Cache getCache(String name) { + return cacheMap.get(name); + } + + @Override + public Collection getCacheNames() { + return cacheNames; + } + + @Override + public void putIfAbsent(Cache cache, String key, Object value) { + Cache localCache = getCache(cache.getName()); + if (localCache != null) { + localCache.putIfAbsent(key, value); + } + } + + +} diff --git a/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java b/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java new file mode 100644 index 00000000..92939647 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/caffeine/UpdatableCacheManager.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.application.caffeine; + +import org.springframework.cache.Cache; + +public interface UpdatableCacheManager { + + void putIfAbsent(Cache cache, String key, Object value); +} diff --git a/src/main/java/life/mosu/mosuserver/application/event/EventService.java b/src/main/java/life/mosu/mosuserver/application/event/EventService.java index 0fb020ed..7cf4bf8a 100644 --- a/src/main/java/life/mosu/mosuserver/application/event/EventService.java +++ b/src/main/java/life/mosu/mosuserver/application/event/EventService.java @@ -7,6 +7,7 @@ import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.global.support.CursorResponse; +import life.mosu.mosuserver.infra.persistence.s3.FileUploadHelper; import life.mosu.mosuserver.infra.persistence.s3.S3Service; import life.mosu.mosuserver.presentation.event.dto.EventRequest; import life.mosu.mosuserver.presentation.event.dto.EventResponse; @@ -22,12 +23,13 @@ public class EventService { private final EventJpaRepository eventJpaRepository; private final EventQueryRepository eventQueryRepository; - // private final EventAttachmentService attachmentService; + private final FileUploadHelper uploadHelper; private final S3Service s3Service; @Transactional public void createEvent(EventRequest request) { EventJpaEntity eventEntity = eventJpaRepository.save(request.toEntity()); + uploadHelper.updateTag(eventEntity.getS3Key()); // attachmentService.createAttachment(request.optionalAttachment(), eventEntity); } diff --git a/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java b/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java index 8d47026e..03714ab2 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java @@ -23,7 +23,9 @@ public class ExamService { @Transactional public void register(ExamRequest request) { + validateExamDate(request); ExamJpaEntity exam = request.toEntity(); + examJpaRepository.save(exam); } @@ -71,4 +73,10 @@ public void close(Long examId) { .orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND)); exam.close(); } + + private void validateExamDate(ExamRequest request) { + if (!request.deadlineTime().isBefore(request.examDate().atStartOfDay())) { + throw new CustomRuntimeException(ErrorCode.EXAM_DATE_AFTER_DEADLINE); + } + } } diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java index 8fd8d1ef..6ae044d7 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java @@ -41,7 +41,6 @@ public class ExamApplicationService { private final S3Service s3Service; private final FixedQuantityDiscountCalculator calculator; - @Transactional public List register(RegisterExamApplicationEvent event) { List examApplicationEntities = event.toEntity(); @@ -57,7 +56,6 @@ public void updateSubjects(Long userId, Long examApplicationId, examSubjectJpaRepository.deleteExamSubjectsWithDonePayment(examApplicationId); List examSubjects = request.toEntityList(examApplicationId); examSubjectJpaRepository.saveAll(examSubjects); - } @Transactional(propagation = Propagation.REQUIRES_NEW) @@ -97,27 +95,17 @@ public ExamTicketResponse getExamTicket(Long userId, Long examApplicationId) { .map(Subject::getSubjectName) .toList(); - String s3Key = examTicketInfo.s3Key(); - String examTicketImgUrl = null; - - if (s3Key != null) { - examTicketImgUrl = s3Service.getPreSignedUrl(s3Key); - } + String examTicketImgUrl = getExamTicketImgUrl(examTicketInfo); return ExamTicketResponse.of(examTicketImgUrl, examTicketInfo.userName(), examTicketInfo.birth(), examTicketInfo.examNumber(), subjects, examTicketInfo.schoolName()); - } public ExamApplicationInfoResponse getApplication(Long userId, Long examApplicationId, Long applicationId) { - validateUser(userId, examApplicationId); - //상세 조회는 done 만 가능 -// Integer examApplicationCount = paymentJpaRepository.countByExamApplicationId( -// examApplicationId); List examApplicationEntities = examApplicationJpaRepository.findByApplicationId( applicationId); int lunchCount = (int) examApplicationEntities.stream() @@ -135,6 +123,7 @@ public ExamApplicationInfoResponse getApplication(Long userId, Long examApplicat Set subjects = examSubjects.stream() .map(ExamSubjectJpaEntity::getSubjectName) .collect(Collectors.toSet()); + //totalAmount 는 Lunch 가격이 포함되었을 수도 있음 //totalAmount - Lunch 가격으로 getAppliedDiscountAmount() 메소드에 넣어야함. @@ -153,7 +142,7 @@ public ExamApplicationInfoResponse getApplication(Long userId, Long examApplicat examApplicationInfo.schoolName(), AddressResponse.from(examApplicationInfo.address()), subjects, - examApplicationInfo.isLunchChecked() ? examApplicationInfo.lunchName() : "신청 안 함", + examApplicationInfo.isLunchChecked() ? examApplicationInfo.lunchName() : "도시락 X", paymentAmount, discountAmount, examApplicationInfo.paymentMethod().getName() @@ -187,4 +176,15 @@ private void validateExamTicketOpenDate(LocalDate examDate, String examNumber) { throw new CustomRuntimeException(ErrorCode.EXAM_TICKET_NOT_OPEN); } } + + private String getExamTicketImgUrl(ExamTicketInfoProjection examTicketInfo) { + String s3Key = examTicketInfo.s3Key(); + String examTicketImgUrl = null; + + if (s3Key != null) { + examTicketImgUrl = s3Service.getPreSignedUrl(s3Key); + } + return examTicketImgUrl; + } + } diff --git a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java index 965f5f42..faca75b0 100644 --- a/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java +++ b/src/main/java/life/mosu/mosuserver/application/inquiry/InquiryService.java @@ -3,16 +3,16 @@ import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity; import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; import life.mosu.mosuserver.domain.inquiry.repository.InquiryJpaRepository; -import life.mosu.mosuserver.domain.inquiryAnswer.repository.InquiryAnswerJpaRepository; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserRole; -import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse.InquiryAnswerDetailResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -26,11 +26,9 @@ @RequiredArgsConstructor public class InquiryService { - private final UserJpaRepository userJpaRepository; private final InquiryAttachmentService inquiryAttachmentService; private final InquiryJpaRepository inquiryJpaRepository; private final InquiryAnswerService inquiryAnswerService; - private final InquiryAnswerJpaRepository inquiryAnswerJpaRepository; @Transactional public void createInquiry(UserJpaEntity user, InquiryCreateRequest request) { @@ -59,8 +57,9 @@ public InquiryDetailResponse getInquiryDetail(UserJpaEntity user, Long postId) { } @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) - public Page getMyInquiry(Long userId, Pageable pageable) { - return inquiryJpaRepository.searchMyInquiry(userId, pageable); + public Page getMyInquiry(Long userId, Pageable pageable) { + return inquiryJpaRepository.searchMyInquiry(userId, + pageable); } @Transactional @@ -74,6 +73,17 @@ public void deleteInquiry(UserJpaEntity user, Long postId) { inquiryJpaRepository.delete(inquiry); } + @Transactional + public void updateInquiry(UserJpaEntity user, InquiryUpdateRequest request, Long postId) { + InquiryJpaEntity inquiry = getInquiry(postId); + hasPermission(inquiry.getUserId(), user); + + inquiry.update(request.title(), request.content(), user.getName()); + inquiryJpaRepository.save(inquiry); + + inquiryAttachmentService.updateAttachment(request.attachments(), inquiry); + } + private InquiryDetailResponse toInquiryDetailResponse(InquiryJpaEntity inquiry) { InquiryAnswerDetailResponse answer = inquiryAnswerService.getInquiryAnswerDetail( inquiry.getId()); diff --git a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java index 4405792c..134c94c1 100644 --- a/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java +++ b/src/main/java/life/mosu/mosuserver/application/notice/NoticeService.java @@ -12,6 +12,8 @@ import life.mosu.mosuserver.presentation.notice.dto.NoticeUpdateRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.cache.annotation.CacheEvict; +import org.springframework.cache.annotation.Cacheable; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; @@ -28,6 +30,7 @@ public class NoticeService { private final NoticeJpaRepository noticeJpaRepository; private final NoticeAttachmentService attachmentService; + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void createNotice(NoticeCreateRequest request, UserJpaEntity user) { NoticeJpaEntity noticeEntity = noticeJpaRepository.save(request.toEntity(user)); @@ -44,6 +47,7 @@ public List getNotices(int page, int size) { .toList(); } + @Cacheable(cacheNames = "notice", key = "#noticeId") @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public NoticeDetailResponse getNoticeDetail(Long noticeId) { NoticeJpaEntity notice = getNotice(noticeId); @@ -51,12 +55,14 @@ public NoticeDetailResponse getNoticeDetail(Long noticeId) { return toNoticeDetailResponse(notice); } + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void deleteNotice(Long noticeId) { NoticeJpaEntity noticeEntity = getNotice(noticeId); noticeJpaRepository.delete(noticeEntity); } + @CacheEvict(cacheNames = "notice", allEntries = true) @Transactional public void updateNotice(Long noticeId, NoticeUpdateRequest request, UserJpaEntity user) { NoticeJpaEntity noticeEntity = getNotice(noticeId); diff --git a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java new file mode 100644 index 00000000..76e46921 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserPersistenceProcessor.java @@ -0,0 +1,47 @@ +package life.mosu.mosuserver.application.oauth; + +import life.mosu.mosuserver.domain.user.entity.AuthProvider; +import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserRole; +import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; +import life.mosu.mosuserver.global.processor.StepProcessor; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Component +@RequiredArgsConstructor +public class OAuthUserPersistenceProcessor implements StepProcessor { + + private final UserJpaRepository userRepository; + + @Override + @Transactional + public UserJpaEntity process(final OAuthUserInfo info) { + return userRepository.findByPhoneNumber(info.phoneNumber()) + .map(existingUser -> { + existingUser.updateOAuthUser( + info.gender(), + info.name(), + info.phoneNumber(), + info.birthDay(), + info.marketingAgreed()); + return existingUser; + }) + .orElseGet(() -> { + final UserJpaEntity newUser = UserJpaEntity.builder() + .loginId(info.email()) + .gender(info.gender()) + .name(info.name()) + .birth(info.birthDay()) + .phoneNumber(info.phoneNumber()) + .userRole(UserRole.ROLE_PENDING) + .provider(AuthProvider.KAKAO) + .agreedToTermsOfService(true) + .agreedToPrivacyPolicy(true) + .agreedToMarketing(info.marketingAgreed()) + .build(); + return userRepository.save(newUser); + }); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java index f67b968d..3c0ddf67 100644 --- a/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java +++ b/src/main/java/life/mosu/mosuserver/application/oauth/OAuthUserService.java @@ -1,15 +1,10 @@ package life.mosu.mosuserver.application.oauth; -import java.time.LocalDate; import java.util.Collections; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.domain.profile.entity.Gender; import life.mosu.mosuserver.domain.profile.repository.ProfileJpaRepository; -import life.mosu.mosuserver.domain.user.entity.AuthProvider; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; -import life.mosu.mosuserver.domain.user.entity.UserRole; -import life.mosu.mosuserver.domain.user.repository.UserJpaRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.core.ParameterizedTypeReference; @@ -25,7 +20,7 @@ @RequiredArgsConstructor public class OAuthUserService extends DefaultOAuth2UserService { - private final UserJpaRepository userRepository; + private final OAuthUserPersistenceProcessor oAuthUserPersistenceProcessor; private final ProfileJpaRepository profileRepository; private final WebClient webClient; @@ -44,12 +39,15 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) agreedToMarketing = termsList.stream() .filter(term -> term instanceof Map) .map(term -> (Map) term) - .filter(termMap -> "terms_03".equals(termMap.get("tag"))) + .filter(termMap -> + "terms_03".equals(termMap.get("tag"))) .findFirst() .map(termMap -> (Boolean) termMap.get("agreed")) .orElse(false); } + log.info("동의 여부{}", agreedToMarketing); + final String registrationId = userRequest.getClientRegistration().getRegistrationId(); final String userNameAttributeName = userRequest.getClientRegistration() .getProviderDetails() @@ -59,7 +57,7 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) final OAuthUserInfo userInfo = OAuthUserInfo.of(OAuthProvider.from(registrationId), oAuth2UserAttributes, agreedToMarketing); - final UserJpaEntity oAuthUser = updateOrWrite(userInfo); + final UserJpaEntity oAuthUser = oAuthUserPersistenceProcessor.process(userInfo); Boolean isProfileRegistered = profileRepository.existsByUserId(oAuthUser.getId()); @@ -67,35 +65,6 @@ public OAuth2User loadUser(final OAuth2UserRequest userRequest) isProfileRegistered); } - private UserJpaEntity updateOrWrite(final OAuthUserInfo info) { - return userRepository.findByLoginId(info.email()) - .map(existingUser -> { - existingUser.updateOAuthUser( - info.gender(), - info.name(), - info.phoneNumber(), - info.birthDay() != null ? info.birthDay() : LocalDate.of(1900, 1, 1)); - return existingUser; - }) - .orElseGet(() -> { - final UserJpaEntity newUser = UserJpaEntity.builder() - .loginId(info.email() != null ? info.email() : "NA") - .gender(info.gender() != null ? info.gender() : Gender.PENDING) - .name(info.name() != null ? info.name() : "NA") - .birth(info.birthDay() != null ? info.birthDay() - : LocalDate.EPOCH) - .phoneNumber(info.phoneNumber() != null ? info.phoneNumber() - : "010-0000-0000") - .userRole(UserRole.ROLE_PENDING) - .provider(AuthProvider.KAKAO) - .agreedToTermsOfService(true) - .agreedToPrivacyPolicy(true) - .agreedToMarketing(info.marketingAgreed()) - .build(); - return userRepository.save(newUser); - }); - } - private Map getServiceTerms(String accessToken) { String url = "https://kapi.kakao.com/v2/user/service_terms"; diff --git a/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java b/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java index ffd8d406..3c08190a 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java @@ -1,6 +1,8 @@ package life.mosu.mosuserver.application.refund; import life.mosu.mosuserver.application.refund.processor.TossRefundProcessor; +import life.mosu.mosuserver.domain.payment.entity.PaymentJpaEntity; +import life.mosu.mosuserver.domain.payment.entity.PaymentStatus; import life.mosu.mosuserver.domain.payment.projection.PaymentWithLunchProjection; import life.mosu.mosuserver.domain.payment.repository.PaymentJpaRepository; import life.mosu.mosuserver.domain.refund.entity.RefundJpaEntity; @@ -10,10 +12,12 @@ import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.toss.dto.CancelTossPaymentResponse; import life.mosu.mosuserver.presentation.refund.dto.MergedRefundRequest; +import life.mosu.mosuserver.presentation.refund.dto.RefundAmountResponse; import life.mosu.mosuserver.presentation.refund.dto.RefundRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @@ -28,6 +32,16 @@ public class RefundService { private final TossRefundProcessor tossRefundProcessor; private final PaymentJpaRepository paymentJpaRepository; + @Transactional(propagation = Propagation.SUPPORTS, readOnly = true) + public RefundAmountResponse getRefundAmount(String paymentKey, Long examApplicationId) { + PaymentWithLunchProjection targetPayment = findPaymentOrThrow(paymentKey, + examApplicationId); + + int totalQuantity = getTotalPaymentCount(paymentKey); + int refundAmount = calculateRefundAmount(totalQuantity, targetPayment.lunchChecked()); + return RefundAmountResponse.of(refundAmount); + } + @Transactional public void doProcess(Long userId, MergedRefundRequest request) { RefundRequest details = request.details(); @@ -36,14 +50,23 @@ public void doProcess(Long userId, MergedRefundRequest request) { PaymentWithLunchProjection targetPayment = findPaymentOrThrow(paymentKey, examApplicationId); + log.info("target payment log : {}", targetPayment); int totalQuantity = getTotalPaymentCount(paymentKey); + + log.info("total quantity log : {}", totalQuantity); + int refundAmount = calculateRefundAmount(totalQuantity, targetPayment.lunchChecked()); RefundJpaEntity refundEntity = processRefund(request, refundAmount, targetPayment.examApplicationId()); try { refundJpaRepository.save(refundEntity); + PaymentJpaEntity payment = paymentJpaRepository.findByExamApplicationId( + targetPayment.examApplicationId()) + .orElseThrow(() -> new CustomRuntimeException(ErrorCode.PAYMENT_NOT_FOUND)); + + payment.changeStatus(PaymentStatus.ABORTED); eventTxService.publishSuccessEvent(refundEntity.getTransactionKey(), refundAmount, userId, targetPayment.examId(), examApplicationId); } catch (Exception e) { @@ -63,7 +86,8 @@ private PaymentWithLunchProjection findPaymentOrThrow(String paymentKey, } private int getTotalPaymentCount(String paymentKey) { - return paymentJpaRepository.findByPaymentKey(paymentKey).size(); + return paymentJpaRepository.findByPaymentKeyAndPaymentStatus(paymentKey, PaymentStatus.DONE) + .size(); } private int calculateRefundAmount(int totalQuantity, boolean lunchChecked) { diff --git a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java index 54b2fb76..dccc27df 100644 --- a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java @@ -1,6 +1,7 @@ package life.mosu.mosuserver.application.refund.tx; import life.mosu.mosuserver.application.refund.support.RefundQuotaSyncService; +import life.mosu.mosuserver.domain.payment.repository.PaymentJpaRepository; import life.mosu.mosuserver.global.tx.TxFailureHandler; import life.mosu.mosuserver.infra.notify.dto.luna.LunaNotificationEvent; import life.mosu.mosuserver.infra.notify.dto.luna.LunaNotificationStatus; @@ -19,6 +20,7 @@ public class RefundTxEventListener { private final TxFailureHandler refundFailureHandler; private final NotifyEventPublisher notifier; private final RefundQuotaSyncService quotaSyncService; + private final PaymentJpaRepository paymentJpaRepository; @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) public void afterRollbackHandler(RefundTxEvent event) { @@ -33,7 +35,6 @@ public void afterCommitHandler(RefundTxEvent event) { RefundContext ctx = event.getContext(); quotaSyncService.sync(ctx.examId()); log.info("[AFTER_COMMIT] 환불 성공 후 알림톡 발송 시작: orderId={}", ctx.transactionKey()); - sendNotification(ctx.userId(), ctx.examApplicationId()); } diff --git a/src/main/java/life/mosu/mosuserver/application/user/UserService.java b/src/main/java/life/mosu/mosuserver/application/user/UserService.java index 39c9a663..be991f70 100644 --- a/src/main/java/life/mosu/mosuserver/application/user/UserService.java +++ b/src/main/java/life/mosu/mosuserver/application/user/UserService.java @@ -51,4 +51,8 @@ public UserInfoResponse getUserInfo(Long userId) { return UserInfoResponse.from(user); } + + public Boolean isPhoneNumberAvailable(String phoneNumber) { + return !userJpaRepository.existsByPhoneNumber(phoneNumber); + } } diff --git a/src/main/java/life/mosu/mosuserver/application/virtualaccount/event/DepositSuccessEventHandler.java b/src/main/java/life/mosu/mosuserver/application/virtualaccount/event/DepositSuccessEventHandler.java index 7bca18a7..1c53ec90 100644 --- a/src/main/java/life/mosu/mosuserver/application/virtualaccount/event/DepositSuccessEventHandler.java +++ b/src/main/java/life/mosu/mosuserver/application/virtualaccount/event/DepositSuccessEventHandler.java @@ -23,7 +23,7 @@ public class DepositSuccessEventHandler implements DepositEventHandler new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); diff --git a/src/main/java/life/mosu/mosuserver/domain/admin/projection/RecommendationDetailsProjection.java b/src/main/java/life/mosu/mosuserver/domain/admin/projection/RecommendationDetailsProjection.java index 741804cc..c6a8bc59 100644 --- a/src/main/java/life/mosu/mosuserver/domain/admin/projection/RecommendationDetailsProjection.java +++ b/src/main/java/life/mosu/mosuserver/domain/admin/projection/RecommendationDetailsProjection.java @@ -10,8 +10,8 @@ public record RecommendationDetailsProjection( LocalDate birth, String recommendeeName, String recommendeePhoneNumber, - String recommendeeBank, - String recommendeeAccountNumber + String recommenderBank, + String recommenderAccountNumber ) { } diff --git a/src/main/java/life/mosu/mosuserver/domain/application/entity/Lunch.java b/src/main/java/life/mosu/mosuserver/domain/application/entity/Lunch.java index 835f24e6..2ff07bba 100644 --- a/src/main/java/life/mosu/mosuserver/domain/application/entity/Lunch.java +++ b/src/main/java/life/mosu/mosuserver/domain/application/entity/Lunch.java @@ -9,7 +9,7 @@ @Getter @RequiredArgsConstructor public enum Lunch { - NONE("선택 안 함"), + NONE("도시락 X"), OPTION1("도시락 A"), OPTION2("도시락 B"), OPTION3("비건 도시락"), diff --git a/src/main/java/life/mosu/mosuserver/domain/application/repository/ApplicationJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/application/repository/ApplicationJpaRepository.java index 4eff05c4..38613467 100644 --- a/src/main/java/life/mosu/mosuserver/domain/application/repository/ApplicationJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/application/repository/ApplicationJpaRepository.java @@ -29,21 +29,6 @@ AND a.status IN ('PENDING', 'ABORT') """) List findAllByUserId(@Param("userId") Long userId); - @Query( - """ - SELECT CASE WHEN COUNT(a) > 0 THEN true ELSE false END - FROM ApplicationJpaEntity a - JOIN ExamApplicationJpaEntity ea ON a.id = ea.applicationId - JOIN ExamJpaEntity e ON ea.examId = e.id - JOIN PaymentJpaEntity p ON ea.id = p.examApplicationId - WHERE a.userId = :userId - AND p.paymentStatus = 'DONE' - AND e.id IN :examIds - """ - ) - boolean existsByUserIdAndExamIds(@Param("userId") Long userId, - @Param("examIds") List examIds); - @Modifying @Query(value = """ UPDATE application a diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java new file mode 100644 index 00000000..af1f57e4 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheGroup.java @@ -0,0 +1,33 @@ +package life.mosu.mosuserver.domain.caffeine; + +import java.time.Duration; +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum CacheGroup { + + NOTICE( + "notice", + Duration.ofMinutes(10), + CacheType.LOCAL + ), + + INQUIRY( + "inquiry", + Duration.ofMinutes(10), + CacheType.GLOBAL + ), + + COMPOSITE_ALL( + "composite", + Duration.ofMinutes(10), + CacheType.COMPOSITE + ); + + + private final String cacheName; + private final Duration expiredAfterWrite; + private final CacheType cacheType; +} diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java new file mode 100644 index 00000000..98c4e603 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/CacheType.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.domain.caffeine; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +public enum CacheType { + LOCAL("로컬 타입만 적용"), + GLOBAL("분산 캐시만 적용"), + COMPOSITE("로컬 + 분산 캐시 모두 적용"); + + private final String type; + +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java index 0c2d59c4..77d54a97 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/BlockedIp.java @@ -6,15 +6,18 @@ @Getter public class BlockedIp { + private final TimePenalty penaltyLevel; public BlockedIp(TimePenalty penaltyLevel) { this.penaltyLevel = penaltyLevel; } - public Duration getTtl(){ - return penaltyLevel.getDuration(); + public static BlockedIp init() { + return new BlockedIp(TimePenalty.LEVEL_0); } - + public Duration getTtl() { + return penaltyLevel.getDuration(); + } } diff --git a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java index 7e852423..c05c272b 100644 --- a/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java +++ b/src/main/java/life/mosu/mosuserver/domain/caffeine/dto/RequestCounter.java @@ -1,12 +1,16 @@ package life.mosu.mosuserver.domain.caffeine.dto; -import lombok.Getter; +import java.util.concurrent.atomic.AtomicInteger; -@Getter public class RequestCounter { - private int count = 0; - public void increment() { - count++; + private final AtomicInteger count = new AtomicInteger(); + + public int incrementAndGet() { + return count.incrementAndGet(); + } + + public int getCount() { + return count.get(); } } diff --git a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaEntity.java index 24c157dc..591e999c 100644 --- a/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/exam/entity/ExamJpaEntity.java @@ -80,7 +80,7 @@ public ExamJpaEntity( } public boolean hasNotLunch() { - return lunchName == null; + return this.lunchName == null || this.lunchPrice == null; } public void close() { diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java index a1e378ec..363d9ea6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamApplicationJpaRepository.java @@ -44,9 +44,12 @@ public interface ExamApplicationJpaRepository extends JOIN PaymentJpaEntity p on p.examApplicationId = ea.id WHERE ea.id = :examApplicationId AND p.paymentStatus = 'DONE' + AND ea.userId = :userId + AND p.deleted = false """) - Optional findExamApplicationInfoById(Long userId, - Long examApplicationId); + Optional findExamApplicationInfoById( + @Param("userId") Long userId, + @Param("examApplicationId") Long examApplicationId); @Query(""" @@ -67,6 +70,7 @@ Optional findExamApplicationInfoById(Long userId, WHERE ea.id = :examApplicationId AND u.id = :userId AND p.paymentStatus = 'DONE' + AND p.deleted = false """) Optional findExamTicketInfoProjectionById( @Param("userId") Long userId, @@ -105,6 +109,7 @@ Optional findExamTicketInfoProjectionById( JOIN PaymentJpaEntity p on p.examApplicationId = ea.id WHERE ea.id = :targetId AND p.paymentStatus = 'DONE' + AND p.deleted = false """) Optional findExamAndPaymentByExamApplicationId( @Param("targetId") Long targetId); @@ -121,6 +126,7 @@ Optional findExamAndPaymentByExamApplicationId( JOIN PaymentJpaEntity p ON p.examApplicationId = ea.id WHERE ea.id = :examApplicationId AND p.paymentStatus = 'DONE' + AND p.deleted = false """) Optional findExamInfo(@Param("examApplicationId") Long examApplicationId); @@ -137,6 +143,7 @@ Optional findExamAndPaymentByExamApplicationId( JOIN PaymentJpaEntity p ON p.examApplicationId = ea.id WHERE ea.id = :examApplicationId AND p.paymentStatus = 'DONE' + AND p.deleted = false """) Optional findExamInfoWithExamNumber( @Param("examApplicationId") Long examApplicationId); @@ -148,6 +155,7 @@ SELECT case when COUNT(ea) > 0 then true else false end WHERE ea.id = :examApplicationId AND ea.userId = :userId AND p.paymentStatus = 'DONE' + AND p.deleted = false """) boolean existByUserIdAndExamApplicationId(@Param("userId") Long userId, @Param("examApplicationId") Long examApplicationId); @@ -161,4 +169,29 @@ boolean existByUserIdAndExamApplicationId(@Param("userId") Long userId, AND e.deleted = false """) Optional findByOrderId(String orderId); + + @Query( + """ + SELECT CASE WHEN COUNT(ea) > 0 THEN true ELSE false END + FROM ExamApplicationJpaEntity ea + JOIN PaymentJpaEntity p ON ea.id = p.examApplicationId + WHERE ea.userId = :userId + AND p.paymentStatus = 'DONE' + AND p.deleted = false + AND ea.examId IN :examIds + """ + ) + boolean existsByUserIdAndExamIds( + @Param("userId") Long userId, + @Param("examIds") List examIds); + + + @Query(""" + SELECT COUNT(ea) + FROM ExamApplicationJpaEntity ea + JOIN PaymentJpaEntity p ON ea.id = p.examApplicationId + WHERE p.paymentStatus = 'DONE' + AND p.deleted = false + """) + long countAll(); } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamSubjectJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamSubjectJpaRepository.java index 3aad5dac..e595a9e6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamSubjectJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/repository/ExamSubjectJpaRepository.java @@ -29,6 +29,7 @@ public interface ExamSubjectJpaRepository extends JpaRepository searchInquiries(InquiryStatus status, String sortField, boolean asc, Pageable pageable); - Page searchMyInquiry(Long userId, Pageable pageable); + Page searchMyInquiry(Long userId, Pageable pageable); } diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/repository/PaymentJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/payment/repository/PaymentJpaRepository.java index 85dfb22f..c45efc26 100644 --- a/src/main/java/life/mosu/mosuserver/domain/payment/repository/PaymentJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/payment/repository/PaymentJpaRepository.java @@ -2,8 +2,10 @@ import java.time.LocalDateTime; import java.util.List; -import life.mosu.mosuserver.domain.payment.projection.PaymentWithLunchProjection; +import java.util.Optional; import life.mosu.mosuserver.domain.payment.entity.PaymentJpaEntity; +import life.mosu.mosuserver.domain.payment.entity.PaymentStatus; +import life.mosu.mosuserver.domain.payment.projection.PaymentWithLunchProjection; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; @@ -11,6 +13,8 @@ public interface PaymentJpaRepository extends JpaRepository, PaymentJpaRepositoryCustom { + void deleteByExamApplicationId(Long examApplicationId); + // TODO:인덱스 처리 필요(풀스캔 위험) boolean existsByOrderId(String orderId); @@ -32,7 +36,8 @@ public interface PaymentJpaRepository extends JpaRepository findByPaymentKeyWithLunch(String paymentKey); - List findByPaymentKey(String paymentKey); + List findByPaymentKeyAndPaymentStatus(String paymentKey, + PaymentStatus status); @Query(""" SELECT p @@ -41,4 +46,6 @@ public interface PaymentJpaRepository extends JpaRepository findFailedPayments(@Param("time") LocalDateTime time); + + Optional findByExamApplicationId(Long examApplicationId); } diff --git a/src/main/java/life/mosu/mosuserver/domain/profile/entity/Gender.java b/src/main/java/life/mosu/mosuserver/domain/profile/entity/Gender.java index 74f03bf7..e31a1fe1 100644 --- a/src/main/java/life/mosu/mosuserver/domain/profile/entity/Gender.java +++ b/src/main/java/life/mosu/mosuserver/domain/profile/entity/Gender.java @@ -1,6 +1,8 @@ package life.mosu.mosuserver.domain.profile.entity; import java.util.Arrays; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -18,6 +20,6 @@ public static Gender fromName(String genderName) { .filter(g -> g.getGenderName().equals(genderName)) .findFirst() .orElseThrow( - () -> new IllegalArgumentException("Invalid gender name: " + genderName)); + () -> new CustomRuntimeException(ErrorCode.INVALID_GENDER)); } } diff --git a/src/main/java/life/mosu/mosuserver/domain/profile/entity/ProfileJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/profile/entity/ProfileJpaEntity.java index a75d443a..799c5fe6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/profile/entity/ProfileJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/profile/entity/ProfileJpaEntity.java @@ -89,7 +89,7 @@ public ProfileJpaEntity( public void edit(final EditProfileRequest request) { this.email = request.email(); this.education = request.education(); - this.schoolInfo = request.schoolInfo().toEntity(); + this.schoolInfo = request.schoolInfo() != null ? request.schoolInfo().toEntity() : null; this.grade = request.grade(); } diff --git a/src/main/java/life/mosu/mosuserver/domain/refund/repository/RefundJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/refund/repository/RefundJpaRepository.java index bea9ebcb..a406f6c4 100644 --- a/src/main/java/life/mosu/mosuserver/domain/refund/repository/RefundJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/refund/repository/RefundJpaRepository.java @@ -53,4 +53,5 @@ WHERE r.refundStatus in ('ABORTED') AND r.createdAt < :time """) List findFailedRefunds(@Param("time") LocalDateTime time); + } diff --git a/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java index 280b4a13..a936bcc2 100644 --- a/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/user/entity/UserJpaEntity.java @@ -93,12 +93,14 @@ public void updateOAuthUser( Gender gender, String name, String phoneNumber, - LocalDate birth + LocalDate birth, + boolean agreedToMarketing ) { this.gender = gender; this.name = name; this.phoneNumber = phoneNumber; this.birth = birth; + this.agreedToMarketing = agreedToMarketing; } public void updateUserInfo( diff --git a/src/main/java/life/mosu/mosuserver/domain/user/repository/UserJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/user/repository/UserJpaRepository.java index cd553965..a8d59e8d 100644 --- a/src/main/java/life/mosu/mosuserver/domain/user/repository/UserJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/user/repository/UserJpaRepository.java @@ -2,6 +2,7 @@ import java.util.Optional; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; +import life.mosu.mosuserver.domain.user.entity.UserRole; import org.springframework.data.jpa.repository.JpaRepository; public interface UserJpaRepository extends JpaRepository { @@ -10,8 +11,11 @@ public interface UserJpaRepository extends JpaRepository { boolean existsByLoginId(String loginId); - Optional findByNameAndPhoneNumber(String name, String phoneNumber); Optional findByPhoneNumber(String phoneNumber); + + long countByUserRoleNot(UserRole userRole); + + boolean existsByPhoneNumber(String phoneNumber); } diff --git a/src/main/java/life/mosu/mosuserver/global/annotation/LoginIdPattern.java b/src/main/java/life/mosu/mosuserver/global/annotation/LoginIdPattern.java index d9107131..a5fc650b 100644 --- a/src/main/java/life/mosu/mosuserver/global/annotation/LoginIdPattern.java +++ b/src/main/java/life/mosu/mosuserver/global/annotation/LoginIdPattern.java @@ -2,7 +2,7 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotBlank; +import jakarta.validation.ReportAsSingleViolation; import jakarta.validation.constraints.Pattern; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -10,10 +10,10 @@ import java.lang.annotation.Target; @Pattern(regexp = "^[a-zA-Z0-9_-]{6,12}$", message = "아이디 형식이 올바르지 않습니다.") -@NotBlank @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {}) +@ReportAsSingleViolation public @interface LoginIdPattern { String message() default "아이디는 6~12자의 영문, 숫자, 특수문자(-, _)만 사용 가능합니다."; diff --git a/src/main/java/life/mosu/mosuserver/global/annotation/NotBlankPhoneNumberPattern.java b/src/main/java/life/mosu/mosuserver/global/annotation/NotBlankPhoneNumberPattern.java new file mode 100644 index 00000000..166fb1bd --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/annotation/NotBlankPhoneNumberPattern.java @@ -0,0 +1,27 @@ +package life.mosu.mosuserver.global.annotation; + +import jakarta.validation.Constraint; +import jakarta.validation.Payload; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Pattern; +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Pattern( + regexp = "^01[016789]-\\d{3,4}-\\d{4}$", + message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다." +) +@NotBlank +@Target({ElementType.FIELD, ElementType.PARAMETER}) +@Retention(RetentionPolicy.RUNTIME) +@Constraint(validatedBy = {}) +public @interface NotBlankPhoneNumberPattern { + + String message() default "전화번호 형식은 010-XXXX-XXXX 이어야 합니다."; + + Class[] groups() default {}; + + Class[] payload() default {}; +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java b/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java index 1fa17704..01864693 100644 --- a/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java +++ b/src/main/java/life/mosu/mosuserver/global/annotation/PhoneNumberPattern.java @@ -2,7 +2,6 @@ import jakarta.validation.Constraint; import jakarta.validation.Payload; -import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.Pattern; import java.lang.annotation.ElementType; import java.lang.annotation.Retention; @@ -10,10 +9,10 @@ import java.lang.annotation.Target; @Pattern( - regexp = "^01[016789]-\\d{3,4}-\\d{4}$", + regexp = "^(|01[016789]-\\d{3,4}-\\d{4})$", message = "전화번호 형식은 010-XXXX-XXXX 이어야 합니다." ) -@NotBlank + @Target({ElementType.FIELD, ElementType.PARAMETER}) @Retention(RetentionPolicy.RUNTIME) @Constraint(validatedBy = {}) diff --git a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java b/src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java similarity index 87% rename from src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java rename to src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java index 627b9545..52fab7fa 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/CaffeineCacheConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/CaffeineFilterCacheConfig.java @@ -5,14 +5,14 @@ import com.github.benmanes.caffeine.cache.Expiry; import com.github.benmanes.caffeine.cache.LoadingCache; import java.util.concurrent.TimeUnit; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @Configuration -public class CaffeineCacheConfig { +public class CaffeineFilterCacheConfig { @Bean public Cache ipRequestCountsCache(IpRateLimitingProperties properties) { @@ -41,12 +41,14 @@ public long expireAfterCreate(String key, BlockedIp value, long currentTime) { } @Override - public long expireAfterUpdate(String key, BlockedIp value, long currentTime, long currentDuration) { - return currentDuration; + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return value.getTtl().toNanos(); } @Override - public long expireAfterRead(String key, BlockedIp value, long currentTime, long currentDuration) { + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { return currentDuration; } }) diff --git a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java index c6c9f251..9ebc44a0 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java @@ -4,15 +4,7 @@ import java.util.HashMap; import java.util.List; import java.util.Map; -import life.mosu.mosuserver.application.oauth.OAuthUserService; -import life.mosu.mosuserver.global.filter.TokenExceptionFilter; -import life.mosu.mosuserver.global.filter.TokenFilter; -import life.mosu.mosuserver.global.handler.AuthLogoutHandler; -import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; -import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; -import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.http.HttpMethod; @@ -39,6 +31,16 @@ import org.springframework.web.cors.CorsConfigurationSource; import org.springframework.web.cors.UrlBasedCorsConfigurationSource; +import life.mosu.mosuserver.application.oauth.OAuthUserService; +import life.mosu.mosuserver.global.filter.TokenExceptionFilter; +import life.mosu.mosuserver.global.filter.TokenFilter; +import life.mosu.mosuserver.global.handler.AuthLogoutHandler; +import life.mosu.mosuserver.global.handler.AuthLogoutSuccessHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginFailureHandler; +import life.mosu.mosuserver.global.handler.OAuth2LoginSuccessHandler; +import life.mosu.mosuserver.global.resolver.AuthorizationRequestRedirectResolver; +import lombok.RequiredArgsConstructor; + @Configuration @EnableWebSecurity @EnableMethodSecurity @@ -51,7 +53,8 @@ public class SecurityConfig { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ); private final OAuthUserService userService; private final OAuth2LoginSuccessHandler loginSuccessHandler; diff --git a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java index 806d59aa..6a4ccdb7 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java @@ -1,14 +1,16 @@ package life.mosu.mosuserver.global.config; import java.util.List; -import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; -import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; -import lombok.RequiredArgsConstructor; + import org.springframework.context.annotation.Configuration; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.servlet.config.annotation.CorsRegistry; import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; +import life.mosu.mosuserver.global.resolver.PhoneNumberArgumentResolver; +import life.mosu.mosuserver.global.resolver.UserIdArgumentResolver; +import lombok.RequiredArgsConstructor; + @Configuration @RequiredArgsConstructor public class WebMvcConfig implements WebMvcConfigurer { @@ -33,7 +35,8 @@ public void addCorsMappings(CorsRegistry registry) { "https://api.mosuedu.com", "https://www.mosuedu.com", "https://partnership.mosuedu.com", - "https://admin.mosuedu.com" + "https://admin.mosuedu.com", + "http://dev.mosuedu.com:3000" ) .allowCredentials(true) .maxAge(3600); diff --git a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java index e9918494..e5f3f131 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -32,6 +32,7 @@ public enum ErrorCode { INVALID_SIGN_UP_TOKEN(HttpStatus.UNAUTHORIZED, "유효하지 않은 회원가입 인증 토큰입니다."), MISSING_SIGNUP_TOKEN(HttpStatus.BAD_REQUEST, "회원가입 인증 토큰이 누락되었습니다."), MISSING_PASSWORD_TOKEN(HttpStatus.BAD_REQUEST, "비밀번호 변경 토큰이 누락되었습니다."), + COOKIE_NOT_FOUND(HttpStatus.NOT_FOUND, "쿠키가 존재하지 않습니다."), NOT_FOUND_TOKEN(HttpStatus.NOT_FOUND, "인증 토큰을 찾을 수 없습니다."), NOT_FOUND_ACCESS_TOKEN(HttpStatus.UNAUTHORIZED, "액세스 토큰을 찾을 수 없습니다."), @@ -44,11 +45,13 @@ public enum ErrorCode { USER_INFO_INVALID(HttpStatus.BAD_REQUEST, "유효하지 않은 사용자 정보입니다."), USER_NOT_ACCESS_FORBIDDEN(HttpStatus.BAD_REQUEST, "접근 권한이 없는 사용자입니다"), USER_SAVE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "사용자 저장에 실패했습니다."), + // 신청 관련 에러 WRONG_SUBJECT_TYPE(HttpStatus.BAD_REQUEST, "잘못된 과목명 입니다."), WRONG_LUNCH_TYPE(HttpStatus.BAD_REQUEST, "잘못된 도시락명 입니다."), WRONG_AREA_TYPE(HttpStatus.BAD_REQUEST, "잘못된 지역명 입니다."), WRONG_SUBJECT_COUNT(HttpStatus.BAD_REQUEST, "응시과목은 반드시 다른 과목 2개를 신청해야 합니다."), + NOT_AGREED_TO_TERMS(HttpStatus.BAD_REQUEST, "신청 시 모든 약관에 동의해야 합니다."), // 수험표 관련 에러 EXAM_TICKET_NOT_OPEN(HttpStatus.BAD_REQUEST, "수험표 조회 기간이 아닙니다."), @@ -124,7 +127,8 @@ public enum ErrorCode { // 시험 관련 에러 EXAM_NOT_FOUND(HttpStatus.NOT_FOUND, "시험 정보를 찾을 수 없습니다."), EXAM_DATE_PASSED(HttpStatus.BAD_REQUEST, "이미 지난 시험입니다."), - + EXAM_NOT_APPLIED(HttpStatus.BAD_REQUEST, "1개 이상의 시험을 신청해야 합니다."), + EXAM_DATE_AFTER_DEADLINE(HttpStatus.BAD_REQUEST, "시험일은 접수 마감일 이후여야 합니다."), //lunch 관련 LUNCH_NOT_FOUND(HttpStatus.NOT_FOUND, "점심 정보를 찾을 수 없습니다."), LUNCH_PRICE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "가격 수정에 실패하였습니다."), @@ -162,8 +166,8 @@ public enum ErrorCode { INVALID_VIRTUAL_ACCOUNT_DEPOSIT_EVENT(HttpStatus.BAD_REQUEST, "유효하지 않은 가상 계좌 입금 이벤트입니다."), VIRTUAL_ACCOUNT_CREATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "가상 계좌 생성에 실패했습니다."), - VIRTUAL_ACCOUNT_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "가상 계좌 로그를 찾을 수 없습니다."), - ; + VIRTUAL_ACCOUNT_LOG_NOT_FOUND(HttpStatus.NOT_FOUND, "가상 계좌 로그를 찾을 수 없습니다."); + private final HttpStatus status; private final String message; diff --git a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java index 645da67f..ddf53e9a 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/GlobalExceptionHandler.java @@ -1,8 +1,10 @@ package life.mosu.mosuserver.global.exception; import jakarta.persistence.EntityNotFoundException; -import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.ConstraintViolation; +import jakarta.validation.ConstraintViolationException; import java.util.LinkedHashMap; +import java.util.Locale; import java.util.Map; import life.mosu.mosuserver.infra.notify.NotifyClientAdapter; import life.mosu.mosuserver.infra.notify.dto.discord.DiscordExceptionNotifyEventRequest; @@ -16,6 +18,7 @@ import org.springframework.web.bind.MethodArgumentNotValidException; import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; +import org.springframework.web.method.annotation.HandlerMethodValidationException; import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException; import org.springframework.web.reactive.resource.NoResourceFoundException; @@ -126,7 +129,7 @@ public ResponseEntity handleHttpMessageNotReadableException( return ResponseEntity.status(HttpStatus.CONFLICT).body(response); } - @ExceptionHandler(NoResourceFoundException.class ) + @ExceptionHandler(NoResourceFoundException.class) public ResponseEntity handleNotFound(Exception ex) { notifyIfNeeded(ex); @@ -140,12 +143,40 @@ public ResponseEntity handleNotFound(Exception ex) { } @ExceptionHandler(MethodArgumentTypeMismatchException.class) - public ResponseEntity handleTypeMismatch(MethodArgumentTypeMismatchException ex) { + public ResponseEntity handleTypeMismatch( + MethodArgumentTypeMismatchException ex) { notifyIfNeeded(ex); ErrorResponse response = ErrorResponse.builder() .status(HttpStatus.BAD_REQUEST.value()) .code("TYPE_MISMATCH") - .message("요청 파라미터 타입이 올바르지 않습니다.") + .message(ex.getMessage().toLowerCase(Locale.ROOT)) + .build(); + + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(HandlerMethodValidationException.class) + public ResponseEntity handleHandlerMethodValidation(HandlerMethodValidationException ex) { + notifyIfNeeded(ex); + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("TYPE_MISMATCH") + .message(ex.getMessage().toLowerCase(Locale.ROOT)) + .build(); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); + } + + @ExceptionHandler(ConstraintViolationException.class) + public ResponseEntity handleConstraintViolation(ConstraintViolationException ex) { + notifyIfNeeded(ex); + String message = ex.getConstraintViolations().stream() + .map(ConstraintViolation::getMessage) + .findFirst() + .orElse("유효성 검사에 실패했습니다."); + ErrorResponse response = ErrorResponse.builder() + .status(HttpStatus.BAD_REQUEST.value()) + .code("VAILDATION_ERROR") + .message(message) .build(); return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(response); diff --git a/src/main/java/life/mosu/mosuserver/global/filter/AuthConstants.java b/src/main/java/life/mosu/mosuserver/global/filter/AuthConstants.java index a2e36e42..a884a49e 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/AuthConstants.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/AuthConstants.java @@ -18,6 +18,7 @@ public class AuthConstants { public static final String AUTH_PREFIX = API_PREFIX + "/auth"; public static final String PATH_REISSUE = AUTH_PREFIX + "/reissue"; public static final String PATH_SIGNUP = AUTH_PREFIX + "/signup"; + public static final String COOKIE_ACCESS = AUTH_PREFIX + "/check-cookie"; public static final String USER_PREFIX = API_PREFIX + "/user"; public static final String PATH_PASSWORD_CHANGE = USER_PREFIX + "/me/password"; diff --git a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java index 49f1f3bb..053d2e47 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilter.java @@ -9,13 +9,12 @@ import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import life.mosu.mosuserver.global.config.IpRateLimitingProperties; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; -import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; - -import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Component; @@ -31,59 +30,60 @@ public class IpRateLimitingFilter extends OncePerRequestFilter { private final Cache blockedHistoryCache; private final LoadingCache blockedIpCache; - @Override - protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, - FilterChain filterChain) - throws ServletException, IOException { + protected void doFilterInternal( + HttpServletRequest request, + HttpServletResponse response, + FilterChain filterChain + ) throws ServletException, IOException { if (!ipRateLimitingProperties.isEnabled()) { - log.info("IpRateLimitingFilter disabled"); + log.debug("IpRateLimitingFilter disabled"); filterChain.doFilter(request, response); return; } String ip = getClientIp(request); - isAlreadyBlocked(ip); RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + int after = counter.incrementAndGet(); + int max = ipRateLimitingProperties.getMaxRequestsPerMinute(); - synchronized (counter) { - counter.increment(); - - if (isOverPerMaxRequest(counter)) { - log.warn("차단된 IP: {}, 요청 횟수: {}", ip, counter.getCount()); - handleBlockedIp(ip); - } + if (after > max) { + handleBlockedIp(ip); } - log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, counter.getCount()); + log.debug("IP: {}, 요청 횟수 증가 후: {}", ip, after); log.debug("Cache stats: {}", ipRequestCountsCache.stats()); filterChain.doFilter(request, response); } - private boolean isOverPerMaxRequest(RequestCounter counter) { - return counter.getCount() >= ipRateLimitingProperties.getMaxRequestsPerMinute(); - } - private void handleBlockedIp(String ip) { - BlockedIpHistory history = blockedHistoryCache.get(ip, k -> new BlockedIpHistory(ip)); - TimePenalty nextPenaltyLevel = history.getPenaltyLevel().nextLevel(); - history.updateHistory(nextPenaltyLevel); + BlockedIp existing = blockedIpCache.asMap().putIfAbsent(ip, BlockedIp.init()); + if (existing != null) { + log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, existing.getPenaltyLevel()); + throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); + } + + TimePenalty level = blockedHistoryCache.asMap().compute(ip, (k, history) -> { + BlockedIpHistory h = (history == null) ? new BlockedIpHistory(ip) : history; + TimePenalty next = h.getPenaltyLevel().nextLevel(); + h.updateHistory(next); + return h; + }).getPenaltyLevel(); - blockedIpCache.invalidate(ip); - blockedIpCache.put(ip, new BlockedIp(nextPenaltyLevel)); - log.warn("IP 차단: {}, 차단 레벨: {})", ip, nextPenaltyLevel); + blockedIpCache.asMap().computeIfPresent(ip, (k, v) -> new BlockedIp(level)); throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } - private void isAlreadyBlocked(String requestedIp) { - if(blockedIpCache.getIfPresent(requestedIp) != null){ - log.warn("이미 차단된 IP: {}", requestedIp); + private void isAlreadyBlocked(String ip) { + BlockedIp blockedIp = blockedIpCache.getIfPresent(ip); + if (blockedIp != null) { + log.warn("이미 차단된 IP: {}, 차단 레벨: {}", ip, blockedIp.getPenaltyLevel()); throw new CustomRuntimeException(ErrorCode.TOO_MANY_REQUESTS); } } -} +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java index 9a36da15..12770172 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/TimePenalty.java @@ -7,7 +7,7 @@ @Getter @RequiredArgsConstructor public enum TimePenalty { - LEVEL_0(0, Duration.ZERO), + LEVEL_0(0, Duration.ofSeconds(10)), LEVEL_1(1, Duration.ofMinutes(1)), LEVEL_2(2, Duration.ofMinutes(5)), LEVEL_3(3, Duration.ofMinutes(30)), diff --git a/src/main/java/life/mosu/mosuserver/global/filter/TokenFilter.java b/src/main/java/life/mosu/mosuserver/global/filter/TokenFilter.java index 6a109baf..1ff63eb9 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/TokenFilter.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/TokenFilter.java @@ -78,6 +78,26 @@ protected void doFilterInternal( return; } + if (requestUri.startsWith(AuthConstants.COOKIE_ACCESS)) { + + final TokenCookies tokenCookies = tokenResolver.resolveTokens(request); + String accessToken = tokenCookies.getAccessToken().orElseThrow( + () -> new CustomRuntimeException(ErrorCode.COOKIE_NOT_FOUND) + ); + try { + setAuthentication(accessToken); + + response.setStatus(HttpServletResponse.SC_NO_CONTENT); + return; + } catch (CustomRuntimeException e) { + log.warn("쿠키 토큰 검증 실패: {}", e.getMessage()); + throw e; + } catch (Exception e) { + log.error("쿠키 토큰 검증 중 예외 발생", e); + throw new CustomRuntimeException(ErrorCode.INVALID_TOKEN); + } + } + final TokenCookies tokenCookies = tokenResolver.resolveTokens(request); String accessToken = tokenCookies.getAccessToken().orElseThrow( () -> new CustomRuntimeException(ErrorCode.NOT_FOUND_ACCESS_TOKEN) diff --git a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java index 5f70c3b5..ac0e7486 100644 --- a/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java +++ b/src/main/java/life/mosu/mosuserver/global/filter/Whitelist.java @@ -2,6 +2,7 @@ import jakarta.servlet.http.HttpServletRequest; import java.util.Arrays; +import java.util.List; import java.util.Optional; import lombok.Getter; import lombok.RequiredArgsConstructor; @@ -41,7 +42,8 @@ public enum Whitelist { FAQ("/api/v1/faq", WhitelistMethod.GET), NOTICE("/api/v1/notice", WhitelistMethod.GET), USER_ID_CHECK("/api/v1/user/check-id", WhitelistMethod.GET), - CUSTOMER_KEY_CHECK("/api/v1/user/customer-key", WhitelistMethod.GET), + USER_PHONE_NUMBER_CHECK("/api/v1/user/check-phone-number", WhitelistMethod.GET), + EXAM("/api/v1/exam", WhitelistMethod.GET), EXAM_AREAS("/api/v1/exam/areas", WhitelistMethod.GET), EXAM_ALL("/api/v1/exam/all", WhitelistMethod.GET), @@ -52,10 +54,31 @@ public enum Whitelist { USER_FIND_PASSWORD("/api/v1/user/me/find-password", WhitelistMethod.POST), APPLICATION_GUEST("/api/v1/applications/guest", WhitelistMethod.ALL); + + private static final List AUTH_REQUIRED_EXCEPTIONS = List.of( + new ExceptionRule("/api/v1/exam-application", WhitelistMethod.GET) + ); + private final String path; private final WhitelistMethod method; + public static boolean isAuthException(final HttpServletRequest request) { + String uri = request.getRequestURI(); + String method = request.getMethod(); + for (ExceptionRule rule : AUTH_REQUIRED_EXCEPTIONS) { + if (matchesPath(uri, rule.path()) + && (rule.method() == WhitelistMethod.ALL + || rule.method().name().equalsIgnoreCase(method))) { + return true; + } + } + return false; + } + public static boolean isWhitelisted(final HttpServletRequest request) { + if (isAuthException(request)) { + return false; + } return findMatch(request).isPresent(); } @@ -69,4 +92,16 @@ private static Optional findMatch(final HttpServletRequest request) { .equalsIgnoreCase(requestMethod)) .findFirst(); } + + private static boolean matchesPath(String requestUri, String base) { + if (requestUri == null || base == null) { + return false; + } + String normalizedBase = base.endsWith("/") ? base : base + "/"; + return requestUri.equals(base) || requestUri.startsWith(normalizedBase); + } + + private record ExceptionRule(String path, WhitelistMethod method) { + + } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginFailureHandler.java b/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginFailureHandler.java index 2964bc52..cee9925a 100644 --- a/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginFailureHandler.java +++ b/src/main/java/life/mosu/mosuserver/global/handler/OAuth2LoginFailureHandler.java @@ -1,29 +1,42 @@ package life.mosu.mosuserver.global.handler; +import com.fasterxml.jackson.databind.ObjectMapper; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; import java.io.IOException; +import java.nio.charset.StandardCharsets; +import life.mosu.mosuserver.presentation.auth.dto.request.LoginResponse; +import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.security.core.AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2AuthenticationException; -import org.springframework.security.oauth2.core.OAuth2Error; -import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; +import org.springframework.security.web.authentication.AuthenticationFailureHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; +import org.springframework.web.util.UriUtils; @Slf4j @Component -public class OAuth2LoginFailureHandler extends SimpleUrlAuthenticationFailureHandler { +@RequiredArgsConstructor +public class OAuth2LoginFailureHandler implements + AuthenticationFailureHandler { + + private final ObjectMapper objectMapper; @Override public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response, AuthenticationException exception) throws IOException, ServletException { - if (exception instanceof OAuth2AuthenticationException) { - OAuth2Error error = ((OAuth2AuthenticationException) exception).getError(); - log.info("OAuth2 Error " + error.getErrorCode()); - log.info("OAuth2 Description " + error.getDescription()); - log.info(error.toString()); - } - super.onAuthenticationFailure(request, response, exception); + + LoginResponse loginResponse = LoginResponse.from(); + String jsonResponse = UriUtils.encode(objectMapper.writeValueAsString(loginResponse), + StandardCharsets.UTF_8); + + final String redirectWithAccessToken = UriComponentsBuilder.fromUriString( + "https://www.mosuedu.com/auth/kakao/redirect") + .queryParam("data", jsonResponse) + .build() + .toUriString(); + + response.sendRedirect(redirectWithAccessToken); } } diff --git a/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java b/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java index 8decec77..0cc943a0 100644 --- a/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java +++ b/src/main/java/life/mosu/mosuserver/infra/notify/DiscordNotifier.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.infra.notify; +import jakarta.annotation.PostConstruct; import java.util.Map; import life.mosu.mosuserver.infra.notify.dto.discord.DiscordExceptionNotifyEventRequest; import lombok.RequiredArgsConstructor; @@ -20,15 +21,25 @@ public class DiscordNotifier implements NotifyClientAdapter saveAllExamApplicationsWithSubjects( ps.setLong(5, e.getExamId()); ps.setBoolean(6, e.getIsLunchChecked()); ps.setString(7, e.getExamNumber()); -// ps.setBoolean(8, e.getDeleted()); + ps.setBoolean(8, false); ps.addBatch(); log.info( diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/jpa/InquiryJpaRepositoryImpl.java b/src/main/java/life/mosu/mosuserver/infra/persistence/jpa/InquiryJpaRepositoryImpl.java index 2ac21f89..f82c96c5 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/jpa/InquiryJpaRepositoryImpl.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/jpa/InquiryJpaRepositoryImpl.java @@ -15,6 +15,9 @@ import life.mosu.mosuserver.domain.inquiry.entity.InquiryStatus; import life.mosu.mosuserver.domain.inquiry.entity.QInquiryJpaEntity; import life.mosu.mosuserver.domain.inquiry.repository.InquiryQueryRepository; +import life.mosu.mosuserver.domain.inquiryAnswer.entity.QInquiryAnswerJpaEntity; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryAnswerResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -29,6 +32,7 @@ public class InquiryJpaRepositoryImpl implements InquiryQueryRepository { private final JPAQueryFactory queryFactory; private final EntityManager entityManager; private final QInquiryJpaEntity inquiry = QInquiryJpaEntity.inquiryJpaEntity; + private final QInquiryAnswerJpaEntity inquiryAnswer = QInquiryAnswerJpaEntity.inquiryAnswerJpaEntity; @Override public Page searchInquiries( @@ -38,40 +42,42 @@ public Page searchInquiries( Pageable pageable ) { - JPAQuery query = baseQuery(inquiry) - .where(buildStatusCondition(inquiry, status)) - .orderBy(buildOrderByCondition(sortField, asc)) - .offset(pageable.getOffset()) - .limit(pageable.getPageSize()); + JPAQuery query = baseQuery() + .where(buildStatusCondition(inquiry, status)); long total = getTotalCount(query, inquiry.count()); - List content = query.fetch().stream() - .map(this::mapToResponse) + List content = query + .orderBy(buildOrderByCondition(sortField, asc)) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch().stream() + .map(this::mapToInquiryResponse) .toList(); return new PageImpl<>(content, pageable, total); } @Override - public Page searchMyInquiry(Long userId, Pageable pageable) { - JPAQuery query = baseQuery(inquiry) + public Page searchMyInquiry(Long userId, Pageable pageable) { + JPAQuery query = baseQueryWithAnswer() .where(inquiry.userId.eq(userId)); - long total = getTotalCount(query, inquiry.count()); + long total = getTotalCount(query, inquiry.countDistinct()); - List content = query + List content = query + .orderBy(inquiry.createdAt.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) .fetch().stream() - .map(this::mapToResponse) + .map(this::mapToInquiryListResponse) .toList(); return new PageImpl<>(content, pageable, total); } - private JPAQuery baseQuery(QInquiryJpaEntity inquiry) { + private JPAQuery baseQuery() { return queryFactory .select( inquiry.id, @@ -85,6 +91,27 @@ private JPAQuery baseQuery(QInquiryJpaEntity inquiry) { } + private JPAQuery baseQueryWithAnswer() { + return queryFactory + .select( + inquiry.id, + inquiry.title, + inquiry.content, + inquiry.author, + inquiry.status, + inquiry.createdAt, + inquiryAnswer.id, + inquiryAnswer.title, + inquiryAnswer.content, + inquiryAnswer.author, + inquiryAnswer.createdAt, + inquiryAnswer.updatedAt + ) + .from(inquiry) + .leftJoin(inquiryAnswer) + .on(inquiryAnswer.inquiryId.eq(inquiry.id)); + } + private BooleanExpression buildStatusCondition(QInquiryJpaEntity inquiry, InquiryStatus status) { return status != null ? inquiry.status.eq(status) : null; @@ -108,7 +135,7 @@ private long getTotalCount(JPAQuery query, Expression countExpressi ).orElse(0L); } - private InquiryResponse mapToResponse(Tuple tuple) { + private InquiryResponse mapToInquiryResponse(Tuple tuple) { InquiryStatus status = tuple.get(inquiry.status); return new InquiryResponse( tuple.get(inquiry.id), @@ -119,4 +146,22 @@ private InquiryResponse mapToResponse(Tuple tuple) { formatDate(tuple.get(inquiry.createdAt)) ); } + + + private InquiryListResponse mapToInquiryListResponse(Tuple tuple) { + InquiryResponse inquiryDto = mapToInquiryResponse(tuple); + InquiryAnswerResponse answerDto = null; + + if (tuple.get(inquiryAnswer.id) != null) { + answerDto = new InquiryAnswerResponse( + tuple.get(inquiryAnswer.title), + tuple.get(inquiryAnswer.content), + tuple.get(inquiryAnswer.author), + formatDate(tuple.get(inquiryAnswer.createdAt)), + formatDate(tuple.get(inquiryAnswer.updatedAt)) + ); + } + + return InquiryListResponse.of(inquiryDto, answerDto); + } } diff --git a/src/main/java/life/mosu/mosuserver/infra/persistence/s3/S3Service.java b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/S3Service.java index 823f978b..1abdec88 100644 --- a/src/main/java/life/mosu/mosuserver/infra/persistence/s3/S3Service.java +++ b/src/main/java/life/mosu/mosuserver/infra/persistence/s3/S3Service.java @@ -31,13 +31,23 @@ @RequiredArgsConstructor public class S3Service { + private static final int MAX_FILENAME_LENGTH = 150; + private static final int MAX_S3_KEY_LENGTH = 255; + private final S3Client s3Client; private final S3Presigner s3Presigner; private final S3Properties s3Properties; public FileUploadResponse uploadFile(MultipartFile file, Folder folder) { String sanitizedName = sanitizeFileName(file.getOriginalFilename()); - String s3Key = folder.getPath() + "/" + UUID.randomUUID() + "_" + sanitizedName; + String randomPrefix = UUID.randomUUID().toString(); + String s3Key = folder.getPath() + "/" + randomPrefix + "_" + sanitizedName; + + if (s3Key.length() > MAX_S3_KEY_LENGTH) { + int excess = s3Key.length() - MAX_S3_KEY_LENGTH; + sanitizedName = sanitizedName.substring(0, sanitizedName.length() - excess); + s3Key = folder.getPath() + "/" + randomPrefix + "_" + sanitizedName; + } try { s3Client.putObject( @@ -108,12 +118,23 @@ public String getPreSignedUrl(String s3Key) { } private String sanitizeFileName(String originalFilename) { - try { - return URLEncoder.encode(originalFilename, StandardCharsets.UTF_8) - .replaceAll("\\+", "%20"); - } catch (Exception e) { - throw new RuntimeException("파일 이름 인코딩 실패", e); + + String encoded = URLEncoder.encode(originalFilename, StandardCharsets.UTF_8) + .replaceAll("\\+", "%20"); + + // 파일명만 잘라내기 (확장자 유지) + String extension = ""; + int dotIndex = encoded.lastIndexOf('.'); + if (dotIndex != -1) { + extension = encoded.substring(dotIndex); + encoded = encoded.substring(0, dotIndex); + } + + if (encoded.length() > MAX_FILENAME_LENGTH) { + encoded = encoded.substring(0, MAX_FILENAME_LENGTH); } + + return encoded; } private String shortenKey(String key) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminBannerController.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminBannerController.java index eb852b2f..b8257969 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminBannerController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminBannerController.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.presentation.admin; +import jakarta.validation.Valid; import java.util.List; import life.mosu.mosuserver.application.admin.AdminBannerService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; @@ -28,7 +29,7 @@ public class AdminBannerController { @PostMapping @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> create( - @RequestBody BannerRequest request) { + @Valid @RequestBody BannerRequest request) { adminBannerService.create(request); return ResponseEntity.ok( ApiResponseWrapper.success(HttpStatus.OK, "배너 등록 성공")); diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/BannerRequest.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/BannerRequest.java index 1abb7a7d..ddfece7b 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/BannerRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/BannerRequest.java @@ -12,7 +12,6 @@ public record BannerRequest( FileRequest file ) { - //배너 등록할 때 fileRequest 가 없을 때는 어떤 식으로 하면 되는지 public BannerJpaEntity toEntity() { return BannerJpaEntity.builder() .fileName(file != null ? file.fileName() : null) diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/DashBoardResponse.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/DashBoardResponse.java index 0b63f660..1ef7589a 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/DashBoardResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/DashBoardResponse.java @@ -2,12 +2,20 @@ public record DashBoardResponse( Long applicationCounts, - Long refundCounts, + Long refundAbortedCounts, Long userCounts ) { - public static DashBoardResponse of(Long applicationCounts, Long refundCounts, Long userCounts) { - return new DashBoardResponse(applicationCounts, refundCounts, userCounts); + public static DashBoardResponse of( + Long applicationCounts, + Long refundAbortedCounts, + Long userCounts + ) { + return new DashBoardResponse( + applicationCounts, + refundAbortedCounts, + userCounts + ); } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/RecommendationExcelDto.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/RecommendationExcelDto.java index 9cd9e401..3fe78aa7 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/RecommendationExcelDto.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/RecommendationExcelDto.java @@ -31,13 +31,13 @@ public record RecommendationExcelDto( @ExcelColumn(headerName = "피추천자 전화번호") String recommendeePhoneNumber, - @Schema(description = "피추천자 은행명", example = "신한은행") - @ExcelColumn(headerName = "피추천자 은행명") - String recommendeeBank, + @Schema(description = "추천자 은행명", example = "신한은행") + @ExcelColumn(headerName = "추천자 은행명") + String recommenderBank, - @Schema(description = "피추천자 계좌번호", example = "110123456789") - @ExcelColumn(headerName = "피추천자 계좌번호") - String recommendeeAccountNumber + @Schema(description = "추천자 계좌번호", example = "110123456789") + @ExcelColumn(headerName = "추천자 계좌번호") + String recommenderAccountNumber ) { public static RecommendationExcelDto of(RecommendationDetailsProjection projection) { @@ -48,8 +48,8 @@ public static RecommendationExcelDto of(RecommendationDetailsProjection projecti projection.birth(), projection.recommendeeName(), projection.recommendeePhoneNumber(), - projection.recommendeeBank(), - projection.recommendeeAccountNumber() + projection.recommenderBank(), + projection.recommenderAccountNumber() ); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/AgreementRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/AgreementRequest.java index 37f1ed2a..ef15a8f1 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/AgreementRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/AgreementRequest.java @@ -13,4 +13,8 @@ public record AgreementRequest( ) { + public boolean validateAgreement() { + return agreedToNotices && agreedToRefundPolicy; + } + } diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationGuestRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationGuestRequest.java index 15d66de9..64b702cc 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationGuestRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationGuestRequest.java @@ -13,7 +13,7 @@ import life.mosu.mosuserver.domain.user.entity.AuthProvider; import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserRole; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.presentation.common.FileRequest; @@ -27,7 +27,7 @@ public record ApplicationGuestRequest( String userName, @JsonFormat(pattern = "yyyy-MM-dd") LocalDate birth, - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber, @NotNull ExamApplicationRequest examApplication, diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationRequest.java index 5a168cfc..f8457695 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationRequest.java @@ -30,8 +30,9 @@ public record ApplicationRequest( @NotNull AgreementRequest agreement, - @Schema(description = "응시 과목 목록 (예: PHYSICS_1)", example = "[\"PHYSICS_1\", \"ETHICS_AND_IDEOLOGY\"]") - Set subjects + @Schema(description = "응시 과목 목록") + @NotNull + List subjects ) { public ApplicationJpaEntity toApplicationJpaEntity(Long userId) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java index a75acc3a..b99090b5 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthController.java @@ -12,6 +12,7 @@ import org.springframework.http.HttpHeaders; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; @@ -42,6 +43,13 @@ public ResponseEntity> login( )); } + @GetMapping("/check-cookie") + public ResponseEntity checkToken() { + return ResponseEntity.ok().build(); + } + + ; + private HttpHeaders applyTokenHeader(Token token) { HttpHeaders headers = new HttpHeaders(); diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java index 59024185..88411980 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/AuthControllerDocs.java @@ -15,4 +15,7 @@ public interface AuthControllerDocs { @Operation(description = "로그인 API 지금은 쿠키와 response 둘다 반환하는데 곧 쿠키로만 작동하게 할 것 입니다. <프론트하고 변경하려고 Response 이렇게 만들었는데 나중에 같이 맞춥시다!>", summary = "사용자가 로그인합니다.") public ResponseEntity> login( @RequestBody @Valid final LoginRequest request); + + @Operation(description = "쿠키 검증용 API", summary = "쿠키가 유효한지 확인합니다.") + public ResponseEntity checkToken(); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/LoginResponse.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/LoginResponse.java index 4f9f35ef..7f206fe4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/LoginResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/LoginResponse.java @@ -21,4 +21,8 @@ public static LoginResponse from(Boolean isProfileRegistered, final UserJpaEntit } return new LoginResponse(false, LoginUserResponse.from(user)); } + + public static LoginResponse from() { + return new LoginResponse(null, null); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java index b4ae1690..ba97e99e 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/auth/dto/request/SignUpAccountRequest.java @@ -12,8 +12,8 @@ import life.mosu.mosuserver.domain.user.entity.UserJpaEntity; import life.mosu.mosuserver.domain.user.entity.UserRole; import life.mosu.mosuserver.global.annotation.LoginIdPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; import life.mosu.mosuserver.global.annotation.PasswordPattern; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; import org.springframework.security.crypto.password.PasswordEncoder; public record SignUpAccountRequest( @@ -45,7 +45,7 @@ public record SignUpAccountRequest( @Schema(description = "휴대폰 번호", example = "010-1234-5678", required = true) @NotBlank(message = "휴대폰 번호는 필수입니다.") - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber, SignUpServiceTermRequest serviceTermRequest @@ -58,7 +58,10 @@ public UserJpaEntity toAuthEntity(PasswordEncoder passwordEncoder) { .agreedToTermsOfService(true) .agreedToPrivacyPolicy(true) .agreedToMarketing(serviceTermRequest.agreedToMarketing()) - .gender(Gender.PENDING) + .gender(Gender.fromName(gender)) + .name(userName) + .phoneNumber(phoneNumber) + .birth(birth) .provider(AuthProvider.MOSU) .userRole(UserRole.ROLE_PENDING) .build(); diff --git a/src/main/java/life/mosu/mosuserver/presentation/event/dto/DurationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/event/dto/DurationRequest.java index 91120477..ebc06f04 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/event/dto/DurationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/event/dto/DurationRequest.java @@ -6,16 +6,13 @@ @Schema(description = "이벤트 기간 요청 DTO") public record DurationRequest( - - @Schema(description = "이벤트 시작일", example = "2025-07-01") - LocalDate startDate, - + @Schema(description = "이벤트 종료일", example = "2025-07-31") LocalDate endDate ) { public DurationJpaVO toDurationJpaVO() { - return new DurationJpaVO(startDate, endDate); + return new DurationJpaVO(LocalDate.now(), endDate); } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventRequest.java b/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventRequest.java index 2c3af3f9..ae0241e7 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventRequest.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; -import java.util.List; import life.mosu.mosuserver.domain.event.entity.EventJpaEntity; import life.mosu.mosuserver.domain.file.Visibility; import life.mosu.mosuserver.presentation.common.FileRequest; @@ -25,18 +24,18 @@ public record EventRequest( ) { - public List optionalAttachment() { - FileRequest parsedAttachment = this.attachment; - return parsedAttachment == null ? List.of() : List.of(parsedAttachment); - } +// public List optionalAttachment() { +// FileRequest parsedAttachment = this.attachment; +// return parsedAttachment == null ? List.of() : List.of(parsedAttachment); +// } public EventJpaEntity toEntity() { return EventJpaEntity.builder() .title(title) .eventLink(eventLink) - .duration(duration.toDurationJpaVO()) - .fileName(attachment().fileName()) - .s3Key(attachment().s3Key()) + .duration(duration != null ? duration.toDurationJpaVO() : null) + .fileName(attachment != null ? attachment().fileName() : null) + .s3Key(attachment != null ? attachment().s3Key() : null) .visibility(Visibility.PUBLIC) .build(); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventResponse.java b/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventResponse.java index d346a04f..ed33a7f9 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/event/dto/EventResponse.java @@ -34,8 +34,9 @@ public static EventResponse of(EventJpaEntity event, String eventUrl) { return new EventResponse( event.getId(), event.getTitle(), - event.getDuration().getEndDate(), - event.getEventLink(), + event.getDuration() != null ? event.getDuration().getEndDate() + : null, + event.getEventLink() != null ? event.getEventLink() : null, attachment ); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java b/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java index 5ff384c2..9e18c357 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java @@ -1,5 +1,6 @@ package life.mosu.mosuserver.presentation.exam; +import jakarta.validation.Valid; import java.util.List; import life.mosu.mosuserver.application.exam.ExamService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; @@ -29,7 +30,7 @@ public class ExamController { @PostMapping @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> register( - @RequestBody ExamRequest request + @Valid @RequestBody ExamRequest request ) { examService.register(request); return ResponseEntity.ok( @@ -65,10 +66,9 @@ public ResponseEntity> delete(@PathVariable Long examId examService.delete(examId); return ResponseEntity.ok( ApiResponseWrapper.success(HttpStatus.OK, "시험장 삭제 성공")); - } - @PatchMapping("/{examId}") + @PatchMapping("/{examId}/close") @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity> close(@PathVariable Long examId) { examService.close(examId); diff --git a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamRequest.java b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamRequest.java index 2c793688..e5b0616f 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamRequest.java @@ -1,5 +1,7 @@ package life.mosu.mosuserver.presentation.exam.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; import java.time.LocalDate; import java.time.LocalDateTime; import life.mosu.mosuserver.domain.exam.entity.AddressJpaVO; @@ -7,11 +9,11 @@ import life.mosu.mosuserver.domain.exam.entity.ExamJpaEntity; public record ExamRequest( - String schoolName, - String areaName, - AddressRequest address, - LocalDate examDate, - Integer capacity, + @NotBlank String schoolName, + @NotBlank String areaName, + @NotNull AddressRequest address, + @NotNull LocalDate examDate, + @NotNull Integer capacity, LocalDateTime deadlineTime, LunchRequest lunch ) { @@ -25,8 +27,8 @@ public ExamJpaEntity toEntity() { .examDate(examDate) .capacity(capacity) .deadlineTime(deadlineTime) - .lunchName(lunch.name()) - .lunchPrice(lunch.price()) + .lunchName(lunch != null ? lunch.name() : null) + .lunchPrice(lunch != null ? lunch.price() : null) .build(); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java index a34c0d74..8d2cb577 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryController.java @@ -7,7 +7,8 @@ import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse; -import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryUpdateRequest; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -20,6 +21,7 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; @@ -40,14 +42,25 @@ public ResponseEntity> create( return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.CREATED, "질문 등록 성공")); } + @PutMapping("/{postId}") + @PreAuthorize("isAuthenticated() and hasRole('USER')") + public ResponseEntity> update( + @PathVariable Long postId, + @AuthenticationPrincipal PrincipalDetails principalDetails, + @RequestBody @Valid InquiryUpdateRequest request) { + inquiryService.updateInquiry(principalDetails.user(), request, postId); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "질문 수정 성공")); + } + @GetMapping("/my") @PreAuthorize("isAuthenticated() and hasRole('USER')") - public ResponseEntity>> getMyInquiries( + public ResponseEntity>> getMyInquiries( @UserId Long userId, @PageableDefault(size = 10) Pageable pageable ) { - Page inquiries = inquiryService.getMyInquiry(userId, pageable); - return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "내 질문 목록 조회 성공", inquiries)); + Page inquiries = inquiryService.getMyInquiry(userId, pageable); + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "내 질문 목록 조회 성공", inquiries)); } @GetMapping("/{postId}") @@ -56,7 +69,8 @@ public ResponseEntity> getInquiryDetai @AuthenticationPrincipal PrincipalDetails principalDetails, @PathVariable Long postId) { - InquiryDetailResponse inquiry = inquiryService.getInquiryDetail(principalDetails.user(), postId); + InquiryDetailResponse inquiry = inquiryService.getInquiryDetail(principalDetails.user(), + postId); return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "질문 상세 조회 성공", inquiry)); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryControllerDocs.java index e592404c..f58ebf49 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/InquiryControllerDocs.java @@ -13,7 +13,7 @@ import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryCreateRequest; import life.mosu.mosuserver.presentation.inquiry.dto.InquiryDetailResponse; -import life.mosu.mosuserver.presentation.inquiry.dto.InquiryResponse; +import life.mosu.mosuserver.presentation.inquiry.dto.InquiryListResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.http.ResponseEntity; @@ -37,7 +37,7 @@ ResponseEntity> create( @ApiResponse(responseCode = "200", description = "내 문의글 목록 조회 성공", content = @Content(schema = @Schema(implementation = Page.class))) }) - ResponseEntity>> getMyInquiries( + ResponseEntity>> getMyInquiries( @Parameter(description = "사용자 ID", required = true) Long userId, @Parameter(description = "페이지 정보") Pageable pageable ); diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerResponse.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerResponse.java new file mode 100644 index 00000000..a6adb4ad --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryAnswerResponse.java @@ -0,0 +1,11 @@ +package life.mosu.mosuserver.presentation.inquiry.dto; + +public record InquiryAnswerResponse( + String title, + String content, + String author, + String createdAt, + String updatedAt +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryListResponse.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryListResponse.java new file mode 100644 index 00000000..ba42c635 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryListResponse.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.presentation.inquiry.dto; + +public record InquiryListResponse( + InquiryResponse inquiry, + InquiryAnswerResponse reply +) { + + public static InquiryListResponse of( + InquiryResponse inquiry, + InquiryAnswerResponse reply + ) { + return new InquiryListResponse(inquiry, reply); + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryResponse.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryResponse.java index 33332506..e2a8cf04 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryResponse.java @@ -1,7 +1,6 @@ package life.mosu.mosuserver.presentation.inquiry.dto; import io.swagger.v3.oas.annotations.media.Schema; -import life.mosu.mosuserver.domain.inquiry.entity.InquiryJpaEntity; @Schema(description = "1:1 문의 응답 DTO") public record InquiryResponse( @@ -24,14 +23,5 @@ public record InquiryResponse( String createdAt ) { - public static InquiryResponse of(InquiryJpaEntity inquiry) { - return new InquiryResponse( - inquiry.getId(), - inquiry.getTitle(), - inquiry.getContent(), - inquiry.getAuthor(), - inquiry.getStatus().getStatusName(), - inquiry.getCreatedAt() - ); - } + } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java new file mode 100644 index 00000000..4d83c432 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/inquiry/dto/InquiryUpdateRequest.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.presentation.inquiry.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import java.util.List; +import life.mosu.mosuserver.presentation.common.FileRequest; + +public record InquiryUpdateRequest( + @Size(max = 300, message = "제목은 최대 300자까지 입력 가능합니다.") + @Schema(description = "문의 제목", example = "서비스 이용 관련 질문입니다.") + @NotNull String title, + + @Size(max = 1000, message = "본문은 최대 1000자까지 입력 가능합니다.") + @Schema(description = "문의 내용", example = "포인트는 어떻게 사용하나요?") + @NotNull String content, + List attachments +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/EditProfileRequest.java b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/EditProfileRequest.java index 40f439b6..003733bf 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/EditProfileRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/EditProfileRequest.java @@ -9,7 +9,7 @@ public record EditProfileRequest( @Schema(description = "이메일 주소", example = "hong@example.com") String email, - @Schema(description = "학력 정보 (Enum: ELEMENTARY, MIDDLE, HIGH_SCHOOL, UNIVERSITY 등)", example = "HIGH_SCHOOL") + @Schema(description = "학력 정보") Education education, @Schema(description = "학교 정보", implementation = SchoolInfoRequest.class) diff --git a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/RecommenderRegistrationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/RecommenderRegistrationRequest.java index ff2ddebe..0a790ecd 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/RecommenderRegistrationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/RecommenderRegistrationRequest.java @@ -1,11 +1,11 @@ package life.mosu.mosuserver.presentation.profile.dto; import io.swagger.v3.oas.annotations.media.Schema; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; public record RecommenderRegistrationRequest( @Schema(description = "추천인 전화번호 (전화번호 형식은 010-XXXX-XXXX 이어야 합니다.)", example = "010-8765-4322") - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/SignUpProfileRequest.java b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/SignUpProfileRequest.java index 54506198..074ea638 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/profile/dto/SignUpProfileRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/profile/dto/SignUpProfileRequest.java @@ -9,7 +9,7 @@ import life.mosu.mosuserver.domain.profile.entity.Gender; import life.mosu.mosuserver.domain.profile.entity.Grade; import life.mosu.mosuserver.domain.profile.entity.ProfileJpaEntity; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; @Schema(description = "프로필 등록 요청 DTO") public record SignUpProfileRequest( @@ -29,7 +29,7 @@ public record SignUpProfileRequest( @Schema(description = "휴대폰 번호", example = "010-1234-5678", required = true) @NotBlank(message = "휴대폰 번호는 필수입니다.") - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber, @Schema(description = "이메일 주소", example = "hong@example.com") diff --git a/src/main/java/life/mosu/mosuserver/presentation/recommendation/dto/RecommendationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/recommendation/dto/RecommendationRequest.java index aceb9692..21fae3ea 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/recommendation/dto/RecommendationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/recommendation/dto/RecommendationRequest.java @@ -2,11 +2,11 @@ import jakarta.validation.constraints.NotBlank; import life.mosu.mosuserver.domain.recommendation.RecommendationJpaEntity; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; public record RecommendationRequest( @NotBlank String name, - @PhoneNumberPattern String phoneNumber, + @NotBlankPhoneNumberPattern String phoneNumber, @NotBlank String bank, @NotBlank String accountNumber ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java b/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java index 85688606..ce09a8e4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java @@ -4,16 +4,19 @@ import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.refund.dto.MergedRefundRequest; +import life.mosu.mosuserver.presentation.refund.dto.RefundAmountResponse; import life.mosu.mosuserver.presentation.refund.dto.RefundRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController @@ -24,6 +27,15 @@ public class RefundController { private final RefundService refundService; + @GetMapping() + ResponseEntity> getRefundAmount( + @RequestParam(required = true) String paymentKey, + @RequestParam(required = true) Long examApplicationId) { + var response = refundService.getRefundAmount(paymentKey, examApplicationId); + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "환불 금액 조회 성공", response)); + } + @PostMapping("/{paymentKey}") @PreAuthorize("isAuthenticated() and hasRole('USER')") ResponseEntity> process( diff --git a/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundAmountResponse.java b/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundAmountResponse.java new file mode 100644 index 00000000..2cfd00f8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundAmountResponse.java @@ -0,0 +1,11 @@ +package life.mosu.mosuserver.presentation.refund.dto; + +public record RefundAmountResponse( + int amount +) { + + public static RefundAmountResponse of(int amount) { + return new RefundAmountResponse(amount); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/UserController.java b/src/main/java/life/mosu/mosuserver/presentation/user/UserController.java index ca87dbdd..016bb936 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/UserController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/UserController.java @@ -1,15 +1,19 @@ package life.mosu.mosuserver.presentation.user; import life.mosu.mosuserver.application.user.UserService; +import life.mosu.mosuserver.global.annotation.LoginIdPattern; +import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; -import life.mosu.mosuserver.presentation.user.dto.request.IsLoginIdAvailableResponse; import life.mosu.mosuserver.presentation.user.dto.response.CustomerKeyResponse; +import life.mosu.mosuserver.presentation.user.dto.response.IsLoginIdAvailableResponse; +import life.mosu.mosuserver.presentation.user.dto.response.IsPhoneNumberAvailableResponse; import life.mosu.mosuserver.presentation.user.dto.response.UserInfoResponse; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; @@ -18,6 +22,7 @@ @RestController @RequestMapping("/user") @RequiredArgsConstructor +@Validated public class UserController implements UserControllerDocs { private final UserService userService; @@ -46,7 +51,7 @@ public ResponseEntity> getCustomerKey( @GetMapping("/check-id") public ResponseEntity> isLoginIdAvailable( - @RequestParam String loginId + @LoginIdPattern @RequestParam String loginId ) { Boolean isLoginIdAvailable = userService.isLoginIdAvailable(loginId); @@ -55,4 +60,14 @@ public ResponseEntity> isLoginIdA IsLoginIdAvailableResponse.from(isLoginIdAvailable))); } + @GetMapping("/check-phone-number") + public ResponseEntity> isPhoneNumberAvailable( + @PhoneNumberPattern @RequestParam String phoneNumber + ) { + Boolean isPhoneNumberAvailable = userService.isPhoneNumberAvailable(phoneNumber); + + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "User PhoneNumber 등록 가능 여부 조회 성공", + IsPhoneNumberAvailableResponse.from(isPhoneNumberAvailable))); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/UserControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/user/UserControllerDocs.java index a7263050..64fc6785 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/UserControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/UserControllerDocs.java @@ -7,14 +7,17 @@ import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; +import life.mosu.mosuserver.global.annotation.LoginIdPattern; import life.mosu.mosuserver.global.annotation.UserId; import life.mosu.mosuserver.global.util.ApiResponseWrapper; -import life.mosu.mosuserver.presentation.user.dto.request.IsLoginIdAvailableResponse; import life.mosu.mosuserver.presentation.user.dto.response.CustomerKeyResponse; +import life.mosu.mosuserver.presentation.user.dto.response.IsLoginIdAvailableResponse; import org.springframework.http.ResponseEntity; +import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RequestParam; @Tag(name = "User", description = "사용자 관련 API") +@Validated public interface UserControllerDocs { @Operation(summary = "고객 키 조회", description = "사용자 ID를 이용해 결제에 사용될 고객 키(Customer Key)를 조회합니다.") @@ -37,6 +40,6 @@ public ResponseEntity> getCustomerKey( @ApiResponse(responseCode = "500", description = "서버 내부 오류") }) public ResponseEntity> isLoginIdAvailable( - @RequestParam String loginId + @LoginIdPattern @RequestParam String loginId ); } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindLoginIdRequest.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindLoginIdRequest.java index 3ff2fe68..a3990ee3 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindLoginIdRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindLoginIdRequest.java @@ -1,10 +1,10 @@ package life.mosu.mosuserver.presentation.user.dto.request; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; public record FindLoginIdRequest( String name, - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindPasswordRequest.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindPasswordRequest.java index 95f97940..08dcd5b7 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindPasswordRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/FindPasswordRequest.java @@ -1,13 +1,13 @@ package life.mosu.mosuserver.presentation.user.dto.request; import life.mosu.mosuserver.global.annotation.LoginIdPattern; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; +import life.mosu.mosuserver.global.annotation.NotBlankPhoneNumberPattern; public record FindPasswordRequest( String name, @LoginIdPattern String loginId, - @PhoneNumberPattern + @NotBlankPhoneNumberPattern String phoneNumber ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/IsLoginIdAvailableResponse.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsLoginIdAvailableResponse.java similarity index 79% rename from src/main/java/life/mosu/mosuserver/presentation/user/dto/request/IsLoginIdAvailableResponse.java rename to src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsLoginIdAvailableResponse.java index 81854a7f..48a0aa70 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/user/dto/request/IsLoginIdAvailableResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsLoginIdAvailableResponse.java @@ -1,4 +1,4 @@ -package life.mosu.mosuserver.presentation.user.dto.request; +package life.mosu.mosuserver.presentation.user.dto.response; public record IsLoginIdAvailableResponse( Boolean isLoginIdAvailable diff --git a/src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsPhoneNumberAvailableResponse.java b/src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsPhoneNumberAvailableResponse.java new file mode 100644 index 00000000..91a5ff05 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/user/dto/response/IsPhoneNumberAvailableResponse.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.presentation.user.dto.response; + +public record IsPhoneNumberAvailableResponse( + Boolean isPhoneNumberAvailable +) { + + public static IsPhoneNumberAvailableResponse from(Boolean isPhoneNumberAvailable) { + return new IsPhoneNumberAvailableResponse(isPhoneNumberAvailable); + } +} diff --git a/src/main/resources/application-base.yml b/src/main/resources/application-base.yml new file mode 100644 index 00000000..c74b38c9 --- /dev/null +++ b/src/main/resources/application-base.yml @@ -0,0 +1,88 @@ +server: + port: ${SPRING_PORT} + servlet: + context-path: ${BASE_PATH} + session: + cookie: + same-site: none + secure: false + error: + include-stacktrace: never + +spring: + config: + import: + - optional:file:.env[.properties] + - security-config.yml + - swagger-config.yml + devtools: + restart: + enabled: false + mail: + host: ${MAIL_HOST} + port: ${MAIL_PORT} + username: ${MAIL_USERNAME} + password: ${MAIL_PASSWORD} + properties: + mail.smtp.debug: true + mail.smtp.connectiontimeout: 1000 + mail.starttls.enable: true + mail.smtp.auth: true + datasource: + url: ${DB_URL} + username: ${DB_USERNAME} + password: ${DB_PASSWORD} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: 15 + minimum-idle: 15 + + servlet: + multipart: + max-file-size: ${MAX_FILE_SIZE} + max-request-size: ${MAX_REQUEST_SIZE} + jpa: + open-in-view: false + show-sql: true + hibernate: + ddl-auto: update + + properties: + hibernate: + show_sql: true + format_sql: true + highlight_sql: true + use_sql_comments: true + jdbc: + time_zone: Asia/Seoul + dialect: org.hibernate.dialect.MySQLDialect + data: + redis: + host: ${REDIS_HOST} + port: ${VELKEY_PORT} + lettuce: + pool: + enabled: true + max-active: 32 + max-idle: 8 + min-idle: 4 + max-wait: 1000 + messages: + basename: messages + encoding: UTF-8 + mvc: + view: + prefix: /WEB-INF/views/ + suffix: .jsp +aws: + s3: + bucket-name: ${AWS_BUCKET_NAME} + region: ${AWS_REGION} + access-key: ${AWS_ACCESS_KEY} + secret-key: ${AWS_SECRET_KEY} + pre-signed-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} + +toss: + api: + base-url: https://api.tosspayments.com/v1 + diff --git a/src/main/resources/application-local.yml b/src/main/resources/application-local.yml new file mode 100644 index 00000000..3c3dc3fb --- /dev/null +++ b/src/main/resources/application-local.yml @@ -0,0 +1,16 @@ +logging: + level: + root: TRACE +toss: + secret-key: ${TOSS_SECRET_KEY} +discord: + base-url: "" + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml new file mode 100644 index 00000000..99c3af57 --- /dev/null +++ b/src/main/resources/application-prod.yml @@ -0,0 +1,26 @@ +logging: + file: + name: ./logs/app.log + level: + root: INFO + +management: + endpoints: + web: + exposure: + include: "*" +toss: + secret-key: ${TOSS_SECRET_KEY} + +discord: + base-url: ${DISCORD_URL} + + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application-test.yml b/src/main/resources/application-test.yml new file mode 100644 index 00000000..5ef36ffc --- /dev/null +++ b/src/main/resources/application-test.yml @@ -0,0 +1,26 @@ +logging: + file: + name: ./logs/app.log + level: + root: info + +management: + endpoints: + web: + exposure: + include: "*" +toss: + secret-key: ${TOSS_SECRET_KEY} + +discord: + base-url: "" + + +alimtalk: + user-id: ${ALIMTALK_USER_ID} + api-key: ${ALIMTALK_API_KEY} + api: + base-url: ${ALIMTALK_URL} + +kakao: + channel-id: ${KAKAO_CHANNEL_ID} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0deb7721..8d2d26b7 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -1,112 +1,4 @@ -server: - port: ${SPRING_PORT} - servlet: - context-path: ${BASE_PATH} - session: - cookie: - same-site: none - secure: false - error: - include-stacktrace: never - spring: - mail: - host: ${MAIL_HOST} - port: ${MAIL_PORT} - username: ${MAIL_USERNAME} - password: ${MAIL_PASSWORD} - properties: - mail.smtp.debug: true - mail.smtp.connectiontimeout: 1000 - mail.starttls.enable: true - mail.smtp.auth: true - config: - import: - - optional:file:.env[.properties] - - security-config.yml - - swagger-config.yml - datasource: - url: ${DB_URL} - username: ${DB_USERNAME} - password: ${DB_PASSWORD} - driver-class-name: com.mysql.cj.jdbc.Driver - hikari: - maximum-pool-size: 15 - minimum-idle: 15 - - servlet: - multipart: - max-file-size: ${MAX_FILE_SIZE} - max-request-size: ${MAX_REQUEST_SIZE} - jpa: - open-in-view: false - show-sql: true - hibernate: - ddl-auto: update - - properties: - hibernate: - show_sql: true - format_sql: true - highlight_sql: true - use_sql_comments: true - jdbc: - time_zone: Asia/Seoul - dialect: org.hibernate.dialect.MySQLDialect - data: - redis: - host: ${REDIS_HOST} - port: ${VELKEY_PORT} - lettuce: - pool: - enabled: true - max-active: 32 - max-idle: 8 - min-idle: 4 - max-wait: 1000 - messages: - basename: messages - encoding: UTF-8 - mvc: - view: - prefix: /WEB-INF/views/ - suffix: .jsp - -management: - endpoints: - web: - exposure: - include: "*" - -aws: - s3: - bucket-name: ${AWS_BUCKET_NAME} - region: ${AWS_REGION} - access-key: ${AWS_ACCESS_KEY} - secret-key: ${AWS_SECRET_KEY} - pre-signed-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} - -logging: - file: - path: ./logs - name: app.log - level: - root: INFO - - -toss: - secret-key: ${TOSS_SECRET_KEY} - api: - base-url: https://api.tosspayments.com/v1 - -alimtalk: - user-id: ${ALIMTALK_USER_ID} - api-key: ${ALIMTALK_API_KEY} - api: - base-url: ${ALIMTALK_URL} - -kakao: - channel-id: ${KAKAO_CHANNEL_ID} - -discord: - base-url: ${DISCORD_URL} \ No newline at end of file + profiles: + active: ${APPLICATION_PROFILE:prod} + include: base diff --git a/src/main/resources/db/data/data.sql b/src/main/resources/db/data/data.sql new file mode 100644 index 00000000..2bb55f4b --- /dev/null +++ b/src/main/resources/db/data/data.sql @@ -0,0 +1,60 @@ +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.931747', '2025-08-03 05:01:17.931747', '강남구 대치동 987', '서울특별시', + '06234', 'DAECHI', 532, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '대치중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.939018', '2025-08-03 05:01:17.939018', '양천구 목동서로 369', '서울특별시', + '07995', 'MOKDONG', 896, '2025-10-19 23:59:59.000000', '2025-10-26', '고정 도시락', 9000, + '목운중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.945445', '2025-08-03 05:01:17.945445', '양천구 신정로 250', '서울특별시', + '08018', 'MOKDONG', 896, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, + '신서중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.952291', '2025-08-03 05:01:17.952291', '강남구 개포로 619', '서울특별시', + '06327', 'DAECHI', 840, '2025-10-19 23:59:59.000000', '2025-10-26', '고정 도시락', 9000, '개원중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.958425', '2025-08-03 05:01:17.958425', '강남구 개포로 619', '서울특별시', + '06327', 'DAECHI', 840, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '개원중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.965381', '2025-08-03 05:01:17.965381', '영등포구 문래로 195', '서울특별시', + '07291', 'MOKDONG', 558, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, + '문래중학교', false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.972408', '2025-08-03 05:01:17.972408', '노원구 덕릉로 70길 99', '서울특별시', + '01673', 'NOWON', 448, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '온곡중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.978612', '2025-08-03 05:01:17.978612', '노원구 덕릉로 70길 99', '서울특별시', + '01673', 'NOWON', 448, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '온곡중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.982574', '2025-08-03 05:01:17.982574', '대구광역시 달서구 장기로 76', '서울특별시', + '42677', 'DAEGU', 392, '2025-10-12 23:59:59.000000', '2025-10-19', '고정 도시락', 9000, '노변중학교', + false, 'OPEN'); +INSERT INTO sa.exam (created_at, updated_at, detail, street, zipcode, area, capacity, + deadline_time, exam_date, lunch_name, lunch_price, school_name, deleted, + exam_status) +VALUES ('2025-08-03 05:01:17.988270', '2025-08-03 05:01:17.988270', '대구광역시 달서구 장기로 76', '서울특별시', + '42677', 'DAEGU', 392, '2025-10-26 23:59:59.000000', '2025-11-02', '고정 도시락', 9000, '노변중학교', + false, 'OPEN'); \ No newline at end of file diff --git a/src/main/resources/security-config.yml b/src/main/resources/security-config.yml index 523cb69c..41ed8fac 100644 --- a/src/main/resources/security-config.yml +++ b/src/main/resources/security-config.yml @@ -16,7 +16,7 @@ spring: - birthday - birthyear - phone_number - service-terms: terms_03 + service-terms: terms_01,terms_02,terms_03 client-name: kakao provider: kakao: diff --git a/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java b/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java new file mode 100644 index 00000000..e38ce290 --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/application/oauth/OAuthUserServiceTest.java @@ -0,0 +1,44 @@ +package life.mosu.mosuserver.application.oauth; + +import java.util.HashMap; +import java.util.List; +import java.util.Map; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +@DisplayName("카카오_추가기능_응답_테스트") +class OAuthUserServiceTest { + + @Test + @DisplayName("Service Terms 응답에 마케팅 동의(terms_03)가 없는 경우 false로 파싱한다") + void 마케팅_동의_여부를_파싱한다() { + Map term1 = Map.of( + "tag", "terms_02", + "required", true, + "agreed", true + ); + Map term2 = Map.of( + "tag", "terms_01", + "required", true, + "agreed", true + ); + + Map serviceTermsAttributes = new HashMap<>(); + serviceTermsAttributes.put("id", 4342056184L); + serviceTermsAttributes.put("service_terms", List.of(term1, term2)); + + boolean agreedToMarketing = false; + if (serviceTermsAttributes.get("service_terms") instanceof List termsList) { + agreedToMarketing = termsList.stream() + .filter(term -> term instanceof Map) + .map(term -> (Map) term) + .filter(termMap -> "terms_03".equals(termMap.get("tag"))) + .findFirst() + .map(termMap -> (Boolean) termMap.get("agreed")) + .orElse(false); + } + + Assertions.assertFalse(agreedToMarketing); + } +} \ No newline at end of file diff --git a/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java b/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java new file mode 100644 index 00000000..679ec31b --- /dev/null +++ b/src/test/java/life/mosu/mosuserver/global/filter/IpRateLimitingFilterMultiUserIsolationTest.java @@ -0,0 +1,494 @@ +package life.mosu.mosuserver.global.filter; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; +import static org.awaitility.Awaitility.await; + +import com.github.benmanes.caffeine.cache.Cache; +import com.github.benmanes.caffeine.cache.Caffeine; +import com.github.benmanes.caffeine.cache.Expiry; +import com.github.benmanes.caffeine.cache.LoadingCache; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import java.time.Duration; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.ExecutorService; +import java.util.concurrent.Executors; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicInteger; +import java.util.stream.Collectors; +import java.util.stream.IntStream; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIp; +import life.mosu.mosuserver.domain.caffeine.dto.BlockedIpHistory; +import life.mosu.mosuserver.domain.caffeine.dto.RequestCounter; +import life.mosu.mosuserver.global.config.IpRateLimitingProperties; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import lombok.extern.slf4j.Slf4j; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; + +@Slf4j +public class IpRateLimitingFilterMultiUserIsolationTest { + + private IpRateLimitingFilter filter; + private IpRateLimitingProperties properties; + private Cache ipRequestCountsCache; + private Cache blockedHistoryCache; + private LoadingCache blockedIpCache; + + @BeforeEach + public void setup() { + properties = Mockito.mock(IpRateLimitingProperties.class); + Mockito.when(properties.isEnabled()).thenReturn(true); + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(100); + + ipRequestCountsCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(1)) + .build(); + + blockedHistoryCache = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + + blockedIpCache = Caffeine.newBuilder() + .expireAfter(new Expiry() { + @Override + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + @Override + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + @Override + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(key -> null); + + filter = new IpRateLimitingFilter(properties, ipRequestCountsCache, blockedHistoryCache, + blockedIpCache); + } + + @Test + public void 임계치_이하에서는_요청_모두_통과하고_카운터증가_체인호출됨() throws InterruptedException { + int userCount = 500; + int requestsPerUser = 50; + int repeatCount = 10; + + // IP 리스트 500개 생성 + List ips = IntStream.range(1, userCount + 1) + .mapToObj(i -> "192.168." + (i / 255) + "." + (i % 255)) + .collect(Collectors.toList()); + + // 각 IP별 누적 카운트 저장용 배열 (500명 × 누적 합) + long[] cumulativeCounts = new long[userCount]; + + for (int run = 1; run <= repeatCount; run++) { + // 캐시 초기화 (필요시) + ipRequestCountsCache.invalidateAll(); + blockedHistoryCache.invalidateAll(); + blockedIpCache.invalidateAll(); + + ExecutorService executor = Executors.newFixedThreadPool(100); + CountDownLatch latch = new CountDownLatch(userCount * requestsPerUser); + + for (String ip : ips) { + for (int i = 0; i < requestsPerUser; i++) { + executor.submit(() -> { + try { + HttpServletRequest request = new MockHttpServletRequest() { + @Override + public String getRemoteAddr() { + return ip; + } + }; + HttpServletResponse response = new MockHttpServletResponse(); + FilterChain filterChain = (req, res) -> { + }; + + filter.doFilterInternal(request, response, filterChain); + + } catch (CustomRuntimeException e) { + // 차단 예외 무시 + } catch (Exception e) { + e.printStackTrace(); + } finally { + latch.countDown(); + } + }); + } + } + + latch.await(); + executor.shutdown(); + + for (int idx = 0; idx < userCount; idx++) { + String ip = ips.get(idx); + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + long count = counter.getCount(); + cumulativeCounts[idx] += count; + log.debug("[Run {}] IP: {}, 카운트: {}", run, ip, count); + + assertThat(count) + .withFailMessage("Run %d: IP %s 카운트가 %d이어야 합니다, 실제: %d", run, ip, + requestsPerUser, count) + .isEqualTo(requestsPerUser); + } + } + + // 평균 카운트 계산 및 출력 + for (int idx = 0; idx < userCount; idx++) { + double avg = cumulativeCounts[idx] / (double) repeatCount; + log.info("IP: {}, 평균 카운트 ({}회 반복): {}", ips.get(idx), repeatCount, avg); + } + } + + @Test + void 임계치_이하에서는_카운터정확_체인_모두_호출됨() throws Exception { + // (기존 임계치_초과시_즉시차단되고_체인호출안됨_차단캐시에저장됨 본문 그대로) + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + String ip = "10.0.0.1"; + int requests = 200; + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + for (int i = 0; i < requests; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + assertThat(counter.getCount()).isEqualTo(requests); + assertThat(chainCalls.get()).isEqualTo(requests); + } + + + @Test + void 이미_차단된_IP는_카운터와무관하게_즉시차단_체인호출안됨() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(10); + String ip = "10.0.0.2"; + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + + for (int i = 0; i < 10; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + + boolean blockedThrown = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + + assertThat(blockedThrown).isTrue(); + assertThat(chainCalls.get()).isEqualTo(10); + BlockedIp blocked = blockedIpCache.getIfPresent(ip); + assertThat(blocked).isNotNull(); + } + + + @Test + void 차단상태에서는_카운터증가없고_체인호출안됨() throws Exception { + String ip = "10.0.0.3"; + BlockedIp mockedBlocked = Mockito.mock(BlockedIp.class); + Mockito.when(mockedBlocked.getTtl()).thenReturn(Duration.ofSeconds(30)); + blockedIpCache.put(ip, mockedBlocked); + ipRequestCountsCache.put(ip, new RequestCounter()); + ipRequestCountsCache.get(ip, k -> new RequestCounter()).incrementAndGet(); + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + boolean blockedThrown = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + assertThat(blockedThrown).isTrue(); + assertThat(chainCalls.get()).isEqualTo(0); + assertThat(ipRequestCountsCache.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(1); + } + + @Test + void 카운터_윈도우_만료후_초기화된다() throws Exception { + Cache shortLivedCount = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofSeconds(2)) + .build(); + Cache history = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + LoadingCache blocked = Caffeine.newBuilder() + .expireAfter(new Expiry() { + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(k -> null); + + IpRateLimitingFilter localFilter = new IpRateLimitingFilter(properties, shortLivedCount, + history, blocked); + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + + String ip = "10.0.0.4"; + FilterChain chain = (req, res) -> { + }; + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + assertThat(shortLivedCount.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(2); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + RequestCounter c = shortLivedCount.getIfPresent(ip); + assertThat(c).isNull(); + }); + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + assertThat(shortLivedCount.get(ip, k -> new RequestCounter()).getCount()).isEqualTo(1); + } + + @Test + void 차단_TTL_만료후_요청_통과된다() throws Exception { + LoadingCache localBlocked = Caffeine.newBuilder() + .expireAfter(new Expiry() { + public long expireAfterCreate(String key, BlockedIp value, long currentTime) { + return value.getTtl().toNanos(); + } + + public long expireAfterUpdate(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + + public long expireAfterRead(String key, BlockedIp value, long currentTime, + long currentDuration) { + return currentDuration; + } + }) + .build(k -> null); + + Cache counts = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofMinutes(1)) + .build(); + Cache history = Caffeine.newBuilder() + .maximumSize(10_000) + .expireAfterWrite(Duration.ofHours(1)) + .build(); + IpRateLimitingFilter localFilter = new IpRateLimitingFilter(properties, counts, history, + localBlocked); + + String ip = "10.0.0.5"; + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1000); + + BlockedIp mockedBlocked = Mockito.mock(BlockedIp.class); + Mockito.when(mockedBlocked.getTtl()).thenReturn(Duration.ofSeconds(2)); + + localBlocked.put(ip, mockedBlocked); + + boolean blockedThrown = false; + try { + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), + (req, res) -> { + }); + } catch (CustomRuntimeException e) { + blockedThrown = true; + } + assertThat(blockedThrown).isTrue(); + + await().atMost(5, TimeUnit.SECONDS).untilAsserted(() -> { + BlockedIp b = localBlocked.getIfPresent(ip); + assertThat(b).isNull(); + }); + + localFilter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + } + + @Test + void 차단은_IP_간_독립적으로_동작한다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(5); + String bad = "10.0.0.6"; + String good = "10.0.0.7"; + AtomicInteger goodCalls = new AtomicInteger(); + + for (int i = 0; i < 5; i++) { + filter.doFilterInternal(reqForIp(bad), new MockHttpServletResponse(), (req, res) -> { + }); + } + boolean blocked = false; + try { + filter.doFilterInternal(reqForIp(bad), new MockHttpServletResponse(), (req, res) -> { + }); + } catch (CustomRuntimeException e) { + blocked = true; + } + assertThat(blocked).isEqualTo(true); + + filter.doFilterInternal(reqForIp(good), new MockHttpServletResponse(), + (req, res) -> goodCalls.incrementAndGet()); + assertThat(goodCalls.get()).isEqualTo(1); + } + + @Test + void 단일IP_고경합에서도_카운터와_체인호출이_정확하다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(100_000); + String ip = "10.0.0.8"; + int threads = 200; + int perThread = 50; + ExecutorService exec = Executors.newFixedThreadPool(50); + CountDownLatch latch = new CountDownLatch(threads * perThread); + AtomicInteger chainCalls = new AtomicInteger(); + + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + + for (int t = 0; t < threads; t++) { + exec.submit(() -> { + try { + for (int i = 0; i < perThread; i++) { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + for (int i = 0; i < perThread; i++) { + latch.countDown(); + } + } + }); + } + latch.await(); + exec.shutdown(); + + long expected = (long) threads * perThread; + RequestCounter counter = ipRequestCountsCache.get(ip, k -> new RequestCounter()); + assertThat(counter.getCount()).isEqualTo(expected); + assertThat(chainCalls.get()).isEqualTo(expected); + } + + @Test + void 차단중에는_모든요청이_차단되고_체인호출없다() throws Exception { + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1); + String ip = "10.0.0.9"; + + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + + boolean secondBlocked = false; + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + } catch (CustomRuntimeException e) { + secondBlocked = true; + } + assertThat(secondBlocked).isEqualTo(true); + + AtomicInteger chainCalls = new AtomicInteger(); + FilterChain chain = (req, res) -> chainCalls.incrementAndGet(); + int concurrent = 100; + ExecutorService exec = Executors.newFixedThreadPool(20); + CountDownLatch latch = new CountDownLatch(concurrent); + for (int i = 0; i < concurrent; i++) { + exec.submit(() -> { + try { + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), chain); + } catch (CustomRuntimeException ignored) { + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + latch.await(); + exec.shutdown(); + + assertThat(chainCalls.get()).isEqualTo(0); + } + + private HttpServletRequest reqForIp(String ip) { + return new MockHttpServletRequest() { + @Override + public String getRemoteAddr() { + return ip; + } + }; + } + + @Test + void 다중스레드_동시차단_시도에도_차단이력은_한단계만_상승한다() throws Exception { + // 임계치 1: 첫 요청만 허용, 그 이후는 모두 차단 + Mockito.when(properties.getMaxRequestsPerMinute()).thenReturn(1); + String ip = "10.0.0.200"; + + // 1) 첫 요청: 허용되어 카운터가 1이 됨 + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), (req, res) -> { + }); + + // 2) 동시에 많은 요청을 던져서 handleBlockedIp가 동시 다발로 호출되도록 유도 + int concurrent = 200; + CountDownLatch latch = new CountDownLatch(concurrent); + ExecutorService exec = Executors.newFixedThreadPool(40); + + for (int i = 0; i < concurrent; i++) { + exec.submit(() -> { + try { + try { + filter.doFilterInternal(reqForIp(ip), new MockHttpServletResponse(), + (req, res) -> { + }); + } catch (CustomRuntimeException ignored) { + // 차단 예외는 무시 (정상 동작) + } + } catch (Exception e) { + throw new RuntimeException(e); + } finally { + latch.countDown(); + } + }); + } + + latch.await(); + exec.shutdown(); + + // 3) 검증 + // - blockedIpCache에 엔트리가 있어야 함 (차단 상태) + // - BlockedIpHistory의 penalty level이 '정확히 한 단계'만 상승해야 함 + // (여러 스레드가 동시에 업데이트해도 compute로 원자적 갱신이므로 LEVEL_1 이 되어야 함) + + BlockedIp blocked = blockedIpCache.getIfPresent(ip); + assertThat(blocked).isNotNull(); + + BlockedIpHistory history = blockedHistoryCache.getIfPresent(ip); + assertThat(history).isNotNull(); + + // 초기 레벨이 LEVEL_0이라고 가정하고, 상승 후 LEVEL_1을 기대 + log.info("history: {}", history); + assertThat(history.getPenaltyLevel()).isEqualTo(TimePenalty.LEVEL_1); + + } +} + +