From cc0b84b2b92a9db432df09f0005490e105a8d1dc Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:17:53 +0900 Subject: [PATCH 01/28] =?UTF-8?q?delete:=20=EB=B6=88=ED=95=84=EC=9A=94?= =?UTF-8?q?=ED=95=9C=20=ED=8C=8C=EC=9D=BC=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../life/mosu/mosuserver/presentation/school/dto/AreaDetail.java | 0 .../life/mosu/mosuserver/presentation/school/dto/AreaMeta.java | 0 .../presentation/school/dto/AvailableSchoolResponse.java | 0 .../life/mosu/mosuserver/presentation/school/dto/LunchInfo.java | 0 4 files changed, 0 insertions(+), 0 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java delete mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java delete mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java delete mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java deleted file mode 100644 index e69de29b..00000000 diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java deleted file mode 100644 index e69de29b..00000000 From e527e284cf5fa0e8843db86526a7ce310deb65df Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:20:00 +0900 Subject: [PATCH 02/28] =?UTF-8?q?refacor:=20exam=20=EA=B4=80=EB=A0=A8=20dt?= =?UTF-8?q?o=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mosuserver/presentation/exam/dto/ExamRequest.java | 2 +- .../mosuserver/presentation/exam/dto/ExamResponse.java | 9 +++++---- 2 files changed, 6 insertions(+), 5 deletions(-) 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 5d62295d..d908d33e 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 @@ -13,7 +13,7 @@ public record ExamRequest( LocalDate examDate, Integer capacity, LocalDateTime deadlineTime, - ExamLunchRequest lunch + LunchRequest lunch ) { public ExamJpaEntity toEntity() { diff --git a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamResponse.java b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamResponse.java index 1a9b59bd..8d4efae6 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/ExamResponse.java @@ -3,15 +3,15 @@ import java.time.LocalDate; import java.time.LocalDateTime; import java.util.List; -import life.mosu.mosuserver.domain.exam.Area; import life.mosu.mosuserver.domain.exam.ExamJpaEntity; import life.mosu.mosuserver.presentation.common.AddressResponse; +import life.mosu.mosuserver.presentation.lunch.dto.LunchResponse; public record ExamResponse( Long id, String schoolName, AddressResponse address, - Area area, + String area, Integer capacity, LocalDateTime deadlineTime, LocalDate examDate, @@ -20,15 +20,16 @@ public record ExamResponse( public static ExamResponse from(ExamJpaEntity exam) { AddressResponse address = AddressResponse.from(exam.getAddress()); + LunchResponse lunch = LunchResponse.of(exam.getLunchName(), exam.getLunchPrice()); return new ExamResponse( exam.getId(), exam.getSchoolName(), address, - exam.getArea(), + exam.getArea().getAreaName(), exam.getCapacity(), exam.getDeadlineTime(), exam.getExamDate(), - LunchResponse.of(exam.getLunchName(), exam.getLunchPrice()) + lunch ); } From 4b24c81b36da3606fd85fd446155cf985ae64e09 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:20:39 +0900 Subject: [PATCH 03/28] =?UTF-8?q?refactor:=20lunch=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mosuserver/presentation/exam/dto/LunchInfo.java | 12 ++++++++++++ .../presentation/exam/dto/LunchRequest.java | 8 ++++++++ .../presentation/lunch/dto/LunchResponse.java | 11 +++++++++++ 3 files changed, 31 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchInfo.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchRequest.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/lunch/dto/LunchResponse.java diff --git a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchInfo.java b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchInfo.java new file mode 100644 index 00000000..13da3bc2 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchInfo.java @@ -0,0 +1,12 @@ +package life.mosu.mosuserver.presentation.exam.dto; + +public record LunchInfo( + String name, + Integer price +) { + + public static LunchInfo of(String name, Integer price) { + return new LunchInfo(name, price); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchRequest.java b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchRequest.java new file mode 100644 index 00000000..b43c96ec --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/dto/LunchRequest.java @@ -0,0 +1,8 @@ +package life.mosu.mosuserver.presentation.exam.dto; + +public record LunchRequest( + String name, + Integer price +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/lunch/dto/LunchResponse.java b/src/main/java/life/mosu/mosuserver/presentation/lunch/dto/LunchResponse.java new file mode 100644 index 00000000..a07c2f96 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/lunch/dto/LunchResponse.java @@ -0,0 +1,11 @@ +package life.mosu.mosuserver.presentation.lunch.dto; + +public record LunchResponse( + String name, + Integer price +) { + + public static LunchResponse of(String name, Integer price) { + return new LunchResponse(name, price); + } +} From 95698e30ff38307c06ff0fc4a1a2ccf20bb34f64 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:21:34 +0900 Subject: [PATCH 04/28] =?UTF-8?q?refactor:=20=ED=85=8C=EC=9D=B4=EB=B8=94?= =?UTF-8?q?=20=EA=B5=AC=EC=A1=B0=20=EB=B3=80=EA=B2=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../mosu/mosuserver/domain/exam/ExamJpaEntity.java | 14 +++++++++++--- .../examapplication/ExamApplicationJpaEntity.java | 13 +++++++------ 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaEntity.java index 5f68b407..b35663f9 100644 --- a/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaEntity.java @@ -52,14 +52,22 @@ public class ExamJpaEntity { private Integer lunchPrice; @Builder - public ExamJpaEntity(String schoolName, AddressJpaVO address, Area area, Integer capacity, - LocalDateTime deadlineTime, LocalDate examDate, String lunchName, Integer lunchPrice) { + public ExamJpaEntity( + String schoolName, + AddressJpaVO address, + Area area, + LocalDate examDate, + Integer capacity, + LocalDateTime deadlineTime, + String lunchName, + Integer lunchPrice + ) { this.schoolName = schoolName; this.address = address; this.area = area; + this.examDate = examDate; this.capacity = capacity; this.deadlineTime = deadlineTime; - this.examDate = examDate; this.lunchName = lunchName; this.lunchPrice = lunchPrice; } diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java index dddf42cd..96a160ca 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java @@ -27,24 +27,25 @@ public class ExamApplicationJpaEntity { @Column(name = "exam_id") private Long examId; - @Column(name = "lunch_id") - private Long lunchId; + @Column(name = "lunch_checked") + private boolean isLunchChecked; @Column(name = "exam_number") private String examNumber; @Builder - public ExamApplicationJpaEntity(Long applicationId, Long examId, Long lunchId) { + public ExamApplicationJpaEntity(Long applicationId, Long examId, boolean isLunchChecked) { this.applicationId = applicationId; this.examId = examId; - this.lunchId = lunchId; + this.isLunchChecked = isLunchChecked; } - public static ExamApplicationJpaEntity create(Long applicationId, Long examId, Long lunchId) { + public static ExamApplicationJpaEntity create(Long applicationId, Long examId, + boolean isLunchChecked) { return ExamApplicationJpaEntity.builder() .applicationId(applicationId) .examId(examId) - .lunchId(lunchId) + .isLunchChecked(isLunchChecked) .build(); } From 9174212c0bedc4c36d69a2d194c5510258bef8b0 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:22:15 +0900 Subject: [PATCH 05/28] =?UTF-8?q?refactor:=20=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20dto=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/ApplicationRequest.java | 4 +- .../application/dto/ApplicationResponse.java | 9 +++-- .../dto/ExamApplicationRequest.java | 7 +--- .../dto/ExamApplicationResponse.java | 39 +++++++++++++++++++ 4 files changed, 46 insertions(+), 13 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java 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 9bc10d11..c2342896 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 @@ -25,9 +25,7 @@ public record ApplicationRequest( @Schema(description = "시험 신청 Id 목록", required = true) @NotNull List examApplication, - /** - * ExamApplication = exam_id, lunch_id 를 포함하고 있음 - */ + @Schema(description = "약관 동의 정보", required = true) @NotNull AgreementRequest agreement, diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java index 0d24fd97..a984fc27 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java @@ -2,22 +2,23 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; @Schema(description = "신청 응답 DTO") public record ApplicationResponse( @Schema(description = "신청 ID", example = "1") Long applicationId, - + @Schema(description = "시험 목록") - List exams + List exams ) { public static ApplicationResponse of(Long applicationId) { return new ApplicationResponse(applicationId, null); } - - public static ApplicationResponse of(Long applicationId, List exams) { + + public static ApplicationResponse of(Long applicationId, List exams) { return new ApplicationResponse(applicationId, exams); } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java index 826371f4..4fdd80c5 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java @@ -1,13 +1,8 @@ package life.mosu.mosuserver.presentation.application.dto; -import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent.TargetExam; - public record ExamApplicationRequest( Long examId, - Long lunchId + boolean isLunchChecked ) { - public TargetExam toTargetExam() { - return new TargetExam(examId, lunchId); - } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java new file mode 100644 index 00000000..140fc565 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java @@ -0,0 +1,39 @@ +package life.mosu.mosuserver.presentation.examapplication.dto; + +import java.time.LocalDate; +import java.util.Set; +import life.mosu.mosuserver.presentation.common.AddressResponse; + +public record ExamApplicationResponse( + Long examApplicationId, + String paymentKey, + String paymentStatus, + LocalDate examDate, + String schoolName, + AddressResponse address, + Set subjects, + String lunchName +) { + + public static ExamApplicationResponse of( + Long examApplicationId, + String paymentKey, + String paymentStatus, + LocalDate examDate, + String schoolName, + AddressResponse address, + Set subjects, + String lunchName + ) { + return new ExamApplicationResponse( + examApplicationId, + paymentKey, + paymentStatus, + examDate, + schoolName, + address, + subjects, + lunchName + ); + } +} From e427bdf4251fe13b24a7bf0ac5c4378f364801eb Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:23:58 +0900 Subject: [PATCH 06/28] =?UTF-8?q?feat:=20=EC=8B=A0=EC=B2=AD=20=EB=8B=A8?= =?UTF-8?q?=EA=B1=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC?= =?UTF-8?q?=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExamApplicationService.java | 42 +++++++++++++++++++ .../ExamApplicationController.java | 11 +++++ 2 files changed, 53 insertions(+) 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 2edd5e34..781ab464 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java @@ -1,6 +1,8 @@ package life.mosu.mosuserver.application.examapplication; import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent; import life.mosu.mosuserver.domain.application.ExamTicketImageJpaEntity; import life.mosu.mosuserver.domain.application.ExamTicketImageJpaRepository; @@ -12,12 +14,15 @@ import life.mosu.mosuserver.domain.examapplication.ExamNumberGenerationService; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaEntity; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaRepository; +import life.mosu.mosuserver.domain.payment.PaymentRepository; import life.mosu.mosuserver.domain.profile.ProfileJpaEntity; import life.mosu.mosuserver.domain.profile.ProfileJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.storage.application.S3Service; import life.mosu.mosuserver.presentation.admin.dto.ExamTicketResponse; +import life.mosu.mosuserver.presentation.common.AddressResponse; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; import life.mosu.mosuserver.presentation.examapplication.dto.UpdateSubjectRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -33,6 +38,7 @@ public class ExamApplicationService { private final ExamTicketImageJpaRepository examTicketImageJpaRepository; private final ProfileJpaRepository profileJpaRepository; private final ExamJpaRepository examJpaRepository; + private final PaymentRepository paymentRepository; private final S3Service s3Service; @@ -50,6 +56,7 @@ public void updateSubjects(Long examApplicationId, UpdateSubjectRequest request) examSubjectJpaRepository.saveAll(examSubjects); } + @Transactional public ExamTicketResponse getExamTicket(Long userId, Long examApplicationId) { ExamApplicationJpaEntity examApplication = examApplicationJpaRepository.findById( @@ -81,4 +88,39 @@ public ExamTicketResponse getExamTicket(Long userId, Long examApplicationId) { } + + //TODO: paymentKey 작성 필요 + public ExamApplicationResponse getApplication(Long examApplicationId) { + ExamApplicationJpaEntity examApplication = examApplicationJpaRepository.findById( + examApplicationId).orElseThrow( + () -> new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); + +// PaymentJpaEntity payment = paymentRepository.findByExamApplicationId(examApplicationId); + + ExamJpaEntity exam = examJpaRepository.findById( + examApplication.getExamId()) + .orElseThrow(() -> new CustomRuntimeException( + ErrorCode.EXAM_NOT_FOUND)); + + // 과목 정보 조회 + List examSubjects = + examSubjectJpaRepository.findByExamApplicationId( + examApplication.getId()); + Set subjects = examSubjects.stream() + .map(examSubject -> examSubject.getSubject() + .getSubjectName()) + .collect(Collectors.toSet()); + + return ExamApplicationResponse.of( + examApplication.getId(), +// payment.getPaymentKey(), + null, + null, + exam.getExamDate(), + exam.getSchoolName(), + AddressResponse.from(exam.getAddress()), + subjects, + examApplication.isLunchChecked() ? exam.getLunchName() : "신청 안 함" + ); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java b/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java index 08553545..37ebdd2e 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java @@ -3,6 +3,7 @@ import life.mosu.mosuserver.application.examapplication.ExamApplicationService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.admin.dto.ExamTicketResponse; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; import life.mosu.mosuserver.presentation.examapplication.dto.UpdateSubjectRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; @@ -22,6 +23,16 @@ public class ExamApplicationController { private final ExamApplicationService examApplicationService; + + @GetMapping("{examApplicationId}") + public ResponseEntity> getApplication( + @PathVariable("examApplicationId") Long examApplicationId + ) { + ExamApplicationResponse response = examApplicationService.getApplication(examApplicationId); + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "신청 정보 조회를 완료하였습니다.", response)); + } + @PutMapping("{examApplicationId}/subjects") public ResponseEntity> updateSubjects( @PathVariable("examApplicationId") Long examApplicationId, From bf9279adf6b000639d291b4e121aa9b16a02b572 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:24:38 +0900 Subject: [PATCH 07/28] =?UTF-8?q?feat:=20=EC=8B=9C=ED=97=98=EC=9E=A5=20?= =?UTF-8?q?=EC=A0=84=EC=B2=B4=20=EC=A1=B0=ED=9A=8C=20=EA=B8=B0=EB=8A=A5=20?= =?UTF-8?q?=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../life/mosu/mosuserver/application/exam/ExamService.java | 6 ++++++ .../mosu/mosuserver/presentation/exam/ExamController.java | 7 +++++++ 2 files changed, 13 insertions(+) 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 975d5bbc..cda83644 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java @@ -39,4 +39,10 @@ public List getByArea(String areaName) { public List getDistinctAreas() { return examJpaRepository.findDistinctAreas(); } + + public List getExams() { + List exams = examJpaRepository.findAll(); + return ExamResponse.fromList(exams); + } + } 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 5aac2ca0..c77c9ee0 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java @@ -33,6 +33,13 @@ public ResponseEntity> register( } + @GetMapping("/all") + public ResponseEntity>> getExams() { + List response = examService.getExams(); + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "전체 시험 정보 조회 성공", response)); + } + @GetMapping public ResponseEntity>> getByArea( @RequestParam String areaName From 56eab5f88bd965b74235aafd5aebe1df6001e003 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:25:29 +0900 Subject: [PATCH 08/28] =?UTF-8?q?refactor:=20=EC=8B=A0=EC=B2=AD=20?= =?UTF-8?q?=EA=B4=80=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ApplicationService.java | 48 +++++++++++++++---- .../dto/RegisterExamApplicationEvent.java | 6 +-- .../application/ApplicationController.java | 4 +- 3 files changed, 43 insertions(+), 15 deletions(-) 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 7779fe23..759590ae 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java @@ -21,7 +21,8 @@ import life.mosu.mosuserver.presentation.application.dto.ApplicationRequest; import life.mosu.mosuserver.presentation.application.dto.ApplicationResponse; import life.mosu.mosuserver.presentation.application.dto.ExamApplicationRequest; -import life.mosu.mosuserver.presentation.application.dto.ExamWithSubjects; +import life.mosu.mosuserver.presentation.common.AddressResponse; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -43,7 +44,7 @@ public class ApplicationService { @Transactional public ApplicationResponse apply(Long userId, ApplicationRequest request) { - // 중복 신청 검증 + // 중복 신청 검증 - 수정 필요 List examIds = request.examApplication().stream() .map(ExamApplicationRequest::examId) .toList(); @@ -60,6 +61,9 @@ public ApplicationResponse apply(Long userId, ApplicationRequest request) { ApplicationJpaEntity savedApplication = applicationJpaRepository.save(application); Long applicationId = savedApplication.getId(); + //해당 시험이 진짜 있는 일정인지, lunch 가 없는 시험인데 lunch 를 신청했는지 + validateExamIdsAndLunchSelection(request.examApplication()); + List examApplicationEntities = examApplicationService.register( RegisterExamApplicationEvent.of(request.examApplication(), applicationId) ); @@ -87,7 +91,7 @@ public ApplicationResponse apply(Long userId, ApplicationRequest request) { } - // 전체 신청 내역 조회 + // 전체 신청 내역 조회 - 수정 필요 @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public List getApplications(Long userId) { List applications = applicationJpaRepository.findAllByUserId(userId); @@ -106,7 +110,7 @@ public List getApplications(Long userId) { throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); } // ExamWithSubjects 리스트 생성 - List exams = examApplications.stream() + List exams = examApplications.stream() .map(examApplication -> { // 시험 정보 조회 ExamJpaEntity exam = examJpaRepository.findById( @@ -124,15 +128,16 @@ public List getApplications(Long userId) { .collect(Collectors.toSet()); // ExamWithSubjects 생성 - return new ExamWithSubjects( + return ExamApplicationResponse.of( examApplication.getId(), - exam.getArea().getAreaName(), + null, + null, exam.getExamDate(), exam.getSchoolName(), - null, - examApplication.getExamNumber() != null - ? examApplication.getExamNumber() : "", - subjects + AddressResponse.from(exam.getAddress()), + subjects, + examApplication.isLunchChecked() ? exam.getLunchName() + : "신청 안 함" ); }) .toList(); @@ -141,4 +146,27 @@ public List getApplications(Long userId) { }) .toList(); } + + private void validateExamIdsAndLunchSelection(List requests) { + + List examIds = requests.stream() + .map(ExamApplicationRequest::examId).toList(); + + List existingExams = examJpaRepository.findAllById(examIds); + if (existingExams.size() != examIds.size()) { + throw new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND); + } + + Set examsWithoutLunch = existingExams.stream() + .filter(exam -> exam.getLunchName() == null) + .map(ExamJpaEntity::getId) + .collect(Collectors.toSet()); + + boolean invalidLunchSelection = requests.stream() + .anyMatch(req -> examsWithoutLunch.contains(req.examId()) && req.isLunchChecked()); + + if (invalidLunchSelection) { + throw new CustomRuntimeException(ErrorCode.LUNCH_SELECTION_INVALID); + } + } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java b/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java index ae090401..d1f82d14 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java @@ -14,7 +14,7 @@ public static RegisterExamApplicationEvent of( Long applicationId ) { List targetExams = examApplicationRequests.stream() - .map(request -> new TargetExam(request.examId(), request.lunchId())) + .map(request -> new TargetExam(request.examId(), request.isLunchChecked())) .toList(); return new RegisterExamApplicationEvent(targetExams, applicationId); } @@ -24,14 +24,14 @@ public List toEntity() { .map(targetExam -> ExamApplicationJpaEntity.create( applicationId, targetExam.examId(), - targetExam.lunchId() + targetExam.isLunchChecked )) .toList(); } public record TargetExam( Long examId, - Long lunchId + boolean isLunchChecked ) { } diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java index b4ff4fed..eb692b65 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java @@ -19,13 +19,13 @@ @Slf4j @RestController -@RequestMapping("/application") +@RequestMapping("/applications") @RequiredArgsConstructor public class ApplicationController implements ApplicationControllerDocs { private final ApplicationService applicationService; - //신청 + //신청 -> response 수정해야 함 @PostMapping // @PreAuthorize("isAuthenticated() and hasRole('USER')") public ResponseEntity> apply( From 4983a02000f22b1e496dd39abffd25afa1996624 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:27:10 +0900 Subject: [PATCH 09/28] =?UTF-8?q?refactor:=20payment=20examApplicationId?= =?UTF-8?q?=20=EB=A1=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/PaymentFailureHandler.java | 2 +- .../domain/payment/PaymentJpaEntity.java | 16 ++++++++-------- .../domain/payment/PaymentRepository.java | 2 ++ 3 files changed, 11 insertions(+), 9 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java index 7e515c21..0ab4b0c6 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java @@ -19,7 +19,7 @@ public class PaymentFailureHandler { public void handlePaymentFailure(PaymentEvent event) { List existingPayments = paymentRepository.findByOrderId(event.orderId()); Set existingAppIds = existingPayments.stream() - .map(PaymentJpaEntity::getApplicationSchoolId) + .map(PaymentJpaEntity::getExamApplicationId) .collect(Collectors.toSet()); List missingAppSchoolIds = event.applicationSchoolIds().stream() diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaEntity.java index 4dc7b6d0..0e7ace3b 100644 --- a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaEntity.java @@ -26,8 +26,8 @@ public class PaymentJpaEntity extends BaseTimeEntity { @Column(name = "payment_id") private Long id; - @Column(name = "application_school_id") - private Long applicationSchoolId; + @Column(name = "exam_application_id") + private Long examApplicationId; @Column(name = "payment_key") private String paymentKey; @@ -48,14 +48,14 @@ public class PaymentJpaEntity extends BaseTimeEntity { @Builder(access = AccessLevel.PRIVATE) private PaymentJpaEntity( - Long applicationSchoolId, + Long examApplicationId, String paymentKey, String orderId, PaymentAmountVO paymentAmount, PaymentStatus paymentStatus, PaymentMethod paymentMethod ) { - this.applicationSchoolId = applicationSchoolId; + this.examApplicationId = examApplicationId; this.paymentKey = paymentKey; this.orderId = orderId; this.paymentAmount = paymentAmount; @@ -64,7 +64,7 @@ private PaymentJpaEntity( } public static PaymentJpaEntity of( - Long applicationSchoolId, + Long examApplicationId, String paymentKey, String orderId, PaymentStatus paymentStatus, @@ -72,7 +72,7 @@ public static PaymentJpaEntity of( PaymentMethod paymentMethod ) { return PaymentJpaEntity.builder() - .applicationSchoolId(applicationSchoolId) + .examApplicationId(examApplicationId) .paymentKey(paymentKey) .orderId(orderId) .paymentStatus(paymentStatus) @@ -82,14 +82,14 @@ public static PaymentJpaEntity of( } public static PaymentJpaEntity ofFailure( - Long applicationSchoolId, + Long examApplicationId, String orderId, PaymentStatus paymentStatus, Integer totalAmount ) { PaymentAmountVO paymentAmount = PaymentAmountVO.ofFailure(totalAmount); return PaymentJpaEntity.builder() - .applicationSchoolId(applicationSchoolId) + .examApplicationId(examApplicationId) .orderId(orderId) .paymentStatus(paymentStatus) .paymentAmount(paymentAmount) diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java index 084863c2..82ddbce4 100644 --- a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java @@ -9,4 +9,6 @@ public interface PaymentRepository extends JpaRepository boolean existsByOrderId(String orderId); List findByOrderId(String orderId); + + PaymentJpaEntity findByExamApplicationId(Long examApplicationId); } From f7153f5ef8d260c2badc2b5ff96eb0a40cdf7fdb Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:27:35 +0900 Subject: [PATCH 10/28] =?UTF-8?q?feat:=20lunch=20=EA=B4=80=EB=A0=A8=20?= =?UTF-8?q?=EC=97=90=EB=9F=AC=EC=BD=94=EB=93=9C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/life/mosu/mosuserver/global/exception/ErrorCode.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 b2989c14..cf93f7e1 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -100,7 +100,8 @@ public enum ErrorCode { //lunch 관련 LUNCH_NOT_FOUND(HttpStatus.NOT_FOUND, "점심 정보를 찾을 수 없습니다."), - LUNCH_PRICE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "가격 수정에 실패하였습니다."); + LUNCH_PRICE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "가격 수정에 실패하였습니다."), + LUNCH_SELECTION_INVALID(HttpStatus.BAD_REQUEST, "점심이 등록되지 않은 시험에 점심 신청을 할 수 없습니다."); private final HttpStatus status; From b96d7762642a8e5880d07574275c09d7d06fde53 Mon Sep 17 00:00:00 2001 From: chominju Date: Tue, 22 Jul 2025 07:29:25 +0900 Subject: [PATCH 11/28] =?UTF-8?q?chore:=20=EC=A3=BC=EC=84=9D=20=EC=B2=98?= =?UTF-8?q?=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/dto/ExamWithSubjects.java | 61 ++++++++++--------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamWithSubjects.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamWithSubjects.java index 3d301512..f490c10d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamWithSubjects.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamWithSubjects.java @@ -1,30 +1,31 @@ -package life.mosu.mosuserver.presentation.application.dto; - -import io.swagger.v3.oas.annotations.media.Schema; -import java.time.LocalDate; -import java.util.Set; - -@Schema(description = "시험 정보와 과목 정보") -public record ExamWithSubjects( - @Schema(description = "시험 신청 ID", example = "1") - Long examId, - - @Schema(description = "지역", example = "대치") - String area, - - @Schema(description = "시험 날짜", example = "2025-08-10") - LocalDate examDate, - - @Schema(description = "학교명", example = "대치중학교") - String schoolName, - - @Schema(description = "점심 메뉴", example = "신청 안 함") - String lunch, - - @Schema(description = "수험 번호", example = "20250001") - String examinationNumber, - - @Schema(description = "응시 과목 목록", example = "[\"생활과 윤리\", \"정치와 법\"]") - Set subjects -) { -} +//package life.mosu.mosuserver.presentation.application.dto; +// +//import io.swagger.v3.oas.annotations.media.Schema; +//import java.time.LocalDate; +//import java.util.Set; +// +//@Schema(description = "시험 정보와 과목 정보") +//public record ExamWithSubjects( +// @Schema(description = "시험 신청 ID", example = "1") +// Long examId, +// +// @Schema(description = "지역", example = "대치") +// String area, +// +// @Schema(description = "시험 날짜", example = "2025-08-10") +// LocalDate examDate, +// +// @Schema(description = "학교명", example = "대치중학교") +// String schoolName, +// +// @Schema(description = "점심 메뉴", example = "신청 안 함") +// String lunch, +// +// @Schema(description = "수험 번호", example = "20250001") +// String examNumber, +// +// @Schema(description = "응시 과목 목록", example = "[\"생활과 윤리\", \"정치와 법\"]") +// Set subjects +//) { +// +//} From 163e4c4fd9c7fd5f33a6029c84e1f551dd5207d2 Mon Sep 17 00:00:00 2001 From: chominju Date: Wed, 23 Jul 2025 05:47:22 +0900 Subject: [PATCH 12/28] chore: comment out developmentOnly dependency for spring-boot-devtools Co-authored-by: polyglot-k --- build.gradle | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/build.gradle b/build.gradle index 4af5357d..3a4f81c3 100644 --- a/build.gradle +++ b/build.gradle @@ -32,7 +32,7 @@ dependencies { testImplementation 'org.projectlombok:lombok' testCompileOnly 'org.projectlombok:lombok' testAnnotationProcessor 'org.projectlombok:lombok' - developmentOnly 'org.springframework.boot:spring-boot-devtools' +// developmentOnly 'org.springframework.boot:spring-boot-devtools' compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' testImplementation 'org.springframework.boot:spring-boot-starter-test' From 4c1202fc3d5dc58053465ef8dd8380bbb04e9cb1 Mon Sep 17 00:00:00 2001 From: chominju Date: Wed, 23 Jul 2025 05:57:22 +0900 Subject: [PATCH 13/28] =?UTF-8?q?feat:=20examApplication=20=EA=B4=80?= =?UTF-8?q?=EB=A0=A8=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ExamSubjectJpaRepository.java | 2 + .../ExamApplicationBulkRepository.java | 96 +++++++++++++++++++ .../dto/ExamApplicationRequest.java | 2 +- .../ExamApplicationController.java | 22 +++-- ....java => ExamApplicationInfoResponse.java} | 24 +++-- 5 files changed, 130 insertions(+), 16 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/infra/respository/ExamApplicationBulkRepository.java rename src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/{ExamApplicationResponse.java => ExamApplicationInfoResponse.java} (59%) diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamSubjectJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamSubjectJpaRepository.java index 001a788e..cd953daf 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamSubjectJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamSubjectJpaRepository.java @@ -8,4 +8,6 @@ public interface ExamSubjectJpaRepository extends JpaRepository findByExamApplicationId(Long examApplicationId); void deleteByExamApplicationId(Long examApplicationId); + + List findByExamApplicationIdIn(List examApplicationIds); } diff --git a/src/main/java/life/mosu/mosuserver/infra/respository/ExamApplicationBulkRepository.java b/src/main/java/life/mosu/mosuserver/infra/respository/ExamApplicationBulkRepository.java new file mode 100644 index 00000000..53136cb6 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/infra/respository/ExamApplicationBulkRepository.java @@ -0,0 +1,96 @@ +package life.mosu.mosuserver.infra.respository; + +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.sql.Statement; +import java.sql.Timestamp; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import life.mosu.mosuserver.domain.application.Subject; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.dao.DataAccessException; +import org.springframework.jdbc.core.ConnectionCallback; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.stereotype.Repository; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Repository +@RequiredArgsConstructor +public class ExamApplicationBulkRepository { + + private static final String SQL_INSERT_EXAM_APPLICATION = """ + INSERT INTO exam_application + (created_at, updated_at, application_id, + exam_id, lunch_checked, exam_number) + VALUES (?, ?, ?, ?, ?, ?) + """; + private static final String SQL_INSERT_EXAM_SUBJECT = """ + INSERT INTO exam_subject (exam_application_id, subject) VALUES (?, ?) + """; + private final JdbcTemplate jdbcTemplate; + + + @Transactional + public List saveAllExamApplicationsWithSubjects( + List entities, Set subjects) { + + List generatedIds = jdbcTemplate.execute((ConnectionCallback>) con -> { + try (PreparedStatement ps = con.prepareStatement( + SQL_INSERT_EXAM_APPLICATION, Statement.RETURN_GENERATED_KEYS)) { + for (ExamApplicationJpaEntity e : entities) { + ps.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now())); + ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); + ps.setLong(3, e.getApplicationId()); + ps.setLong(4, e.getExamId()); + ps.setBoolean(5, e.getIsLunchChecked()); + ps.setString(6, e.getExamNumber()); + ps.addBatch(); + } + + ps.executeBatch(); + + List ids = new ArrayList<>(); + try (ResultSet rs = ps.getGeneratedKeys()) { + while (rs.next()) { + ids.add(rs.getLong(1)); + } + } + return ids; + + } catch (SQLException e) { + log.info("SQL Exception : {}", e); + throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_MULTI_INSERT_ERROR); + + } + }); + + List subjectParams = new ArrayList<>(); + + for (int i = 0; i < entities.size(); i++) { + Long examApplicationId = generatedIds.get(i); + + for (Subject subj : subjects) { + subjectParams.add(new Object[]{examApplicationId, String.valueOf(subj)}); + } + } + + try { + if (!subjectParams.isEmpty()) { + jdbcTemplate.batchUpdate(SQL_INSERT_EXAM_SUBJECT, subjectParams); + } + } catch (DataAccessException e) { + throw new CustomRuntimeException(ErrorCode.EXAM_SUBJECT_MULTI_INSERT_ERROR); + } + + return generatedIds; + } + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java index 4fdd80c5..adad158b 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java @@ -2,7 +2,7 @@ public record ExamApplicationRequest( Long examId, - boolean isLunchChecked + Boolean isLunchChecked ) { } diff --git a/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java b/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java index 37ebdd2e..0eaab615 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/examapplication/ExamApplicationController.java @@ -3,11 +3,12 @@ import life.mosu.mosuserver.application.examapplication.ExamApplicationService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.admin.dto.ExamTicketResponse; -import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationInfoResponse; import life.mosu.mosuserver.presentation.examapplication.dto.UpdateSubjectRequest; import lombok.RequiredArgsConstructor; import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PutMapping; @@ -23,16 +24,26 @@ public class ExamApplicationController { private final ExamApplicationService examApplicationService; - + //TODO: 테스트 필요 @GetMapping("{examApplicationId}") - public ResponseEntity> getApplication( + public ResponseEntity> getApplication( @PathVariable("examApplicationId") Long examApplicationId ) { - ExamApplicationResponse response = examApplicationService.getApplication(examApplicationId); + ExamApplicationInfoResponse response = examApplicationService.getApplication( + examApplicationId); return ResponseEntity.ok( ApiResponseWrapper.success(HttpStatus.OK, "신청 정보 조회를 완료하였습니다.", response)); } + @DeleteMapping("{examApplicationId}") + public ResponseEntity> deleteExamApplication( + @PathVariable("examApplicationId") Long examApplicationId + ) { + examApplicationService.deleteExamApplication(examApplicationId); + return ResponseEntity.ok( + ApiResponseWrapper.success(HttpStatus.OK, "신청 정보 정상적으로 삭제되었습니다.")); + } + @PutMapping("{examApplicationId}/subjects") public ResponseEntity> updateSubjects( @PathVariable("examApplicationId") Long examApplicationId, @@ -47,8 +58,7 @@ public ResponseEntity> getExamTicket( @RequestParam Long userId, @PathVariable("examApplicationId") Long examApplicationId ) { - ExamTicketResponse response = examApplicationService.getExamTicket(userId, - examApplicationId); + ExamTicketResponse response = examApplicationService.getExamTicket(examApplicationId); return ResponseEntity.ok( ApiResponseWrapper.success(HttpStatus.OK, "수험표 발급을 완료했습니다.", response)); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java similarity index 59% rename from src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java rename to src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java index 140fc565..6600cac1 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/examapplication/dto/ExamApplicationInfoResponse.java @@ -4,36 +4,42 @@ import java.util.Set; import life.mosu.mosuserver.presentation.common.AddressResponse; -public record ExamApplicationResponse( +public record ExamApplicationInfoResponse( Long examApplicationId, String paymentKey, - String paymentStatus, LocalDate examDate, String schoolName, AddressResponse address, Set subjects, - String lunchName + String lunchName, + Integer paymentAmount, + Integer discountAmount, + String paymentMethod ) { - public static ExamApplicationResponse of( + public static ExamApplicationInfoResponse of( Long examApplicationId, String paymentKey, - String paymentStatus, LocalDate examDate, String schoolName, AddressResponse address, Set subjects, - String lunchName + String lunchName, + Integer paymentAmount, + Integer discountAmount, + String paymentMethod ) { - return new ExamApplicationResponse( + return new ExamApplicationInfoResponse( examApplicationId, paymentKey, - paymentStatus, examDate, schoolName, address, subjects, - lunchName + lunchName, + paymentAmount, + discountAmount, + paymentMethod ); } } From b4e6183bd4833741043109fe4f9d87fbed162e60 Mon Sep 17 00:00:00 2001 From: chominju Date: Wed, 23 Jul 2025 05:58:22 +0900 Subject: [PATCH 14/28] =?UTF-8?q?feat:=20application=20=EA=B4=80=EB=A0=A8?= =?UTF-8?q?=20=EA=B8=B0=EB=8A=A5=20=EA=B5=AC=ED=98=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../application/ApplicationService.java | 219 ++++++++++++------ .../application/ApplicationController.java | 23 +- .../ApplicationControllerDocs.java | 7 +- .../application/dto/ApplicationResponse.java | 5 - .../dto/CreateApplicationResponse.java | 13 ++ .../dto/ExamApplicationResponse.java | 38 +++ .../presentation/exam/ExamController.java | 5 +- 7 files changed, 223 insertions(+), 87 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/presentation/application/dto/CreateApplicationResponse.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java 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 759590ae..f5230298 100644 --- a/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/application/ApplicationService.java @@ -1,7 +1,10 @@ package life.mosu.mosuserver.application.application; +import java.util.HashSet; import java.util.List; +import java.util.Map; import java.util.Set; +import java.util.function.Function; import java.util.stream.Collectors; import life.mosu.mosuserver.application.examapplication.ExamApplicationService; import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent; @@ -9,20 +12,24 @@ import life.mosu.mosuserver.domain.application.ApplicationJpaRepository; import life.mosu.mosuserver.domain.application.ExamTicketImageJpaEntity; import life.mosu.mosuserver.domain.application.ExamTicketImageJpaRepository; +import life.mosu.mosuserver.domain.application.Subject; import life.mosu.mosuserver.domain.exam.ExamJpaEntity; import life.mosu.mosuserver.domain.exam.ExamJpaRepository; import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaRepository; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaEntity; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaRepository; +import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; +import life.mosu.mosuserver.domain.payment.PaymentJpaRepository; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.global.util.FileRequest; +import life.mosu.mosuserver.infra.respository.ExamApplicationBulkRepository; import life.mosu.mosuserver.presentation.application.dto.ApplicationRequest; import life.mosu.mosuserver.presentation.application.dto.ApplicationResponse; +import life.mosu.mosuserver.presentation.application.dto.CreateApplicationResponse; import life.mosu.mosuserver.presentation.application.dto.ExamApplicationRequest; -import life.mosu.mosuserver.presentation.common.AddressResponse; -import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; +import life.mosu.mosuserver.presentation.application.dto.ExamApplicationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; @@ -40,18 +47,34 @@ public class ApplicationService { private final ExamSubjectJpaRepository examSubjectJpaRepository; private final ExamApplicationJpaRepository examApplicationJpaRepository; private final ExamJpaRepository examJpaRepository; + private final ExamApplicationBulkRepository examApplicationBulkRepository; + private final PaymentJpaRepository paymentJpaRepository; + @Transactional - public ApplicationResponse apply(Long userId, ApplicationRequest request) { + public CreateApplicationResponse apply(Long userId, ApplicationRequest request) { + + List examApplicationRequests = request.examApplication(); + Set subjects = request.validatedSubjects(); - // 중복 신청 검증 - 수정 필요 - List examIds = request.examApplication().stream() + List examIds = examApplicationRequests.stream() .map(ExamApplicationRequest::examId) .toList(); - // examId 가 동일 하냐? - //다른 exam 인데 시간이 같나? - //exam_id lunch_id 쌍으로 포함된 exam_application 이 존재하냐 + // 중복 신청 검증 + Set examIdSet = new HashSet<>(examIds); + if (examIds.size() != examIdSet.size()) { + throw new RuntimeException("같은 시험을 신청할 수 없습니다."); + } + + // 신청을 1개 이상 신청했는지 검증 + if (examIds.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); + } + + //해당 시험이 진짜 있는 일정인지, lunch 가 없는 시험인데 lunch 를 신청했는지 + validateExamIdsAndLunchSelection(examApplicationRequests); + boolean isDuplicate = applicationJpaRepository.existsByUserIdAndExamIds(userId, examIds); if (isDuplicate) { throw new CustomRuntimeException(ErrorCode.APPLICATION_SCHOOL_DUPLICATED); @@ -61,25 +84,15 @@ public ApplicationResponse apply(Long userId, ApplicationRequest request) { ApplicationJpaEntity savedApplication = applicationJpaRepository.save(application); Long applicationId = savedApplication.getId(); - //해당 시험이 진짜 있는 일정인지, lunch 가 없는 시험인데 lunch 를 신청했는지 - validateExamIdsAndLunchSelection(request.examApplication()); - List examApplicationEntities = examApplicationService.register( RegisterExamApplicationEvent.of(request.examApplication(), applicationId) ); - examApplicationJpaRepository.saveAll(examApplicationEntities); - - List allExamSubjects = examApplicationEntities.stream() - .flatMap(examApplication -> { - Long examApplicationId = examApplication.getId(); - return request.validatedSubjects().stream() - .map(subject -> ExamSubjectJpaEntity.create(examApplicationId, - subject)); - }) - .toList(); - examSubjectJpaRepository.saveAll(allExamSubjects); + // 시험 신청 목록과 과목 multi-insert + examApplicationBulkRepository.saveAllExamApplicationsWithSubjects(examApplicationEntities, + subjects); + // 수험표 저장 FileRequest fileReq = request.admissionTicket(); if (fileReq.fileName() != null && fileReq.s3Key() != null) { ExamTicketImageJpaEntity examTicketImage = fileReq @@ -87,63 +100,30 @@ public ApplicationResponse apply(Long userId, ApplicationRequest request) { examTicketImageJpaRepository.save(examTicketImage); } - return ApplicationResponse.of(applicationId); + return CreateApplicationResponse.of(applicationId); } - // 전체 신청 내역 조회 - 수정 필요 + // TODO: 테스트 필요 @Transactional(readOnly = true, propagation = Propagation.SUPPORTS) public List getApplications(Long userId) { - List applications = applicationJpaRepository.findAllByUserId(userId); - if (applications.isEmpty()) { - throw new CustomRuntimeException(ErrorCode.APPLICATION_LIST_NOT_FOUND); - } + List applications = getUserApplications(userId); + + List examApplications = getExamApplications(applications); + + Map examMap = getExamMap(examApplications); + + Map> subjectMap = getSubjectMap(examApplications); + + Map> examResponsesGroupedByApplicationId = + groupExamResponsesByApplication(examApplications, examMap, subjectMap); return applications.stream() - .map(application -> { - // 해당 신청의 시험 신청들 조회 - List examApplications = - examApplicationJpaRepository.findByApplicationId(application.getId()); - - if (examApplications.isEmpty()) { - throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); - } - // ExamWithSubjects 리스트 생성 - List exams = examApplications.stream() - .map(examApplication -> { - // 시험 정보 조회 - ExamJpaEntity exam = examJpaRepository.findById( - examApplication.getExamId()) - .orElseThrow(() -> new CustomRuntimeException( - ErrorCode.EXAM_NOT_FOUND)); - - // 과목 정보 조회 - List examSubjects = - examSubjectJpaRepository.findByExamApplicationId( - examApplication.getId()); - Set subjects = examSubjects.stream() - .map(examSubject -> examSubject.getSubject() - .getSubjectName()) - .collect(Collectors.toSet()); - - // ExamWithSubjects 생성 - return ExamApplicationResponse.of( - examApplication.getId(), - null, - null, - exam.getExamDate(), - exam.getSchoolName(), - AddressResponse.from(exam.getAddress()), - subjects, - examApplication.isLunchChecked() ? exam.getLunchName() - : "신청 안 함" - ); - }) - .toList(); - - return ApplicationResponse.of(application.getId(), exams); - }) + .map(app -> ApplicationResponse.of( + app.getId(), + examResponsesGroupedByApplicationId.getOrDefault(app.getId(), List.of()) + )) .toList(); } @@ -169,4 +149,101 @@ private void validateExamIdsAndLunchSelection(List reque throw new CustomRuntimeException(ErrorCode.LUNCH_SELECTION_INVALID); } } + + private List getUserApplications(Long userId) { + List applications = applicationJpaRepository.findAllByUserId(userId); + if (applications.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.APPLICATION_LIST_NOT_FOUND); + } + return applications; + } + + private List getExamApplications( + List applications) { + List applicationIds = applications.stream() + .map(ApplicationJpaEntity::getId) + .toList(); + + List examApplications = + examApplicationJpaRepository.findByApplicationIdIn(applicationIds); + + if (examApplications.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); + } + return examApplications; + } + + private Map getExamMap(List examApplications) { + List examIds = examApplications.stream() + .map(ExamApplicationJpaEntity::getExamId) + .distinct() + .toList(); + + return examJpaRepository.findByIdIn(examIds).stream() + .collect(Collectors.toMap(ExamJpaEntity::getId, Function.identity())); + } + + private Map getPaymentMap( + List examApplications) { + List examApplicationIds = examApplications.stream() + .map(ExamApplicationJpaEntity::getId) + .toList(); + + return paymentJpaRepository.findByExamApplicationIdIn(examApplicationIds).stream() + .collect(Collectors.toMap(PaymentJpaEntity::getExamApplicationId, + Function.identity())); + } + + private Map> getSubjectMap( + List examApplications) { + List examApplicationIds = examApplications.stream() + .map(ExamApplicationJpaEntity::getId) + .toList(); + + return examSubjectJpaRepository.findByExamApplicationIdIn(examApplicationIds).stream() + .collect(Collectors.groupingBy(ExamSubjectJpaEntity::getExamApplicationId)); + } + + private Map> groupExamResponsesByApplication( + List examApplications, + Map examMap, + Map> subjectMap + ) { + Map paymentMap = getPaymentMap(examApplications); + + return examApplications.stream() + .map(examApplication -> { + Long examApplicationId = examApplication.getId(); + ExamJpaEntity exam = examMap.get(examApplication.getExamId()); + + Set subjects = subjectMap.getOrDefault(examApplicationId, List.of()) + .stream() + .map(es -> es.getSubject().getSubjectName()) + .collect(Collectors.toSet()); + + PaymentJpaEntity payment = paymentMap.get(examApplicationId); + String paymentStatus = + (payment != null) ? payment.getPaymentStatus().name() : null; + Integer totalAmount = (payment != null && payment.getPaymentAmount() != null) + ? payment.getPaymentAmount().getTotalAmount() + : 0; + + ExamApplicationResponse response = ExamApplicationResponse.of( + examApplicationId, + examApplication.getCreatedAt(), + paymentStatus, + totalAmount, + exam.getSchoolName(), + exam.getExamDate(), + subjects, + examApplication.getIsLunchChecked() ? exam.getLunchName() : "신청 안 함" + ); + + return Map.entry(examApplication.getApplicationId(), response); + }) + .collect(Collectors.groupingBy( + Map.Entry::getKey, + Collectors.mapping(Map.Entry::getValue, Collectors.toList()) + )); + } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java index eb692b65..218e82af 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationController.java @@ -1,11 +1,13 @@ package life.mosu.mosuserver.presentation.application; import jakarta.validation.Valid; +import java.net.URI; import java.util.List; import life.mosu.mosuserver.application.application.ApplicationService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.application.dto.ApplicationRequest; import life.mosu.mosuserver.presentation.application.dto.ApplicationResponse; +import life.mosu.mosuserver.presentation.application.dto.CreateApplicationResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.HttpStatus; @@ -16,6 +18,7 @@ import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.util.UriComponentsBuilder; @Slf4j @RestController @@ -25,17 +28,25 @@ public class ApplicationController implements ApplicationControllerDocs { private final ApplicationService applicationService; - //신청 -> response 수정해야 함 @PostMapping // @PreAuthorize("isAuthenticated() and hasRole('USER')") - public ResponseEntity> apply( + public ResponseEntity> apply( @RequestParam Long userId, - @Valid @RequestBody ApplicationRequest request - ) { - ApplicationResponse response = applicationService.apply(userId, request); - return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "신청 성공", response)); + @Valid @RequestBody ApplicationRequest request, + UriComponentsBuilder uriComponentsBuilder) { + CreateApplicationResponse response = applicationService.apply(userId, request); + + URI location = uriComponentsBuilder + .path("/applications") + .queryParam("userId", response.applicationId()) + .build() + .toUri(); + + return ResponseEntity.created(location) + .body(ApiResponseWrapper.success(HttpStatus.OK, "신청 성공", response)); } + // TODO: 테스트 필요 //전체 신청 내역 조회 @GetMapping public ResponseEntity>> getApplications( diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationControllerDocs.java index 6ccae120..81ba9b3f 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/ApplicationControllerDocs.java @@ -13,9 +13,11 @@ import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.application.dto.ApplicationRequest; import life.mosu.mosuserver.presentation.application.dto.ApplicationResponse; +import life.mosu.mosuserver.presentation.application.dto.CreateApplicationResponse; import org.springframework.http.ResponseEntity; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.util.UriComponentsBuilder; @Tag(name = "Application API", description = "신청 관련 API 명세") public interface ApplicationControllerDocs { @@ -24,12 +26,13 @@ public interface ApplicationControllerDocs { @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "신청 성공") }) - ResponseEntity> apply( + ResponseEntity> apply( @Parameter(name = "userId", description = "사용자 ID", in = ParameterIn.QUERY) @RequestParam Long userId, @Parameter(description = "신청 요청 정보", required = true) - @Valid @RequestBody ApplicationRequest request + @Valid @RequestBody ApplicationRequest request, + UriComponentsBuilder uriComponentsBuilder ); @Operation(summary = "전체 신청 내역 조회", description = "사용자의 전체 신청 내역을 조회합니다.") diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java index a984fc27..9575eff4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ApplicationResponse.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.util.List; -import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; @Schema(description = "신청 응답 DTO") public record ApplicationResponse( @@ -14,10 +13,6 @@ public record ApplicationResponse( List exams ) { - public static ApplicationResponse of(Long applicationId) { - return new ApplicationResponse(applicationId, null); - } - public static ApplicationResponse of(Long applicationId, List exams) { return new ApplicationResponse(applicationId, exams); } diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/CreateApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/CreateApplicationResponse.java new file mode 100644 index 00000000..45ea7c73 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/CreateApplicationResponse.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.presentation.application.dto; + +public record CreateApplicationResponse( + Long applicationId +) { + + public static CreateApplicationResponse of( + Long applicationId) { + return new CreateApplicationResponse( + applicationId); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java new file mode 100644 index 00000000..aa612c2c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationResponse.java @@ -0,0 +1,38 @@ +package life.mosu.mosuserver.presentation.application.dto; + +import java.time.LocalDate; +import java.util.Set; + +public record ExamApplicationResponse( + Long examApplicationId, + String createdAt, + String paymentStatus, + Integer totalAmount, + String schoolName, + LocalDate examDate, + Set subjects, + String lunchName +) { + + public static ExamApplicationResponse of( + Long examApplicationId, + String createdAt, + String paymentStatus, + Integer totalAmount, + String schoolName, + LocalDate examDate, + Set subjects, + String lunchName + ) { + return new ExamApplicationResponse( + examApplicationId, + createdAt, + paymentStatus, + totalAmount, + schoolName, + examDate, + subjects, + lunchName + ); + } +} 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 c77c9ee0..043473c9 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/exam/ExamController.java @@ -2,7 +2,6 @@ import java.util.List; import life.mosu.mosuserver.application.exam.ExamService; -import life.mosu.mosuserver.domain.exam.Area; import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.exam.dto.ExamRequest; import life.mosu.mosuserver.presentation.exam.dto.ExamResponse; @@ -50,8 +49,8 @@ public ResponseEntity>> getByArea( } @GetMapping("/areas") - public ResponseEntity>> getDistinctAreas() { - List response = examService.getDistinctAreas(); + public ResponseEntity>> getDistinctAreas() { + List response = examService.getDistinctAreas(); return ResponseEntity.ok( ApiResponseWrapper.success(HttpStatus.OK, "시험 지역 조회 성공", response)); } From a2726d8f9dbbd19903d265497580b6ce7290575c Mon Sep 17 00:00:00 2001 From: chominju Date: Wed, 23 Jul 2025 05:59:34 +0900 Subject: [PATCH 15/28] feat: refactor exam application handling and add new projections --- .../exam/ExamQuotaCacheManager.java | 3 +- .../ExamApplicationService.java | 114 +++++++++--------- .../dto/RegisterExamApplicationEvent.java | 4 +- .../ExamTicketImageJpaRepository.java | 3 +- .../ExamApplicationInfoProjection.java | 20 +++ .../ExamApplicationJpaEntity.java | 19 ++- .../ExamApplicationJpaRepository.java | 44 +++++++ .../ExamTicketInfoProjection.java | 13 ++ .../admin/dto/ExamTicketResponse.java | 17 +++ 9 files changed, 172 insertions(+), 65 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationInfoProjection.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/examapplication/ExamTicketInfoProjection.java diff --git a/src/main/java/life/mosu/mosuserver/application/exam/ExamQuotaCacheManager.java b/src/main/java/life/mosu/mosuserver/application/exam/ExamQuotaCacheManager.java index 7358d80c..89a6a3fe 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/ExamQuotaCacheManager.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/ExamQuotaCacheManager.java @@ -48,8 +48,7 @@ public void addSchoolCurrentApplicationCount(String schoolName, Long currentCoun String key = REDIS_KEY_SCHOOL_CURRENT_APPLICATIONS + schoolName; redisTemplate.opsForValue().set(key, currentCount); } - - + public Long getSchoolApplicationCounts(String schoolName) { return redisTemplate.opsForValue() .get(REDIS_KEY_SCHOOL_CURRENT_APPLICATIONS + schoolName); 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 781ab464..ab1a4421 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java @@ -4,28 +4,26 @@ import java.util.Set; import java.util.stream.Collectors; import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent; -import life.mosu.mosuserver.domain.application.ExamTicketImageJpaEntity; -import life.mosu.mosuserver.domain.application.ExamTicketImageJpaRepository; +import life.mosu.mosuserver.domain.application.ApplicationJpaRepository; import life.mosu.mosuserver.domain.application.Subject; -import life.mosu.mosuserver.domain.exam.ExamJpaEntity; -import life.mosu.mosuserver.domain.exam.ExamJpaRepository; +import life.mosu.mosuserver.domain.discount.FixedQuantityDiscountCalculator; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationInfoProjection; import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaRepository; -import life.mosu.mosuserver.domain.examapplication.ExamNumberGenerationService; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaEntity; import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaRepository; -import life.mosu.mosuserver.domain.payment.PaymentRepository; -import life.mosu.mosuserver.domain.profile.ProfileJpaEntity; -import life.mosu.mosuserver.domain.profile.ProfileJpaRepository; +import life.mosu.mosuserver.domain.examapplication.ExamTicketInfoProjection; +import life.mosu.mosuserver.domain.examapplication.service.ExamNumberGenerationService; import life.mosu.mosuserver.global.exception.CustomRuntimeException; import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.storage.application.S3Service; import life.mosu.mosuserver.presentation.admin.dto.ExamTicketResponse; import life.mosu.mosuserver.presentation.common.AddressResponse; -import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationResponse; +import life.mosu.mosuserver.presentation.examapplication.dto.ExamApplicationInfoResponse; import life.mosu.mosuserver.presentation.examapplication.dto.UpdateSubjectRequest; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; import org.springframework.transaction.annotation.Transactional; @Service @@ -33,20 +31,18 @@ public class ExamApplicationService { private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final ApplicationJpaRepository applicationJpaRepository; private final ExamSubjectJpaRepository examSubjectJpaRepository; private final ExamNumberGenerationService examNumberGenerationService; - private final ExamTicketImageJpaRepository examTicketImageJpaRepository; - private final ProfileJpaRepository profileJpaRepository; - private final ExamJpaRepository examJpaRepository; - private final PaymentRepository paymentRepository; private final S3Service s3Service; + private final FixedQuantityDiscountCalculator calculator; @Transactional public List register(RegisterExamApplicationEvent event) { List examApplicationEntities = event.toEntity(); examNumberGenerationService.grantTo(examApplicationEntities); - return examApplicationJpaRepository.saveAll(examApplicationEntities); + return examApplicationEntities; } @Transactional @@ -56,10 +52,28 @@ public void updateSubjects(Long examApplicationId, UpdateSubjectRequest request) examSubjectJpaRepository.saveAll(examSubjects); } + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void deleteExamApplication(Long examApplicationId) { + ExamApplicationJpaEntity examApplication = examApplicationJpaRepository.findById( + examApplicationId) + .orElseThrow( + () -> new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); + Long applicationId = examApplication.getApplicationId(); + + examApplicationJpaRepository.deleteById(examApplicationId); + + if (!examApplicationJpaRepository.existsByApplicationId(applicationId)) { + applicationJpaRepository.deleteById(applicationId); + } + + examSubjectJpaRepository.deleteByExamApplicationId(examApplicationId); + + } + @Transactional - public ExamTicketResponse getExamTicket(Long userId, Long examApplicationId) { - ExamApplicationJpaEntity examApplication = examApplicationJpaRepository.findById( + public ExamTicketResponse getExamTicket(Long examApplicationId) { + ExamTicketInfoProjection examTicketInfo = examApplicationJpaRepository.findExamTicketInfoProjectionById( examApplicationId) .orElseThrow( () -> new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); @@ -68,59 +82,49 @@ public ExamTicketResponse getExamTicket(Long userId, Long examApplicationId) { examApplicationId); List subjects = examSubjects.stream() - .map(es -> es.getSubject()) + .map(ExamSubjectJpaEntity::getSubject) .toList(); - Long applicationId = examApplication.getApplicationId(); - ExamTicketImageJpaEntity examTicketImage = examTicketImageJpaRepository.findByApplicationId( - applicationId); - ; - ProfileJpaEntity profile = profileJpaRepository.findById(userId) - .orElseThrow(() -> new CustomRuntimeException(ErrorCode.PROFILE_NOT_FOUND)); - - ExamJpaEntity exam = examJpaRepository.findById(examApplication.getExamId()) - .orElseThrow(() -> new CustomRuntimeException(ErrorCode.EXAM_NOT_FOUND)); - - String examTicketImgUrl = s3Service.getPreSignedUrl(examTicketImage.getS3Key()); - - return ExamTicketResponse.of(examTicketImgUrl, profile.getUserName(), profile.getBirth(), - examApplication.getExamNumber(), subjects, exam.getSchoolName()); - + String examTicketImgUrl = s3Service.getPreSignedUrl(examTicketInfo.s3Key()); + return ExamTicketResponse.of(examTicketImgUrl, examTicketInfo.userName(), + examTicketInfo.birth(), + examTicketInfo.examNumber(), subjects, examTicketInfo.schoolName()); } - //TODO: paymentKey 작성 필요 - public ExamApplicationResponse getApplication(Long examApplicationId) { - ExamApplicationJpaEntity examApplication = examApplicationJpaRepository.findById( - examApplicationId).orElseThrow( - () -> new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); - -// PaymentJpaEntity payment = paymentRepository.findByExamApplicationId(examApplicationId); + //TODO: 테스트 필요 + public ExamApplicationInfoResponse getApplication(Long examApplicationId) { - ExamJpaEntity exam = examJpaRepository.findById( - examApplication.getExamId()) - .orElseThrow(() -> new CustomRuntimeException( - ErrorCode.EXAM_NOT_FOUND)); + ExamApplicationInfoProjection examApplicationInfo = examApplicationJpaRepository + .findExamApplicationInfoById(examApplicationId) + .orElseThrow( + () -> new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND)); - // 과목 정보 조회 List examSubjects = - examSubjectJpaRepository.findByExamApplicationId( - examApplication.getId()); + examSubjectJpaRepository.findByExamApplicationId(examApplicationId); + Set subjects = examSubjects.stream() .map(examSubject -> examSubject.getSubject() .getSubjectName()) .collect(Collectors.toSet()); - return ExamApplicationResponse.of( - examApplication.getId(), -// payment.getPaymentKey(), - null, - null, - exam.getExamDate(), - exam.getSchoolName(), - AddressResponse.from(exam.getAddress()), + Integer discountAmount = calculator.getAppliedDiscountAmount( + examApplicationInfo.paymentAmount().getTotalAmount()); + + Integer paymentAmount = + examApplicationInfo.paymentAmount().getTotalAmount() + discountAmount; + + return ExamApplicationInfoResponse.of( + examApplicationInfo.examApplicationId(), + examApplicationInfo.paymentKey(), + examApplicationInfo.examDate(), + examApplicationInfo.schoolName(), + AddressResponse.from(examApplicationInfo.address()), subjects, - examApplication.isLunchChecked() ? exam.getLunchName() : "신청 안 함" + examApplicationInfo.isLunchChecked() ? examApplicationInfo.lunchName() : "신청 안 함", + paymentAmount, + discountAmount, + examApplicationInfo.paymentMethod().getName() ); } } diff --git a/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java b/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java index d1f82d14..593f8dd6 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/dto/RegisterExamApplicationEvent.java @@ -24,14 +24,14 @@ public List toEntity() { .map(targetExam -> ExamApplicationJpaEntity.create( applicationId, targetExam.examId(), - targetExam.isLunchChecked + targetExam.isLunchChecked() )) .toList(); } public record TargetExam( Long examId, - boolean isLunchChecked + Boolean isLunchChecked ) { } diff --git a/src/main/java/life/mosu/mosuserver/domain/application/ExamTicketImageJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/application/ExamTicketImageJpaRepository.java index 1d0c05ab..f5fd8ac6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/application/ExamTicketImageJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/application/ExamTicketImageJpaRepository.java @@ -1,9 +1,10 @@ package life.mosu.mosuserver.domain.application; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; public interface ExamTicketImageJpaRepository extends JpaRepository { - ExamTicketImageJpaEntity findByApplicationId(Long applicationId); + Optional findByApplicationId(Long applicationId); } diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationInfoProjection.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationInfoProjection.java new file mode 100644 index 00000000..c3f354c7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationInfoProjection.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.domain.examapplication; + +import java.time.LocalDate; +import life.mosu.mosuserver.domain.exam.AddressJpaVO; +import life.mosu.mosuserver.domain.payment.PaymentAmountVO; +import life.mosu.mosuserver.domain.payment.PaymentMethod; + +public record ExamApplicationInfoProjection( + Long examApplicationId, + String paymentKey, + LocalDate examDate, + String schoolName, + AddressJpaVO address, + Boolean isLunchChecked, + String lunchName, + PaymentAmountVO paymentAmount, + PaymentMethod paymentMethod +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java index 96a160ca..f5f820b6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java @@ -6,16 +6,19 @@ import jakarta.persistence.GenerationType; import jakarta.persistence.Id; import jakarta.persistence.Table; +import life.mosu.mosuserver.domain.base.BaseTimeEntity; import lombok.AccessLevel; import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import lombok.extern.slf4j.Slf4j; @Getter @Entity @Table(name = "exam_application") +@Slf4j @NoArgsConstructor(access = AccessLevel.PROTECTED) -public class ExamApplicationJpaEntity { +public class ExamApplicationJpaEntity extends BaseTimeEntity { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) @@ -28,20 +31,26 @@ public class ExamApplicationJpaEntity { private Long examId; @Column(name = "lunch_checked") - private boolean isLunchChecked; + private Boolean isLunchChecked; @Column(name = "exam_number") private String examNumber; + @Column(name = "deleted") + private Boolean isDeleted = false; + @Builder - public ExamApplicationJpaEntity(Long applicationId, Long examId, boolean isLunchChecked) { + public ExamApplicationJpaEntity(Long applicationId, Long examId, + Boolean isLunchChecked) { this.applicationId = applicationId; this.examId = examId; this.isLunchChecked = isLunchChecked; } - public static ExamApplicationJpaEntity create(Long applicationId, Long examId, - boolean isLunchChecked) { + public static ExamApplicationJpaEntity create( + Long applicationId, + Long examId, + Boolean isLunchChecked) { return ExamApplicationJpaEntity.builder() .applicationId(applicationId) .examId(examId) diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaRepository.java index b3b6707e..757ded7d 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaRepository.java @@ -1,11 +1,55 @@ package life.mosu.mosuserver.domain.examapplication; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; public interface ExamApplicationJpaRepository extends JpaRepository { List findByApplicationId(Long applicationId); + + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.ExamApplicationInfoProjection( + ea.id, + p.paymentKey, + e.examDate, + e.schoolName, + e.address, + ea.isLunchChecked, + e.lunchName, + p.paymentAmount, + p.paymentMethod + ) + FROM ExamApplicationJpaEntity ea + JOIN ExamJpaEntity e on ea.examId = e.id + JOIN PaymentJpaEntity p on p.examApplicationId = ea.id + WHERE ea.id = :examApplicationId + """) + Optional findExamApplicationInfoById(Long examApplicationId); + + + @Query(""" + SELECT new life.mosu.mosuserver.domain.examapplication.ExamTicketInfoProjection( + et.s3Key, + u.name, + u.birth, + ea.examNumber, + e.schoolName + ) + FROM ExamApplicationJpaEntity ea + LEFT JOIN ExamJpaEntity e on ea.examId = e.id + LEFT JOIN ApplicationJpaEntity a on a.id = ea.applicationId + LEFT JOIN ExamTicketImageJpaEntity et on et.applicationId = a.id + LEFT JOIN UserJpaEntity u on a.userId = u.id + WHERE ea.id = :examApplicationId + """) + Optional findExamTicketInfoProjectionById( + Long examApplicationId); + + boolean existsByApplicationId(Long applicationId); + + List findByApplicationIdIn(List applicationIds); } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamTicketInfoProjection.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamTicketInfoProjection.java new file mode 100644 index 00000000..fce6500d --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamTicketInfoProjection.java @@ -0,0 +1,13 @@ +package life.mosu.mosuserver.domain.examapplication; + +import java.time.LocalDate; + +public record ExamTicketInfoProjection( + String s3Key, + String userName, + LocalDate birth, + String examNumber, + String schoolName +) { + +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java index bb119d9c..46ced76d 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java @@ -42,4 +42,21 @@ public static ExamTicketResponse of( schoolName ); } + + public static ExamTicketResponse ofWithoutExamTicket( + String userName, + LocalDate birth, + String examNumber, + List subjects, + String schoolName) { + + return new ExamTicketResponse( + null, + userName, + birth, + examNumber, + subjects, + schoolName + ); + } } \ No newline at end of file From 2a97477be6c703b488646a6962bb11355fe5ed9e Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:02:14 +0900 Subject: [PATCH 16/28] =?UTF-8?q?chore:=20gitigore=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- pinpoint-agent/profiles/.gitignore | 3 --- 1 file changed, 3 deletions(-) delete mode 100644 pinpoint-agent/profiles/.gitignore diff --git a/pinpoint-agent/profiles/.gitignore b/pinpoint-agent/profiles/.gitignore deleted file mode 100644 index 89cfb236..00000000 --- a/pinpoint-agent/profiles/.gitignore +++ /dev/null @@ -1,3 +0,0 @@ -# for custom profile -private/ -custom/ \ No newline at end of file From f59e3cb8794715a4a5fd04689b23c9059433b4ca Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:03:49 +0900 Subject: [PATCH 17/28] refactor: update S3Properties usage in S3Service to eliminate direct property injection --- .../infra/property/S3Properties.java | 6 +++-- .../infra/storage/application/S3Service.java | 22 ++++++------------- 2 files changed, 11 insertions(+), 17 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/infra/property/S3Properties.java b/src/main/java/life/mosu/mosuserver/infra/property/S3Properties.java index 40e5826c..4b24fd0d 100644 --- a/src/main/java/life/mosu/mosuserver/infra/property/S3Properties.java +++ b/src/main/java/life/mosu/mosuserver/infra/property/S3Properties.java @@ -12,10 +12,12 @@ @Slf4j public class S3Properties { - private int presignedUrlExpirationMinutes; + private String bucketName; + private int preSignedUrlExpirationMinutes; @PostConstruct public void init() { - log.info("S3 Properties Loaded. Expiration Time: {}", presignedUrlExpirationMinutes); + log.info("S3 Properties Loaded. buckName: {}", bucketName); + log.info("S3 Properties Loaded. Expiration Time: {}", preSignedUrlExpirationMinutes); } } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/storage/application/S3Service.java b/src/main/java/life/mosu/mosuserver/infra/storage/application/S3Service.java index 415d7962..17ab2703 100644 --- a/src/main/java/life/mosu/mosuserver/infra/storage/application/S3Service.java +++ b/src/main/java/life/mosu/mosuserver/infra/storage/application/S3Service.java @@ -12,7 +12,6 @@ import life.mosu.mosuserver.infra.storage.presentation.dto.FileUploadResponse; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Service; import org.springframework.web.multipart.MultipartFile; import software.amazon.awssdk.core.sync.RequestBody; @@ -36,13 +35,6 @@ public class S3Service { private final S3Presigner s3Presigner; private final S3Properties s3Properties; - @Value("${aws.s3.bucket-name}") - private String bucketName; - - @Value("${aws.s3.presigned-url-expiration-minutes}") - private int preSignedUrlExpirationMinutes; - - public FileUploadResponse uploadFile(MultipartFile file, Folder folder) { String sanitizedName = sanitizeFileName(file.getOriginalFilename()); String s3Key = folder.getPath() + "/" + UUID.randomUUID() + "_" + sanitizedName; @@ -50,7 +42,7 @@ public FileUploadResponse uploadFile(MultipartFile file, Folder folder) { try { s3Client.putObject( PutObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucketName()) .key(s3Key) .tagging("status=temp") .contentType(file.getContentType()) @@ -65,11 +57,11 @@ public FileUploadResponse uploadFile(MultipartFile file, Folder folder) { return FileUploadResponse.of(file.getOriginalFilename(), s3Key); } - + public void deleteFile(File file) { try { s3Client.deleteObject(DeleteObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucketName()) .key(file.getS3Key()) .build()); } catch (S3Exception e) { @@ -79,7 +71,7 @@ public void deleteFile(File file) { public void updateFileTagToActive(String key) { PutObjectTaggingRequest tagReq = PutObjectTaggingRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucketName()) .key(key) .tagging(Tagging.builder() .tagSet(List.of(Tag.builder().key("status").value("active").build())) @@ -96,16 +88,16 @@ public String getUrl(File file) { } public String getPublicUrl(String s3Key) { - return String.format("https://%s.s3.amazonaws.com/%s", bucketName, s3Key); + return String.format("https://%s.s3.amazonaws.com/%s", s3Properties.getBucketName(), s3Key); } public String getPreSignedUrl(String s3Key) { GetObjectRequest getObjectRequest = GetObjectRequest.builder() - .bucket(bucketName) + .bucket(s3Properties.getBucketName()) .key(s3Key) .build(); Duration expirationDuration = Duration.ofMinutes( - s3Properties.getPresignedUrlExpirationMinutes() + s3Properties.getPreSignedUrlExpirationMinutes() ); GetObjectPresignRequest preSignRequest = GetObjectPresignRequest.builder() .getObjectRequest(getObjectRequest) From 3aa6f2ec94bc859adcdc49a396f8566daf693d69 Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:05:02 +0900 Subject: [PATCH 18/28] feat: implement transaction event handling with DefaultTxEventPublisher and related interfaces --- .../global/tx/DefaultTxEventPublisher.java | 18 +++++++++++++++++ .../mosu/mosuserver/global/tx/TxEvent.java | 20 +++++++++++++++++++ .../mosuserver/global/tx/TxEventFactory.java | 6 ++++++ .../global/tx/TxEventPublisher.java | 12 +++++++++++ .../global/tx/TxFailureHandler.java | 6 ++++++ 5 files changed, 62 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/global/tx/DefaultTxEventPublisher.java create mode 100644 src/main/java/life/mosu/mosuserver/global/tx/TxEvent.java create mode 100644 src/main/java/life/mosu/mosuserver/global/tx/TxEventFactory.java create mode 100644 src/main/java/life/mosu/mosuserver/global/tx/TxEventPublisher.java create mode 100644 src/main/java/life/mosu/mosuserver/global/tx/TxFailureHandler.java diff --git a/src/main/java/life/mosu/mosuserver/global/tx/DefaultTxEventPublisher.java b/src/main/java/life/mosu/mosuserver/global/tx/DefaultTxEventPublisher.java new file mode 100644 index 00000000..808d4fb5 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/tx/DefaultTxEventPublisher.java @@ -0,0 +1,18 @@ +package life.mosu.mosuserver.global.tx; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class DefaultTxEventPublisher implements TxEventPublisher { + + private final ApplicationEventPublisher publisher; + + @Override + public ApplicationEventPublisher publisher() { + return publisher; + } + +} diff --git a/src/main/java/life/mosu/mosuserver/global/tx/TxEvent.java b/src/main/java/life/mosu/mosuserver/global/tx/TxEvent.java new file mode 100644 index 00000000..f5a73e87 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/tx/TxEvent.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.global.tx; + +public abstract class TxEvent { + + private final boolean success; + private final T context; + + protected TxEvent(boolean success, T context) { + this.success = success; + this.context = context; + } + + public boolean isSuccess() { + return success; + } + + public T getContext() { + return context; + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/tx/TxEventFactory.java b/src/main/java/life/mosu/mosuserver/global/tx/TxEventFactory.java new file mode 100644 index 00000000..77bc9545 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/tx/TxEventFactory.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.global.tx; + +public interface TxEventFactory { + + TxEvent create(T context); +} diff --git a/src/main/java/life/mosu/mosuserver/global/tx/TxEventPublisher.java b/src/main/java/life/mosu/mosuserver/global/tx/TxEventPublisher.java new file mode 100644 index 00000000..ee4088d8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/tx/TxEventPublisher.java @@ -0,0 +1,12 @@ +package life.mosu.mosuserver.global.tx; + +import org.springframework.context.ApplicationEventPublisher; + +public interface TxEventPublisher { + + ApplicationEventPublisher publisher(); + + default void publish(T event) { + publisher().publishEvent(event); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/global/tx/TxFailureHandler.java b/src/main/java/life/mosu/mosuserver/global/tx/TxFailureHandler.java new file mode 100644 index 00000000..1c3b0d99 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/tx/TxFailureHandler.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.global.tx; + +public interface TxFailureHandler { + + void handle(E event); +} From 6b23a5e561d26def591e572e6531f23da79a9a05 Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:05:21 +0900 Subject: [PATCH 19/28] feat: implement refund processing service and related components --- .../refund/RefundEventTxService.java | 43 +++++++++++++++++++ .../application/refund/RefundService.java | 20 +++++++++ .../refund/processor/RefundProcessor.java | 6 +++ .../refund/processor/TossRefundProcessor.java | 25 +++++++++++ .../application/refund/tx/RefundContext.java | 39 +++++++++++++++++ .../application/refund/tx/RefundTxEvent.java | 10 +++++ .../refund/tx/RefundTxEventFactory.java | 15 +++++++ .../refund/tx/RefundTxEventListener.java | 25 +++++++++++ .../refund/tx/RefundTxFailureHandler.java | 14 ++++++ .../domain/refund/RefundStatus.java | 6 +++ .../presentation/refund/RefundController.java | 30 +++++++++++++ .../refund/dto/RefundRequest.java | 9 ++++ 12 files changed, 242 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/RefundEventTxService.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/RefundService.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/processor/RefundProcessor.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/processor/TossRefundProcessor.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/tx/RefundContext.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEvent.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventFactory.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java create mode 100644 src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxFailureHandler.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/refund/RefundStatus.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundRequest.java diff --git a/src/main/java/life/mosu/mosuserver/application/refund/RefundEventTxService.java b/src/main/java/life/mosu/mosuserver/application/refund/RefundEventTxService.java new file mode 100644 index 00000000..438ec2a7 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/RefundEventTxService.java @@ -0,0 +1,43 @@ +package life.mosu.mosuserver.application.refund; + + +import life.mosu.mosuserver.application.refund.tx.RefundContext; +import life.mosu.mosuserver.application.refund.tx.RefundTxEventFactory; +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class RefundEventTxService { + + private final TxEventPublisher txEventPublisher; + private final RefundTxEventFactory eventFactory; + + @Transactional + public void publishSuccessEvent( + Long paymentId, + String orderId, + Integer totalAmount + ) { + TxEvent event = eventFactory.create( + RefundContext.ofSuccess(paymentId, orderId, totalAmount)); + txEventPublisher.publish(event); + } + + @Transactional + public void publishFailureEvent( + Long paymentId, + String orderId, + Integer totalAmount + ) { + TxEvent event = eventFactory.create( + RefundContext.ofFailure(paymentId, orderId, totalAmount)); + txEventPublisher.publish(event); + } +} + diff --git a/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java b/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java new file mode 100644 index 00000000..47d461ff --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/RefundService.java @@ -0,0 +1,20 @@ +package life.mosu.mosuserver.application.refund; + +import life.mosu.mosuserver.domain.refund.RefundJpaRepository; +import life.mosu.mosuserver.presentation.refund.dto.RefundRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class RefundService { + + private final RefundJpaRepository refundJpaRepository; + private final RefundEventTxService refundEventTxService; + + @Transactional + public void doProcess(String paymentKey, RefundRequest request) { + + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/processor/RefundProcessor.java b/src/main/java/life/mosu/mosuserver/application/refund/processor/RefundProcessor.java new file mode 100644 index 00000000..73093d71 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/processor/RefundProcessor.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.application.refund.processor; + +public interface RefundProcessor { + + RES execute(REQ request); +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/processor/TossRefundProcessor.java b/src/main/java/life/mosu/mosuserver/application/refund/processor/TossRefundProcessor.java new file mode 100644 index 00000000..d117e9aa --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/processor/TossRefundProcessor.java @@ -0,0 +1,25 @@ +package life.mosu.mosuserver.application.refund.processor; + +import life.mosu.mosuserver.infra.payment.TossPaymentClient; +import life.mosu.mosuserver.infra.payment.dto.CancelTossPaymentResponse; +import life.mosu.mosuserver.presentation.refund.dto.RefundRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TossRefundProcessor implements + RefundProcessor { + + private final TossPaymentClient tossPayment; + + @Override + public CancelTossPaymentResponse execute(RefundRequest request) { + try { +// return tossPayment.cancelPayment(request.paymentKey(), request.toPayload()); + return null; + } catch (Exception ex) { + throw new RuntimeException("Toss 결제 실패", ex); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundContext.java b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundContext.java new file mode 100644 index 00000000..731353fa --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundContext.java @@ -0,0 +1,39 @@ +package life.mosu.mosuserver.application.refund.tx; + +import life.mosu.mosuserver.domain.refund.RefundStatus; + +public record RefundContext( + Long paymentId, + String orderId, + Boolean isSuccess, + RefundStatus status, + Integer totalAmount +) { + + public static RefundContext ofSuccess( + Long paymentId, + String orderId, + Integer totalAmount + ) { + return new RefundContext( + paymentId, + orderId, + true, + RefundStatus.DONE, + totalAmount); + } + + public static RefundContext ofFailure( + Long paymentId, + String orderId, + Integer totalAmount + ) { + return new RefundContext( + paymentId, + orderId, + false, + RefundStatus.ABORTED, + totalAmount + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEvent.java b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEvent.java new file mode 100644 index 00000000..16f6e3ea --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEvent.java @@ -0,0 +1,10 @@ +package life.mosu.mosuserver.application.refund.tx; + +import life.mosu.mosuserver.global.tx.TxEvent; + +public class RefundTxEvent extends TxEvent { + + public RefundTxEvent(boolean success, RefundContext context) { + super(success, context); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventFactory.java b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventFactory.java new file mode 100644 index 00000000..16e2d0ee --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventFactory.java @@ -0,0 +1,15 @@ +package life.mosu.mosuserver.application.refund.tx; + + +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventFactory; +import org.springframework.stereotype.Component; + +@Component +public class RefundTxEventFactory implements TxEventFactory { + + @Override + public TxEvent create(RefundContext context) { + return new RefundTxEvent(context.isSuccess(), context); + } +} \ No newline at end of file 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 new file mode 100644 index 00000000..9129e8b3 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxEventListener.java @@ -0,0 +1,25 @@ +package life.mosu.mosuserver.application.refund.tx; + +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RefundTxEventListener { + + private final TxFailureHandler refundFailureHandler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + public void afterRollbackHandler(RefundTxEvent event) { + RefundContext ctx = event.getContext(); + log.warn("[AFTER_ROLLBACK] 롤백 후 처리 시작: orderId={}", ctx.orderId()); + refundFailureHandler.handle(event); + log.info("[AFTER_ROLLBACK] 롤백 후 처리 완료: orderId={}", ctx.orderId()); + } + +} diff --git a/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxFailureHandler.java new file mode 100644 index 00000000..e69cc609 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/refund/tx/RefundTxFailureHandler.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.application.refund.tx; + +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import org.springframework.stereotype.Component; + +@Component +public class RefundTxFailureHandler implements + TxFailureHandler { + + @Override + public void handle(RefundTxEvent event) { + + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/refund/RefundStatus.java b/src/main/java/life/mosu/mosuserver/domain/refund/RefundStatus.java new file mode 100644 index 00000000..c4752a05 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/refund/RefundStatus.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.domain.refund; + +public enum RefundStatus { + ABORTED, + DONE +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java b/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java new file mode 100644 index 00000000..975763e0 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/refund/RefundController.java @@ -0,0 +1,30 @@ +package life.mosu.mosuserver.presentation.refund; + +import life.mosu.mosuserver.application.refund.RefundService; +import life.mosu.mosuserver.global.util.ApiResponseWrapper; +import life.mosu.mosuserver.presentation.refund.dto.RefundRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +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.RestController; + +@RestController +@RequestMapping("/refunds") +@RequiredArgsConstructor + +public class RefundController { + + private final RefundService refundService; + + @PostMapping("/{paymentKey}") + ResponseEntity> process( + @PathVariable("paymentKey") String paymentKey, + @RequestBody RefundRequest request) { + refundService.doProcess(paymentKey, request); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "결제 취소 성공")); + } +} diff --git a/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundRequest.java b/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundRequest.java new file mode 100644 index 00000000..d46d893e --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/presentation/refund/dto/RefundRequest.java @@ -0,0 +1,9 @@ +package life.mosu.mosuserver.presentation.refund.dto; + +public record RefundRequest( + Long examId, + String refundReason, + Boolean isRefundAgreed +) { + +} From ddc4eaafc6c6a9dd9418c8f9538ae3207358b1e8 Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:06:06 +0900 Subject: [PATCH 20/28] feat: implement payment transaction handling with event publishing and failure management - event driven transaction - when error, rollback --- .../application/payment/OrderIdGenerator.java | 12 -- .../application/payment/PaymentEvent.java | 35 ---- .../payment/PaymentEventListener.java | 61 ------- .../payment/PaymentEventTxService.java | 35 ++++ .../payment/PaymentFailureHandler.java | 44 ----- .../application/payment/PaymentService.java | 169 +++++------------- .../payment/processor/PaymentProcessor.java | 6 + .../processor/TossPaymentProcessor.java | 23 +++ .../payment/tx/PaymentContext.java | 39 ++++ .../payment/tx/PaymentTxEvent.java | 12 ++ .../payment/tx/PaymentTxEventFactory.java | 14 ++ .../payment/tx/PaymentTxEventListener.java | 29 +++ .../payment/tx/PaymentTxFailureHandler.java | 51 ++++++ ...ository.java => PaymentJpaRepository.java} | 4 +- .../service/PaymentAmountCalculator.java | 30 ++++ .../domain/payment/service/PaymentMapper.java | 15 ++ .../service/PaymentOrderIdGenerator.java | 14 ++ .../dto/ConfirmTossPaymentResponse.java | 4 +- .../payment/PaymentWidgetController.java | 2 +- .../payment/dto/PaymentRequest.java | 7 +- 20 files changed, 323 insertions(+), 283 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/application/payment/OrderIdGenerator.java delete mode 100644 src/main/java/life/mosu/mosuserver/application/payment/PaymentEvent.java delete mode 100644 src/main/java/life/mosu/mosuserver/application/payment/PaymentEventListener.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/PaymentEventTxService.java delete mode 100644 src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/processor/PaymentProcessor.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/processor/TossPaymentProcessor.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentContext.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEvent.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventFactory.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java create mode 100644 src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java rename src/main/java/life/mosu/mosuserver/domain/payment/{PaymentRepository.java => PaymentJpaRepository.java} (67%) create mode 100644 src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentAmountCalculator.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentMapper.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentOrderIdGenerator.java diff --git a/src/main/java/life/mosu/mosuserver/application/payment/OrderIdGenerator.java b/src/main/java/life/mosu/mosuserver/application/payment/OrderIdGenerator.java deleted file mode 100644 index d5b1d1f4..00000000 --- a/src/main/java/life/mosu/mosuserver/application/payment/OrderIdGenerator.java +++ /dev/null @@ -1,12 +0,0 @@ -package life.mosu.mosuserver.application.payment; - -import java.util.UUID; -import org.springframework.stereotype.Component; - -@Component -public class OrderIdGenerator { - - public String generate() { - return UUID.randomUUID().toString(); - } -} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentEvent.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentEvent.java deleted file mode 100644 index 6b9c4198..00000000 --- a/src/main/java/life/mosu/mosuserver/application/payment/PaymentEvent.java +++ /dev/null @@ -1,35 +0,0 @@ -package life.mosu.mosuserver.application.payment; - -import java.util.List; -import life.mosu.mosuserver.domain.payment.PaymentStatus; - -public record PaymentEvent( - List applicationSchoolIds, - String orderId, - PaymentStatus status, - Integer totalAmount -) { - - public static PaymentEvent ofSuccess(List applicationIds, String orderId, - Integer totalAmount) { - return new PaymentEvent(applicationIds, orderId, PaymentStatus.DONE, totalAmount); - } - - public static PaymentEvent ofCancelled(List applicationIds, String orderId, - Integer totalAmount) { - return new PaymentEvent(applicationIds, orderId, PaymentStatus.CANCELLED_DONE, totalAmount); - } - - public static PaymentEvent ofFailed(List applicationIds, String orderId, - Integer totalAmount) { - return new PaymentEvent(applicationIds, orderId, PaymentStatus.ABORTED, totalAmount); - } - - @Override - public String toString() { - return "PaymentEvent{" + - "orderId='" + orderId + '\'' + - ", status=" + status + - '}'; - } -} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventListener.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventListener.java deleted file mode 100644 index 9d614b1d..00000000 --- a/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventListener.java +++ /dev/null @@ -1,61 +0,0 @@ -package life.mosu.mosuserver.application.payment; - -import life.mosu.mosuserver.domain.payment.PaymentRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.retry.annotation.Backoff; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; -import org.springframework.stereotype.Component; -import org.springframework.transaction.annotation.Propagation; -import org.springframework.transaction.annotation.Transactional; -import org.springframework.transaction.event.TransactionPhase; -import org.springframework.transaction.event.TransactionalEventListener; - -@Transactional(propagation = Propagation.NOT_SUPPORTED) -@Component -@Slf4j -@RequiredArgsConstructor -public class PaymentEventListener { - - private final PaymentRepository paymentRepository; - private final PaymentFailureHandler paymentFailureHandler; - - @TransactionalEventListener(phase = TransactionPhase.BEFORE_COMMIT) - public void beforeCommitHandler(PaymentEvent event) { - log.debug("[BEFORE_COMMIT] 커밋 직전 처리: orderId={}", event.orderId()); - // 예: 캐시 업데이트, 커밋 직전 검증 - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) - public void afterCommitHandler(PaymentEvent event) { - log.info("[AFTER_COMMIT] 커밋 성공 후 처리: orderId={}", event.orderId()); - // 예: 외부 API 호출, 메시지 큐 발행, 알림 전송 - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) - @Retryable( - retryFor = Exception.class, - maxAttempts = 3, - backoff = @Backoff(delay = 2000, multiplier = 2) - ) - public void afterRollbackHandler(PaymentEvent event) { - log.warn("[AFTER_ROLLBACK] 롤백 후 처리 시작: orderId={}", event.orderId()); - paymentFailureHandler.handlePaymentFailure(event); - log.info("[AFTER_ROLLBACK] 롤백 후 처리 완료: orderId={}", event.orderId()); - } - - @TransactionalEventListener(phase = TransactionPhase.AFTER_COMPLETION) - public void afterCompletionHandler(PaymentEvent event) { - log.debug("[AFTER_COMPLETION] 커밋/롤백 후 무조건 처리: orderId={}", event.orderId()); - // 리소스 정리, 상태 초기화 등 - } - - @Recover - public void recoverAfterRollbackHandler(Exception ex, PaymentEvent event) { - log.error("[RECOVER] 롤백 후 처리 재시도 실패: orderId={}, error={}", event.orderId(), - ex.getMessage(), ex); - } - -} - diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventTxService.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventTxService.java new file mode 100644 index 00000000..33398ee8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/PaymentEventTxService.java @@ -0,0 +1,35 @@ +package life.mosu.mosuserver.application.payment; + +import java.util.List; +import life.mosu.mosuserver.application.payment.tx.PaymentContext; +import life.mosu.mosuserver.application.payment.tx.PaymentTxEventFactory; +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventPublisher; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PaymentEventTxService { + + private final TxEventPublisher txEventPublisher; + private final PaymentTxEventFactory eventFactory; + + @Transactional + public void publishSuccessEvent(List examApplicationIds, String orderId, int amount) { + TxEvent event = eventFactory.create( + PaymentContext.ofSuccess(examApplicationIds, orderId, amount)); + txEventPublisher.publish(event); + } + + @Transactional + public void publishFailureEvent(List examApplicationIds, String orderId, int amount) { + TxEvent event = eventFactory.create( + PaymentContext.ofFailure(examApplicationIds, orderId, amount)); + txEventPublisher.publish(event); + } +} + diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java deleted file mode 100644 index 0ab4b0c6..00000000 --- a/src/main/java/life/mosu/mosuserver/application/payment/PaymentFailureHandler.java +++ /dev/null @@ -1,44 +0,0 @@ -package life.mosu.mosuserver.application.payment; - -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; -import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; -import life.mosu.mosuserver.domain.payment.PaymentRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Component; - -@Slf4j -@RequiredArgsConstructor -@Component -public class PaymentFailureHandler { - - private final PaymentRepository paymentRepository; - - public void handlePaymentFailure(PaymentEvent event) { - List existingPayments = paymentRepository.findByOrderId(event.orderId()); - Set existingAppIds = existingPayments.stream() - .map(PaymentJpaEntity::getExamApplicationId) - .collect(Collectors.toSet()); - - List missingAppSchoolIds = event.applicationSchoolIds().stream() - .filter(appSchoolId -> !existingAppIds.contains(appSchoolId)) - .toList(); - - // 상태 변경 - existingPayments.forEach(payment -> payment.changeStatus(event.status())); - - // 실패 신규 엔티티 생성 ( 배치 후속 처리 필요 ) - List newPayments = missingAppSchoolIds.stream() - .map(appSchoolId -> PaymentJpaEntity.ofFailure( - appSchoolId, - event.orderId(), - event.status(), - event.totalAmount())) - .toList(); - - paymentRepository.saveAll(existingPayments); - paymentRepository.saveAll(newPayments); - } -} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/PaymentService.java b/src/main/java/life/mosu/mosuserver/application/payment/PaymentService.java index b7cd0419..72984901 100644 --- a/src/main/java/life/mosu/mosuserver/application/payment/PaymentService.java +++ b/src/main/java/life/mosu/mosuserver/application/payment/PaymentService.java @@ -1,25 +1,24 @@ package life.mosu.mosuserver.application.payment; -import static life.mosu.mosuserver.domain.discount.DiscountPolicy.FIXED_QUANTITY; - import java.util.List; -import life.mosu.mosuserver.domain.discount.DiscountPolicy; +import life.mosu.mosuserver.application.payment.processor.TossPaymentProcessor; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaRepository; import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; -import life.mosu.mosuserver.domain.payment.PaymentRepository; -import life.mosu.mosuserver.infra.payment.TossPaymentClient; +import life.mosu.mosuserver.domain.payment.PaymentJpaRepository; +import life.mosu.mosuserver.domain.payment.service.PaymentAmountCalculator; +import life.mosu.mosuserver.domain.payment.service.PaymentMapper; +import life.mosu.mosuserver.domain.payment.service.PaymentOrderIdGenerator; +import life.mosu.mosuserver.global.exception.CustomRuntimeException; +import life.mosu.mosuserver.global.exception.ErrorCode; import life.mosu.mosuserver.infra.payment.dto.ConfirmTossPaymentResponse; -import life.mosu.mosuserver.presentation.payment.dto.CancelPaymentRequest; import life.mosu.mosuserver.presentation.payment.dto.PaymentPrepareResponse; import life.mosu.mosuserver.presentation.payment.dto.PaymentRequest; import life.mosu.mosuserver.presentation.payment.dto.PreparePaymentRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.context.ApplicationEventPublisher; -import org.springframework.retry.annotation.Recover; -import org.springframework.retry.annotation.Retryable; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.HttpStatusCodeException; /** * 영속화 처리 이미 들어올 때 할인 정책을 포함해야함 @@ -29,135 +28,63 @@ @RequiredArgsConstructor public class PaymentService { - // private final ApplicationSchoolJpaRepository applicationSchoolJpaRepository; - private final TossPaymentClient tossPayment; - private final OrderIdGenerator orderIdGenerator; - private final PaymentRepository paymentRepository; - private final ApplicationEventPublisher publisher; + private final TossPaymentProcessor tossProcessor; + private final PaymentAmountCalculator amountCalculator; + private final PaymentMapper paymentMapper; + private final PaymentJpaRepository paymentJpaRepository; + private final ExamApplicationJpaRepository examApplicationJpaRepository; + private final PaymentOrderIdGenerator orderIdGenerator; - public PaymentPrepareResponse prepare(PreparePaymentRequest request) { - /** - * 인원 수 redis에 동기화 -> 인원수가 넘어가면, application 까지 rollback - */ - String uuid = orderIdGenerator.generate(); -// int applicationCount = request.getSize(); -// int totalAmount = DiscountPolicy.calculate(FIXED_QUANTITY, applicationCount); + private final PaymentEventTxService eventTxService; - return PaymentPrepareResponse.of(uuid, 0); + public PaymentPrepareResponse prepare(PreparePaymentRequest request) { + List applications = getValidApplications(request.applicationId()); + String orderId = orderIdGenerator.generate(); + int totalAmount = amountCalculator.calculateTotal(applications); + return PaymentPrepareResponse.of(orderId, totalAmount); } @Transactional - @Retryable(retryFor = {HttpStatusCodeException.class}) public void confirm(PaymentRequest request) { + List applications = getValidApplications(request.applicationId()); String orderId = request.orderId(); - List applicationSchoolIds = request.applicationSchoolIds(); - Integer amount = request.amount(); - try { - checkApplicationsExist(applicationSchoolIds); - verifyAmount(request.applicantSize(), request.amount()); - checkDuplicatePayment(orderId); - ConfirmTossPaymentResponse response = confirmPaymentWithToss(request); - List paymentEntities = mapToPaymentEntities(request, response); - verifyPaymentSuccess(paymentEntities); - savePayments(paymentEntities); - publisher.publishEvent(PaymentEvent.ofSuccess(applicationSchoolIds, orderId, amount)); - } catch (Exception ex) { - log.error("error : {}", ex.getMessage()); - publisher.publishEvent(PaymentEvent.ofFailed(applicationSchoolIds, orderId, amount)); - throw ex; - } - } - - @Recover - public void recoverConfirm() { - - } - - @Transactional - public void cancel(String paymentId, CancelPaymentRequest request) { - //환불이 가능한가? - tossPayment.cancelPayment(paymentId, request); - // 환불 정책 - // 영속화 해지할 필요 X - // 영속화 된 거에서 환불 상태로 변경 - } - - - private void checkApplicationsExist(List applicationIds) { -// boolean existsAll = applicationSchoolJpaRepository.existsAllByIds(applicationIds, -// applicationIds.size()); - boolean existsAll = true; - if (!existsAll) { - log.warn("Application IDs not found: {}", applicationIds); - throw new RuntimeException("존재하지 않는 신청입니다."); - } - } + int lunchAmount = amountCalculator.calculateLunchAmount(applications); + List examApplicationIds = applications.stream() + .map(ExamApplicationJpaEntity::getId) + .toList(); - private void verifyAmount(int applicationCount, int requestedAmount) { - int expectedAmount = DiscountPolicy.calculate(FIXED_QUANTITY, applicationCount); - if (requestedAmount != expectedAmount) { - log.warn("Payment amount mismatch: requested={}, expected={}", requestedAmount, - expectedAmount); - throw new RuntimeException("결제 금액이 올바르지 않습니다."); - } - } + try { + amountCalculator.verifyAmount(applications.size(), request.amount() + lunchAmount); + validateNoDuplicateOrderId(orderId); - private void checkDuplicatePayment(String orderId) { - if (paymentRepository.existsByOrderId(orderId)) { - log.warn("Duplicate payment orderId: {}", orderId); - throw new RuntimeException("이미 존재하는 결제 건 입니다."); - } - } + ConfirmTossPaymentResponse tossResponse = tossProcessor.execute(request); - private ConfirmTossPaymentResponse confirmPaymentWithToss(PaymentRequest request) { - try { - ConfirmTossPaymentResponse response = tossPayment.confirmPayment(request.toPayload()); + List payments = paymentMapper.toEntities( + examApplicationIds, + tossResponse + ); + paymentJpaRepository.saveAll(payments); - log.info("Toss payment confirmed successfully: orderId={}", request.orderId()); - return response; -// return new ConfirmTossPaymentResponse( -// "tviva20250702231345mODA4", // paymentKey -// "IDMAoki7azYp8SzQ06LMt12323235", // orderId -// "DONE", // status -// "2025-07-02T23:14:33+09:00", // approvedAt -// 1_000, // totalAmount -// 1_000, // balanceAmount -// 1_000, // suppliedAmount -// 1_000, // vat -// 1_000, // taxFreeAmount -// "간편결제" // method -// ); + eventTxService.publishSuccessEvent(examApplicationIds, orderId, request.amount()); } catch (Exception ex) { - log.error("Toss payment confirmation failed for orderId={}", request.orderId(), ex); - throw ex; // @Retryable 에 의해 재시도 됨 + log.error("결제 승인 실패: {}", ex.getMessage(), ex); + eventTxService.publishFailureEvent(examApplicationIds, orderId, request.amount()); + throw ex; // 롤백 유도 } } - private List mapToPaymentEntities(PaymentRequest request, - ConfirmTossPaymentResponse response) { - return request.applicationSchoolIds().stream() - .map(response::toEntity) - .toList(); - } - - private void verifyPaymentSuccess(List paymentEntities) { - List failedIds = paymentEntities.stream() - .filter(p -> !p.getPaymentStatus().isPaySuccess()) - .map(PaymentJpaEntity::getId) - .toList(); - if (!failedIds.isEmpty()) { - log.error("Payment failed for application IDs: {}", failedIds); - throw new RuntimeException("결제가 실패한 신청서가 있습니다: " + failedIds); + private List getValidApplications(Long applicationId) { + List applications = examApplicationJpaRepository.findByApplicationId( + applicationId); + if (applications.isEmpty()) { + throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_NOT_FOUND); } + return applications; } - private void savePayments(List paymentEntities) { - try { - paymentRepository.saveAll(paymentEntities); - log.info("Payment records saved for {} payments", paymentEntities.size()); - } catch (Exception ex) { - log.error("Failed to save payment records", ex); - throw ex; // 트랜잭션 롤백 유도 + private void validateNoDuplicateOrderId(String orderId) { + if (paymentJpaRepository.existsByOrderId(orderId)) { + throw new CustomRuntimeException(ErrorCode.PAYMENT_ALREADY_EXISTS); } } } diff --git a/src/main/java/life/mosu/mosuserver/application/payment/processor/PaymentProcessor.java b/src/main/java/life/mosu/mosuserver/application/payment/processor/PaymentProcessor.java new file mode 100644 index 00000000..bfb8e1cb --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/processor/PaymentProcessor.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.application.payment.processor; + +public interface PaymentProcessor { + + RES execute(REQ request); +} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/processor/TossPaymentProcessor.java b/src/main/java/life/mosu/mosuserver/application/payment/processor/TossPaymentProcessor.java new file mode 100644 index 00000000..c69752d8 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/processor/TossPaymentProcessor.java @@ -0,0 +1,23 @@ +package life.mosu.mosuserver.application.payment.processor; + +import life.mosu.mosuserver.infra.payment.TossPaymentClient; +import life.mosu.mosuserver.infra.payment.dto.ConfirmTossPaymentResponse; +import life.mosu.mosuserver.presentation.payment.dto.PaymentRequest; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class TossPaymentProcessor implements + PaymentProcessor { + + private final TossPaymentClient tossPayment; + + public ConfirmTossPaymentResponse execute(PaymentRequest request) { + try { + return tossPayment.confirmPayment(request.toPayload()); + } catch (Exception ex) { + throw new RuntimeException("Toss 결제 실패", ex); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentContext.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentContext.java new file mode 100644 index 00000000..d0852342 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentContext.java @@ -0,0 +1,39 @@ +package life.mosu.mosuserver.application.payment.tx; + +import java.util.List; +import life.mosu.mosuserver.domain.payment.PaymentStatus; + +public record PaymentContext( + List examSchoolIds, + String orderId, + Boolean isSuccess, + PaymentStatus status, + Integer totalAmount +) { + + public static PaymentContext ofSuccess( + List examSchoolIds, + String orderId, + Integer totalAmount) { + return new PaymentContext( + examSchoolIds, + orderId, + true, + PaymentStatus.DONE, + totalAmount + ); + } + + public static PaymentContext ofFailure( + List examSchoolIds, + String orderId, + Integer totalAmount) { + return new PaymentContext( + examSchoolIds, + orderId, + false, + PaymentStatus.ABORTED, + totalAmount + ); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEvent.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEvent.java new file mode 100644 index 00000000..31b42c6c --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEvent.java @@ -0,0 +1,12 @@ +package life.mosu.mosuserver.application.payment.tx; + + +import life.mosu.mosuserver.global.tx.TxEvent; + + +public class PaymentTxEvent extends TxEvent { + + public PaymentTxEvent(boolean success, PaymentContext context) { + super(success, context); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventFactory.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventFactory.java new file mode 100644 index 00000000..5301d8c6 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventFactory.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.application.payment.tx; + +import life.mosu.mosuserver.global.tx.TxEvent; +import life.mosu.mosuserver.global.tx.TxEventFactory; +import org.springframework.stereotype.Component; + +@Component +public class PaymentTxEventFactory implements TxEventFactory { + + @Override + public TxEvent create(PaymentContext context) { + return new PaymentTxEvent(context.isSuccess(), context); + } +} diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java new file mode 100644 index 00000000..3b9e0568 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxEventListener.java @@ -0,0 +1,29 @@ +package life.mosu.mosuserver.application.payment.tx; + +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Transactional(propagation = Propagation.NOT_SUPPORTED) +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentTxEventListener { + + private final TxFailureHandler paymentFailureHandler; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK) + public void afterRollbackHandler(PaymentTxEvent event) { + PaymentContext ctx = event.getContext(); + log.warn("[AFTER_ROLLBACK] 롤백 후 처리 시작: orderId={}", ctx.orderId()); + paymentFailureHandler.handle(event); + log.info("[AFTER_ROLLBACK] 롤백 후 처리 완료: orderId={}", ctx.orderId()); + } + +} + diff --git a/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java new file mode 100644 index 00000000..8c0f1adb --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/application/payment/tx/PaymentTxFailureHandler.java @@ -0,0 +1,51 @@ +package life.mosu.mosuserver.application.payment.tx; + +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; +import life.mosu.mosuserver.domain.payment.PaymentJpaRepository; +import life.mosu.mosuserver.global.tx.TxFailureHandler; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +@Slf4j +@RequiredArgsConstructor +@Component +public class PaymentTxFailureHandler implements + TxFailureHandler { + + private final PaymentJpaRepository paymentJpaRepository; + + @Override + public void handle(PaymentTxEvent event) { + + PaymentContext ctx = event.getContext(); + List existingPayments = paymentJpaRepository + .findByOrderId(ctx.orderId()); + + Set existingAppIds = existingPayments.stream() + .map(PaymentJpaEntity::getExamApplicationId) + .collect(Collectors.toSet()); + + List missingExamSchoolIds = ctx.examSchoolIds().stream() + .filter(examSchoolId -> !existingAppIds.contains(examSchoolId)) + .toList(); + + existingPayments.forEach(payment -> payment.changeStatus(ctx.status())); + + // 실패 신규 엔티티 생성 ( 배치 후속 처리 필요 ) + List newPayments = missingExamSchoolIds.stream() + .map(examSchoolId -> PaymentJpaEntity.ofFailure( + examSchoolId, + ctx.orderId(), + ctx.status(), + ctx.totalAmount()) + ) + .toList(); + + paymentJpaRepository.saveAll(existingPayments); + paymentJpaRepository.saveAll(newPayments); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaRepository.java similarity index 67% rename from src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java rename to src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaRepository.java index 82ddbce4..9646d6fb 100644 --- a/src/main/java/life/mosu/mosuserver/domain/payment/PaymentRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/payment/PaymentJpaRepository.java @@ -3,7 +3,7 @@ import java.util.List; import org.springframework.data.jpa.repository.JpaRepository; -public interface PaymentRepository extends JpaRepository { +public interface PaymentJpaRepository extends JpaRepository { // TODO:인덱스 처리 필요(풀스캔 위험) boolean existsByOrderId(String orderId); @@ -11,4 +11,6 @@ public interface PaymentRepository extends JpaRepository List findByOrderId(String orderId); PaymentJpaEntity findByExamApplicationId(Long examApplicationId); + + List findByExamApplicationIdIn(List examApplicationIds); } diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentAmountCalculator.java b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentAmountCalculator.java new file mode 100644 index 00000000..a0351866 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentAmountCalculator.java @@ -0,0 +1,30 @@ +package life.mosu.mosuserver.domain.payment.service; + +import java.util.List; +import life.mosu.mosuserver.domain.discount.DiscountPolicy; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; +import org.springframework.stereotype.Component; + +@Component +public class PaymentAmountCalculator { + + private final static int LUNCH_AMOUNT = 9_000; + + public int calculateTotal(List applications) { + return calculateLunchAmount(applications) + + DiscountPolicy.calculate(DiscountPolicy.FIXED_QUANTITY, applications.size()); + } + + public int calculateLunchAmount(List applications) { + return (int) applications.stream().filter(ExamApplicationJpaEntity::getIsLunchChecked) + .count() + * LUNCH_AMOUNT; + } + + public void verifyAmount(int applicationCount, int requestedAmount) { + int expected = DiscountPolicy.calculate(DiscountPolicy.FIXED_QUANTITY, applicationCount); + if (requestedAmount != expected) { + throw new RuntimeException("결제 금액이 올바르지 않습니다."); + } + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentMapper.java b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentMapper.java new file mode 100644 index 00000000..4e097966 --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentMapper.java @@ -0,0 +1,15 @@ +package life.mosu.mosuserver.domain.payment.service; + +import java.util.List; +import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; +import life.mosu.mosuserver.infra.payment.dto.ConfirmTossPaymentResponse; +import org.springframework.stereotype.Component; + +@Component +public class PaymentMapper { + + public List toEntities(List examApplicationIds, + ConfirmTossPaymentResponse response) { + return examApplicationIds.stream().map(response::toEntity).toList(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentOrderIdGenerator.java b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentOrderIdGenerator.java new file mode 100644 index 00000000..80dd0dcd --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/payment/service/PaymentOrderIdGenerator.java @@ -0,0 +1,14 @@ +package life.mosu.mosuserver.domain.payment.service; + +import java.util.UUID; +import life.mosu.mosuserver.global.NumberGenerator; +import org.springframework.stereotype.Component; + +@Component +public class PaymentOrderIdGenerator implements NumberGenerator { + + @Override + public String generate() { + return UUID.randomUUID().toString(); + } +} diff --git a/src/main/java/life/mosu/mosuserver/infra/payment/dto/ConfirmTossPaymentResponse.java b/src/main/java/life/mosu/mosuserver/infra/payment/dto/ConfirmTossPaymentResponse.java index 93883486..97fdde2e 100644 --- a/src/main/java/life/mosu/mosuserver/infra/payment/dto/ConfirmTossPaymentResponse.java +++ b/src/main/java/life/mosu/mosuserver/infra/payment/dto/ConfirmTossPaymentResponse.java @@ -27,9 +27,9 @@ public class ConfirmTossPaymentResponse { private Integer taxFreeAmount; private String method; - public PaymentJpaEntity toEntity(Long applicationId) { + public PaymentJpaEntity toEntity(Long examApplicationId) { return PaymentJpaEntity.of( - applicationId, + examApplicationId, paymentKey, orderId, toPaymentStatus(), diff --git a/src/main/java/life/mosu/mosuserver/presentation/payment/PaymentWidgetController.java b/src/main/java/life/mosu/mosuserver/presentation/payment/PaymentWidgetController.java index 5fb50b48..4bfb20cc 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/payment/PaymentWidgetController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/payment/PaymentWidgetController.java @@ -48,7 +48,7 @@ public ApiResponseWrapper cancel( @PathVariable String paymentId, @RequestBody CancelPaymentRequest request ) { - paymentService.cancel(paymentId, request); +// paymentService.cancel(paymentId, request); return ApiResponseWrapper.success(HttpStatus.OK, "결제 취소 성공"); } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/payment/dto/PaymentRequest.java b/src/main/java/life/mosu/mosuserver/presentation/payment/dto/PaymentRequest.java index 08c744f5..e9ef0d68 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/payment/dto/PaymentRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/payment/dto/PaymentRequest.java @@ -1,11 +1,10 @@ package life.mosu.mosuserver.presentation.payment.dto; import jakarta.validation.constraints.NotNull; -import java.util.List; import life.mosu.mosuserver.infra.payment.dto.TossPaymentPayload; public record PaymentRequest( - @NotNull List applicationSchoolIds, + @NotNull Long applicationId, @NotNull String paymentKey, @NotNull String orderId, @NotNull Integer amount @@ -14,8 +13,4 @@ public record PaymentRequest( public TossPaymentPayload toPayload() { return new TossPaymentPayload(paymentKey, orderId, amount); } - - public int applicantSize() { - return applicationSchoolIds.size(); - } } \ No newline at end of file From 907b71d66828186c6ac9f5ef94265e23abbfc84a Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:06:19 +0900 Subject: [PATCH 21/28] feat: add NumberGenerator interface for generating strings --- .../java/life/mosu/mosuserver/global/NumberGenerator.java | 6 ++++++ 1 file changed, 6 insertions(+) create mode 100644 src/main/java/life/mosu/mosuserver/global/NumberGenerator.java diff --git a/src/main/java/life/mosu/mosuserver/global/NumberGenerator.java b/src/main/java/life/mosu/mosuserver/global/NumberGenerator.java new file mode 100644 index 00000000..6043c8ad --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/global/NumberGenerator.java @@ -0,0 +1,6 @@ +package life.mosu.mosuserver.global; + +public interface NumberGenerator { + + String generate(); +} From 823d2960d6895743f3b732fda49d95435f454808 Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:06:37 +0900 Subject: [PATCH 22/28] feat: implement FixedQuantityDiscountCalculator and add getAppliedDiscountAmount method refactor: recreate ExamNumberGenerationService to implement NumberGenerator interface --- .../FixedQuantityDiscountCalculator.java | 15 +++++++++++++ .../ExamNumberGenerationService.java | 20 ----------------- .../service/ExamNumberGenerationService.java | 22 +++++++++++++++++++ 3 files changed, 37 insertions(+), 20 deletions(-) delete mode 100644 src/main/java/life/mosu/mosuserver/domain/examapplication/ExamNumberGenerationService.java create mode 100644 src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java diff --git a/src/main/java/life/mosu/mosuserver/domain/discount/FixedQuantityDiscountCalculator.java b/src/main/java/life/mosu/mosuserver/domain/discount/FixedQuantityDiscountCalculator.java index fb920157..0c797acd 100644 --- a/src/main/java/life/mosu/mosuserver/domain/discount/FixedQuantityDiscountCalculator.java +++ b/src/main/java/life/mosu/mosuserver/domain/discount/FixedQuantityDiscountCalculator.java @@ -1,7 +1,9 @@ package life.mosu.mosuserver.domain.discount; import java.util.Map; +import org.springframework.stereotype.Component; +@Component public class FixedQuantityDiscountCalculator implements DiscountCalculator { // 회차별 고정 할인 가격 (총 결제 금액 기준) @@ -18,4 +20,17 @@ public int calculateDiscount(int quantity) { } return FIXED_TOTAL_PRICE.get(quantity); } + + public int getAppliedDiscountAmount(int totalAmount) { + return FIXED_TOTAL_PRICE.entrySet() + .stream() + .filter(entry -> entry.getValue() == totalAmount) + .findFirst() + .map(entry -> { + int quantity = entry.getKey(); + int originalTotal = quantity * 49_000; + return originalTotal - totalAmount; + }) + .orElseThrow(() -> new IllegalArgumentException("해당 결제 금액에 대한 할인 정책이 없습니다.")); + } } diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamNumberGenerationService.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamNumberGenerationService.java deleted file mode 100644 index cf19bc18..00000000 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamNumberGenerationService.java +++ /dev/null @@ -1,20 +0,0 @@ -package life.mosu.mosuserver.domain.examapplication; - -import java.util.List; -import java.util.UUID; -import org.springframework.stereotype.Component; - -@Component -public class ExamNumberGenerationService { - - public void grantTo(List examApplicationEntities) { - examApplicationEntities.forEach(examApplicationEntity -> { - String examNumber = generateExamNumber(); - examApplicationEntity.grantExamNumber(examNumber); - }); - } - - private String generateExamNumber() { - return UUID.randomUUID().toString(); - } -} diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java new file mode 100644 index 00000000..59a1f37f --- /dev/null +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/service/ExamNumberGenerationService.java @@ -0,0 +1,22 @@ +package life.mosu.mosuserver.domain.examapplication.service; + +import java.util.List; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; +import life.mosu.mosuserver.global.NumberGenerator; +import org.springframework.stereotype.Component; + +@Component +public class ExamNumberGenerationService implements NumberGenerator { + + public void grantTo(List examApplicationEntities) { + examApplicationEntities.forEach(examApplicationEntity -> { + String examNumber = generate(); + examApplicationEntity.grantExamNumber(examNumber); + }); + } + + @Override + public String generate() { + return ""; + } +} From b301068cf69e4a7486cca703e39d997b564cfc8a Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:07:02 +0900 Subject: [PATCH 23/28] feat: add error codes for exam application and payment processing --- .../life/mosu/mosuserver/global/exception/ErrorCode.java | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) 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 cf93f7e1..b8896a0d 100644 --- a/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java +++ b/src/main/java/life/mosu/mosuserver/global/exception/ErrorCode.java @@ -35,6 +35,8 @@ public enum ErrorCode { WRONG_LUNCH_TYPE(HttpStatus.BAD_REQUEST, "잘못된 도시락명 입니다."), WRONG_AREA_TYPE(HttpStatus.BAD_REQUEST, "잘못된 지역명 입니다."), + // 수험표 관련 에러 + // 신청 학교 관련 에러 EXAM_APPLICATION_NOT_FOUND(HttpStatus.NOT_FOUND, "신청한 학교 정보를 찾을 수 없습니다."), APPLICATION_SCHOOL_LIST_NOT_FOUND(HttpStatus.NOT_FOUND, "신청한 학교 목록을 찾을 수 없습니다."), @@ -101,9 +103,11 @@ public enum ErrorCode { //lunch 관련 LUNCH_NOT_FOUND(HttpStatus.NOT_FOUND, "점심 정보를 찾을 수 없습니다."), LUNCH_PRICE_UPDATE_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "가격 수정에 실패하였습니다."), - LUNCH_SELECTION_INVALID(HttpStatus.BAD_REQUEST, "점심이 등록되지 않은 시험에 점심 신청을 할 수 없습니다."); - + LUNCH_SELECTION_INVALID(HttpStatus.BAD_REQUEST, "점심이 등록되지 않은 시험에 점심 신청을 할 수 없습니다."), + //payment 관련 + PAYMENT_ALREADY_EXISTS(HttpStatus.BAD_REQUEST, "이미 존재하는 결제 건 입니다."), + PAYMENT_FAILED(HttpStatus.CONFLICT, "결제에 실패하였습니다."); private final HttpStatus status; private final String message; From 42c98e29235da24eb165b2d6e06584759be8f8ee Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:07:14 +0900 Subject: [PATCH 24/28] fix: correct key format for S3 pre-signed URL expiration in application.yml --- src/main/resources/application.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 840f38c9..7fc30058 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -62,7 +62,7 @@ aws: region: ${AWS_REGION} access-key: ${AWS_ACCESS_KEY} secret-key: ${AWS_SECRET_KEY} - presigned-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} + pre-signed-url-expiration-minutes: ${S3_PRESIGNED_URL_EXPIRATION_MINUTES} logging: file: From 34f5872592adfbfcc3097fe2b32201fa07bca4c7 Mon Sep 17 00:00:00 2001 From: polyglot-k Date: Wed, 23 Jul 2025 06:08:10 +0900 Subject: [PATCH 25/28] test: add unit test for getAppliedDiscountAmount method in FixedQuantityDiscountCalculator --- .../discount/FixedQuantityDiscountCalculatorTest.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/test/java/life/mosu/mosuserver/discount/FixedQuantityDiscountCalculatorTest.java b/src/test/java/life/mosu/mosuserver/discount/FixedQuantityDiscountCalculatorTest.java index b448b94f..c1e8aaf5 100644 --- a/src/test/java/life/mosu/mosuserver/discount/FixedQuantityDiscountCalculatorTest.java +++ b/src/test/java/life/mosu/mosuserver/discount/FixedQuantityDiscountCalculatorTest.java @@ -16,6 +16,11 @@ void setUp() { calculator = new FixedQuantityDiscountCalculator(); } + @Test + void 할인률_역산() { + assertEquals(18_000, calculator.getAppliedDiscountAmount(129_000)); + } + @Test void 지원되는_회차에_대한_총_결제금액_계산() { assertEquals(49_000, calculator.calculateDiscount(1)); From edb22765c401ffb5f78bdc10d265e03893909177 Mon Sep 17 00:00:00 2001 From: chominju02 Date: Wed, 23 Jul 2025 06:12:26 +0900 Subject: [PATCH 26/28] =?UTF-8?q?chore:=20application=20=EB=B6=88=ED=95=84?= =?UTF-8?q?=EC=9A=94=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=A0=9C=EA=B1=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/life/mosu/mosuserver/MosuServerApplication.java | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/src/main/java/life/mosu/mosuserver/MosuServerApplication.java b/src/main/java/life/mosu/mosuserver/MosuServerApplication.java index 175ca265..d2ee6e2c 100644 --- a/src/main/java/life/mosu/mosuserver/MosuServerApplication.java +++ b/src/main/java/life/mosu/mosuserver/MosuServerApplication.java @@ -6,14 +6,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@RequiredArgsConstructor -public class MosuServerApplication implements CommandLineRunner { +public class MosuServerApplication { public static void main(String[] args) { SpringApplication.run(MosuServerApplication.class, args); } - - @Override - public void run(String... args) throws Exception { - } } From 925d36f4e82ab06a0d049b7b9cfe76c99c298e89 Mon Sep 17 00:00:00 2001 From: chominju02 Date: Wed, 23 Jul 2025 07:37:15 +0900 Subject: [PATCH 27/28] chore : resolve the conflict --- .../mosuserver/MosuServerApplication.java | 7 +- .../application/admin/AdminService.java | 110 +-- .../ExamApplicationService.java | 3 +- .../admin/ApplicationQueryRepositoryImpl.java | 508 +++++++------ .../admin/RefundQueryRepositoryImpl.java | 166 ++-- .../ExamApplicationJpaEntity.java | 2 +- .../domain/lunch/LunchJpaRepository.java | 0 .../domain/refund/RefundJpaEntity.java | 3 +- .../global/config/SecurityConfig.java | 3 +- .../global/config/WebMvcConfig.java | 8 +- .../initializer/DatabaseInitializer.java | 719 +++++++++--------- .../infra/respository/BulkRepository.java | 91 --- .../presentation/admin/AdminController.java | 39 +- .../admin/AdminControllerDocs.java | 21 +- .../admin/dto/ApplicationExcelDto.java | 95 +-- .../admin/dto/ApplicationFilter.java | 4 +- .../admin/dto/ApplicationListResponse.java | 8 +- .../admin/dto/ExamTicketResponse.java | 7 +- .../admin/dto/SchoolLunchResponse.java | 4 + .../presentation/admin/dto/StudentFilter.java | 4 +- .../dto/ExamApplicationRequest.java | 5 + .../presentation/school/dto/AreaDetail.java | 0 .../presentation/school/dto/AreaMeta.java | 0 .../school/dto/AvailableSchoolResponse.java | 0 .../presentation/school/dto/LunchInfo.java | 0 25 files changed, 820 insertions(+), 987 deletions(-) create mode 100644 src/main/java/life/mosu/mosuserver/domain/lunch/LunchJpaRepository.java delete mode 100644 src/main/java/life/mosu/mosuserver/infra/respository/BulkRepository.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java create mode 100644 src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java diff --git a/src/main/java/life/mosu/mosuserver/MosuServerApplication.java b/src/main/java/life/mosu/mosuserver/MosuServerApplication.java index 175ca265..d2ee6e2c 100644 --- a/src/main/java/life/mosu/mosuserver/MosuServerApplication.java +++ b/src/main/java/life/mosu/mosuserver/MosuServerApplication.java @@ -6,14 +6,9 @@ import org.springframework.boot.autoconfigure.SpringBootApplication; @SpringBootApplication -@RequiredArgsConstructor -public class MosuServerApplication implements CommandLineRunner { +public class MosuServerApplication { public static void main(String[] args) { SpringApplication.run(MosuServerApplication.class, args); } - - @Override - public void run(String... args) throws Exception { - } } diff --git a/src/main/java/life/mosu/mosuserver/application/admin/AdminService.java b/src/main/java/life/mosu/mosuserver/application/admin/AdminService.java index 879625f1..d47dcddb 100644 --- a/src/main/java/life/mosu/mosuserver/application/admin/AdminService.java +++ b/src/main/java/life/mosu/mosuserver/application/admin/AdminService.java @@ -1,55 +1,55 @@ -//package life.mosu.mosuserver.application.admin; -// -//import java.util.List; -//import life.mosu.mosuserver.domain.admin.ApplicationQueryRepositoryImpl; -//import life.mosu.mosuserver.domain.admin.RefundQueryRepositoryImpl; -//import life.mosu.mosuserver.domain.admin.StudentQueryRepositoryImpl; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationExcelDto; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; -//import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; -//import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; -//import life.mosu.mosuserver.presentation.admin.dto.StudentExcelDto; -//import life.mosu.mosuserver.presentation.admin.dto.StudentFilter; -//import life.mosu.mosuserver.presentation.admin.dto.StudentListResponse; -//import lombok.RequiredArgsConstructor; -//import lombok.extern.slf4j.Slf4j; -//import org.springframework.data.domain.Page; -//import org.springframework.data.domain.Pageable; -//import org.springframework.stereotype.Service; -// -//@Slf4j -//@Service -//@RequiredArgsConstructor -//public class AdminService { -// -// private final StudentQueryRepositoryImpl studentQueryRepository; -// private final ApplicationQueryRepositoryImpl applicationQueryRepository; -// private final RefundQueryRepositoryImpl refundQueryRepository; -// -// public Page getStudents(StudentFilter filter, Pageable pageable) { -// return studentQueryRepository.searchAllStudents(filter, pageable); -// } -// -// public List getStudentExcelData() { -// return studentQueryRepository.searchAllStudentsForExcel(); -// } -// -// public List getLunchCounts() { -// return applicationQueryRepository.searchAllSchoolLunches(); -// } -// -// public Page getApplications(ApplicationFilter filter, -// Pageable pageable) { -// return applicationQueryRepository.searchAllApplications(filter, pageable); -// } -// -// public List getApplicationExcelData() { -// return applicationQueryRepository.searchAllApplicationsForExcel(); -// } -// -// public Page getRefunds(Pageable pageable) { -// return refundQueryRepository.searchAllRefunds(pageable); -// } -// -//} +package life.mosu.mosuserver.application.admin; + +import java.util.List; +import life.mosu.mosuserver.domain.admin.ApplicationQueryRepositoryImpl; +import life.mosu.mosuserver.domain.admin.RefundQueryRepositoryImpl; +import life.mosu.mosuserver.domain.admin.StudentQueryRepositoryImpl; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationExcelDto; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; +import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; +import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; +import life.mosu.mosuserver.presentation.admin.dto.StudentExcelDto; +import life.mosu.mosuserver.presentation.admin.dto.StudentFilter; +import life.mosu.mosuserver.presentation.admin.dto.StudentListResponse; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class AdminService { + + private final StudentQueryRepositoryImpl studentQueryRepository; + private final ApplicationQueryRepositoryImpl applicationQueryRepository; + private final RefundQueryRepositoryImpl refundQueryRepository; + + public Page getStudents(StudentFilter filter, Pageable pageable) { + return studentQueryRepository.searchAllStudents(filter, pageable); + } + + public List getStudentExcelData() { + return studentQueryRepository.searchAllStudentsForExcel(); + } + + public List getLunchCounts() { + return applicationQueryRepository.searchAllSchoolLunches(); + } + + public Page getApplications(ApplicationFilter filter, + Pageable pageable) { + return applicationQueryRepository.searchAllApplications(filter, pageable); + } + + public List getApplicationExcelData() { + return applicationQueryRepository.searchAllApplicationsForExcel(); + } + + public Page getRefunds(Pageable pageable) { + return refundQueryRepository.searchAllRefunds(pageable); + } + +} 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 ab1a4421..d6b7a848 100644 --- a/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java +++ b/src/main/java/life/mosu/mosuserver/application/examapplication/ExamApplicationService.java @@ -81,8 +81,9 @@ public ExamTicketResponse getExamTicket(Long examApplicationId) { List examSubjects = examSubjectJpaRepository.findByExamApplicationId( examApplicationId); - List subjects = examSubjects.stream() + List subjects = examSubjects.stream() .map(ExamSubjectJpaEntity::getSubject) + .map(Subject::getSubjectName) .toList(); String examTicketImgUrl = s3Service.getPreSignedUrl(examTicketInfo.s3Key()); diff --git a/src/main/java/life/mosu/mosuserver/domain/admin/ApplicationQueryRepositoryImpl.java b/src/main/java/life/mosu/mosuserver/domain/admin/ApplicationQueryRepositoryImpl.java index c8995a36..8adbf8b5 100644 --- a/src/main/java/life/mosu/mosuserver/domain/admin/ApplicationQueryRepositoryImpl.java +++ b/src/main/java/life/mosu/mosuserver/domain/admin/ApplicationQueryRepositoryImpl.java @@ -1,251 +1,257 @@ -//package life.mosu.mosuserver.domain.admin; -// -//import com.querydsl.core.Tuple; -//import com.querydsl.core.types.Predicate; -//import com.querydsl.core.types.dsl.EnumPath; -//import com.querydsl.core.types.dsl.Expressions; -//import com.querydsl.jpa.impl.JPAQuery; -//import com.querydsl.jpa.impl.JPAQueryFactory; -//import java.time.format.DateTimeFormatter; -//import java.util.HashSet; -//import java.util.List; -//import java.util.Set; -//import java.util.stream.Collectors; -//import life.mosu.mosuserver.domain.application.Lunch; -//import life.mosu.mosuserver.domain.application.QAdmissionTicketImageJpaEntity; -//import life.mosu.mosuserver.domain.application.QApplicationJpaEntity; -//import life.mosu.mosuserver.domain.application.Subject; -//import life.mosu.mosuserver.domain.applicationschool.QApplicationSchoolJpaEntity; -//import life.mosu.mosuserver.domain.payment.QPaymentJpaEntity; -//import life.mosu.mosuserver.domain.profile.QProfileJpaEntity; -//import life.mosu.mosuserver.domain.school.QSchoolJpaEntity; -//import life.mosu.mosuserver.domain.user.QUserJpaEntity; -//import life.mosu.mosuserver.infra.storage.application.S3Service; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationExcelDto; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; -//import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; -//import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; -//import life.mosu.mosuserver.presentation.applicationschool.dto.AdmissionTicketResponse; -//import lombok.RequiredArgsConstructor; -//import org.springframework.data.domain.Page; -//import org.springframework.data.domain.PageImpl; -//import org.springframework.data.domain.Pageable; -//import org.springframework.stereotype.Repository; -// -//@Repository -//@RequiredArgsConstructor -//public class ApplicationQueryRepositoryImpl implements ApplicationQueryRepository { -// -// private static final DateTimeFormatter EXCEL_DT_FORMATTER = -// DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); -// -// private final JPAQueryFactory queryFactory; -// private final S3Service s3Service; -// -// private final QProfileJpaEntity profile = QProfileJpaEntity.profileJpaEntity; -// private final QApplicationJpaEntity application = QApplicationJpaEntity.applicationJpaEntity; -// private final QApplicationSchoolJpaEntity applicationSchool = QApplicationSchoolJpaEntity.applicationSchoolJpaEntity; -// private final QPaymentJpaEntity payment = QPaymentJpaEntity.paymentJpaEntity; -// private final QAdmissionTicketImageJpaEntity admissionTicketImage = QAdmissionTicketImageJpaEntity.admissionTicketImageJpaEntity; -// private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; -// private final QSchoolJpaEntity school = QSchoolJpaEntity.schoolJpaEntity; -// -// @Override -// public Page searchAllApplications(ApplicationFilter filter, -// Pageable pageable) { -// -// JPAQuery query = baseQuery() -// .where( -// buildNameCondition(filter.name()), -// buildPhoneCondition(filter.phone()) -// ) -// .offset(pageable.getOffset()) -// .limit(pageable.getPageSize()); -// -// List content = query.fetch().stream() -// .map(tuple -> { -// Long appSchoolId = tuple.get(applicationSchool.id); -// Set subjects = appSchoolId != null -// ? findSubjectsByApplicationSchoolId(appSchoolId) -// : new HashSet<>(); -// return mapToResponse(tuple, subjects); -// }) -// .toList(); -// -// return new PageImpl<>(content, pageable, content.size()); -// } -// -// @Override -// public List searchAllApplicationsForExcel() { -// JPAQuery query = baseQuery(); -// return query.fetch().stream() -// .map(tuple -> { -// Long appSchoolId = tuple.get(applicationSchool.id); -// Set subjects = appSchoolId != null -// ? findSubjectsByApplicationSchoolId(appSchoolId) -// : new HashSet<>(); -// return mapToExcel(tuple, subjects); -// }) -// .toList(); -// } -// -// @Override -// public List searchAllSchoolLunches() { -// return queryFactory -// .select( -// school.schoolName, -// applicationSchool.lunch.count() -// ) -// .from(applicationSchool) -// .rightJoin(school).on(applicationSchool.schoolId.eq(school.id)) -// .where(applicationSchool.lunch.ne(Lunch.NONE)) -// .groupBy(school.id, school.schoolName) -// .fetch() -// .stream() -// .map(t -> new SchoolLunchResponse( -// t.get(school.schoolName), -// t.get(applicationSchool.lunch.count()) -// )) -// .toList(); -// } -// -// -// private JPAQuery baseQuery() { -// return queryFactory -// .select( -// applicationSchool.id, -// payment.paymentKey, -// applicationSchool.examinationNumber, -// profile.userName, -// profile.gender, -// profile.birth, -// profile.phoneNumber, -// application.guardianPhoneNumber, -// profile.education, -// profile.schoolInfo.schoolName, -// profile.grade, -// applicationSchool.lunch, -// applicationSchool.schoolName, -// applicationSchool.examDate, -// admissionTicketImage.s3Key, -// admissionTicketImage.fileName, -// payment.paymentStatus, -// payment.paymentMethod, -// application.createdAt -// ) -// .from(applicationSchool) -// .leftJoin(application).on(applicationSchool.applicationId.eq(application.id)) -// .leftJoin(payment).on(payment.applicationSchoolId.eq(applicationSchool.id)) -// .leftJoin(user).on(application.userId.eq(user.id)) -// .leftJoin(profile).on(profile.userId.eq(user.id)) -// .leftJoin(admissionTicketImage) -// .on(admissionTicketImage.applicationId.eq(application.id)); -// } -// -// private Predicate buildNameCondition(String name) { -// return (name == null || name.isBlank()) -// ? null -// : profile.userName.contains(name); -// } -// -// private Predicate buildPhoneCondition(String phone) { -// return (phone == null || phone.isBlank()) -// ? null -// : profile.phoneNumber.contains(phone); -// } -// -// private Set findSubjectsByApplicationSchoolId(Long applicationSchoolId) { -// EnumPath subject = Expressions.enumPath(Subject.class, "subject"); -// return new HashSet<>( -// queryFactory -// .select(subject) -// .from(applicationSchool) -// .join(applicationSchool.subjects, subject) -// .where(applicationSchool.id.eq(applicationSchoolId)) -// .fetch() -// ); -// } -// -// private ApplicationListResponse mapToResponse(Tuple tuple, Set subjects) { -// Set subjectNames = subjects.stream() -// .map(Subject::getSubjectName) -// .collect(Collectors.toSet()); -// -// String lunchName = tuple.get(applicationSchool.lunch).getLunchName(); -// -// String s3Key = tuple.get(admissionTicketImage.s3Key); -// String url = getAdmissionTicketImageUrl(s3Key); -// -// AdmissionTicketResponse admissionTicket = AdmissionTicketResponse.of( -// url, -// tuple.get(profile.userName), -// tuple.get(profile.birth), -// tuple.get(applicationSchool.examinationNumber), -// subjectNames, -// tuple.get(applicationSchool.schoolName) -// ); -// -// return new ApplicationListResponse( -// tuple.get(payment.paymentKey), -// tuple.get(applicationSchool.examinationNumber), -// tuple.get(profile.userName), -// tuple.get(profile.gender).getGenderName(), -// tuple.get(profile.birth), -// tuple.get(profile.phoneNumber), -// tuple.get(application.guardianPhoneNumber), -// tuple.get(profile.education).getEducationName(), -// tuple.get(profile.schoolInfo.schoolName), -// tuple.get(profile.grade).getGradeName(), -// lunchName, -// subjectNames, -// tuple.get(applicationSchool.schoolName), -// tuple.get(applicationSchool.examDate), -// tuple.get(admissionTicketImage.fileName), -// tuple.get(payment.paymentStatus), -// tuple.get(payment.paymentMethod), -// tuple.get(application.createdAt), -// admissionTicket -// ); -// } -// -// private ApplicationExcelDto mapToExcel(Tuple tuple, Set subjects) { -// Set subjectNames = subjects.stream() -// .map(Subject::getSubjectName) -// .collect(Collectors.toSet()); -// -// String lunchName = tuple.get(applicationSchool.lunch).getLunchName(); -// String genderName = tuple.get(profile.gender).getGenderName(); -// String gradeName = tuple.get(profile.grade).getGradeName(); -// String educationName = tuple.get(profile.education).getEducationName(); -// String appliedAt = tuple.get(application.createdAt) -// .format(EXCEL_DT_FORMATTER); -// -// return new ApplicationExcelDto( -// tuple.get(payment.paymentKey), -// tuple.get(applicationSchool.examinationNumber), -// tuple.get(profile.userName), -// genderName, -// tuple.get(profile.birth), -// tuple.get(profile.phoneNumber), -// tuple.get(application.guardianPhoneNumber), -// educationName, -// tuple.get(profile.schoolInfo.schoolName), -// gradeName, -// lunchName, -// subjectNames, -// tuple.get(applicationSchool.schoolName), -// tuple.get(applicationSchool.examDate), -// tuple.get(admissionTicketImage.fileName), -// tuple.get(payment.paymentStatus), -// tuple.get(payment.paymentMethod), -// appliedAt -// ); -// } -// -// private String getAdmissionTicketImageUrl(String s3Key) { -// if (s3Key == null || s3Key.isBlank()) { -// return null; -// } -// return s3Service.getPreSignedUrl(s3Key); -// } -//} \ No newline at end of file +package life.mosu.mosuserver.domain.admin; + +import com.querydsl.core.Tuple; +import com.querydsl.core.types.Predicate; +import com.querydsl.core.types.dsl.EnumPath; +import com.querydsl.core.types.dsl.Expressions; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.time.format.DateTimeFormatter; +import java.util.HashSet; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; +import life.mosu.mosuserver.domain.application.QApplicationJpaEntity; +import life.mosu.mosuserver.domain.application.QExamTicketImageJpaEntity; +import life.mosu.mosuserver.domain.application.Subject; +import life.mosu.mosuserver.domain.exam.QExamJpaEntity; +import life.mosu.mosuserver.domain.examapplication.QExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.QExamSubjectJpaEntity; +import life.mosu.mosuserver.domain.payment.PaymentStatus; +import life.mosu.mosuserver.domain.payment.QPaymentJpaEntity; +import life.mosu.mosuserver.domain.profile.QProfileJpaEntity; +import life.mosu.mosuserver.domain.user.QUserJpaEntity; +import life.mosu.mosuserver.infra.storage.application.S3Service; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationExcelDto; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; +import life.mosu.mosuserver.presentation.admin.dto.ExamTicketResponse; +import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class ApplicationQueryRepositoryImpl implements ApplicationQueryRepository { + + private static final DateTimeFormatter EXCEL_DT_FORMATTER = + DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + + private final JPAQueryFactory queryFactory; + private final S3Service s3Service; + + private final QProfileJpaEntity profile = QProfileJpaEntity.profileJpaEntity; + private final QApplicationJpaEntity application = QApplicationJpaEntity.applicationJpaEntity; + private final QPaymentJpaEntity payment = QPaymentJpaEntity.paymentJpaEntity; + private final QUserJpaEntity user = QUserJpaEntity.userJpaEntity; + private final QExamJpaEntity exam = QExamJpaEntity.examJpaEntity; + private final QExamApplicationJpaEntity examApplication = QExamApplicationJpaEntity.examApplicationJpaEntity; + private final QExamTicketImageJpaEntity examTicketImage = QExamTicketImageJpaEntity.examTicketImageJpaEntity; + private final QExamSubjectJpaEntity examSubject = QExamSubjectJpaEntity.examSubjectJpaEntity; + + @Override + public Page searchAllApplications(ApplicationFilter filter, + Pageable pageable) { + + JPAQuery query = baseQuery() + .where( + buildNameCondition(filter.name()), + buildPhoneCondition(filter.phone()) + ) + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()); + + List content = query.fetch().stream() + .map(tuple -> { + Long appSchoolId = tuple.get(examApplication.id); + Set subjects = appSchoolId != null + ? findSubjectsByExamApplicationId(appSchoolId) + : new HashSet<>(); + return mapToResponse(tuple, subjects); + }) + .toList(); + + return new PageImpl<>(content, pageable, content.size()); + } + + @Override + public List searchAllApplicationsForExcel() { + JPAQuery query = baseQuery(); + return query.fetch().stream() + .map(tuple -> { + Long appSchoolId = tuple.get(examApplication.id); + Set subjects = appSchoolId != null + ? findSubjectsByExamApplicationId(appSchoolId) + : new HashSet<>(); + return mapToExcel(tuple, subjects); + }) + .toList(); + } + + @Override + public List searchAllSchoolLunches() { + return queryFactory + .select( + exam.schoolName, + examApplication.id.count() + ) + .from(examApplication) + .rightJoin(exam).on(examApplication.examId.eq(exam.id)) + .rightJoin(application).on(application.id.eq(examApplication.applicationId)) + .rightJoin(payment).on(examApplication.id.eq(payment.examApplicationId)) + .where( + examApplication.isLunchChecked.isTrue(), + payment.paymentStatus.eq(PaymentStatus.DONE) + ) + .groupBy(exam.schoolName) + .fetch() + .stream() + .map(t -> new SchoolLunchResponse( + t.get(exam.schoolName), + t.get(examApplication.id.count()) + )) + .toList(); + } + + private JPAQuery baseQuery() { + return queryFactory + .select( + examApplication.id, + payment.paymentKey, + examApplication.examNumber, + profile.userName, + profile.gender, + profile.birth, + profile.phoneNumber, + application.parentPhoneNumber, + profile.education, + profile.schoolInfo.schoolName, + profile.grade, + exam.schoolName, + exam.examDate, + examApplication.isLunchChecked, + examTicketImage.s3Key, + examTicketImage.fileName, + payment.paymentStatus, + payment.paymentMethod, + application.createdAt + ) + .from(examApplication) + .leftJoin(exam).on(examApplication.examId.eq(exam.id)) + .leftJoin(application).on(examApplication.applicationId.eq(application.id)) + .leftJoin(payment).on(payment.examApplicationId.eq(examApplication.id)) + .leftJoin(user).on(application.userId.eq(user.id)) + .leftJoin(profile).on(profile.userId.eq(user.id)) + .leftJoin(examTicketImage) + .on(examTicketImage.applicationId.eq(application.id)); + } + + private Predicate buildNameCondition(String name) { + return (name == null || name.isBlank()) + ? null + : profile.userName.contains(name); + } + + private Predicate buildPhoneCondition(String phone) { + return (phone == null || phone.isBlank()) + ? null + : profile.phoneNumber.contains(phone); + } + + private Set findSubjectsByExamApplicationId(Long examApplicationId) { + EnumPath subject = Expressions.enumPath(Subject.class, "subject"); + return new HashSet<>( + queryFactory + .select( + examSubject.subject + ) + .from(examSubject) + .where(examSubject.examApplicationId.eq(examApplicationId)) + .fetch() + ); + } + + private ApplicationListResponse mapToResponse(Tuple tuple, Set subjects) { + List subjectNames = subjects.stream() + .map(Subject::getSubjectName) + .toList(); + + String s3Key = tuple.get(examTicketImage.s3Key); + String url = getAdmissionTicketImageUrl(s3Key); + + ExamTicketResponse examTicketResponse = ExamTicketResponse.of( + url, + tuple.get(profile.userName), + tuple.get(profile.birth), + tuple.get(examApplication.examNumber), + subjectNames, + tuple.get(exam.schoolName) + ); + + return new ApplicationListResponse( + tuple.get(payment.paymentKey), + tuple.get(examApplication.examNumber), + tuple.get(profile.userName), + tuple.get(profile.gender).getGenderName(), + tuple.get(profile.birth), + tuple.get(profile.phoneNumber), + tuple.get(application.parentPhoneNumber), + tuple.get(profile.education).getEducationName(), + tuple.get(profile.schoolInfo.schoolName), + tuple.get(profile.grade).getGradeName(), + tuple.get(examApplication.isLunchChecked), + subjectNames, + tuple.get(exam.schoolName), + tuple.get(exam.examDate), + tuple.get(examTicketImage.fileName), + tuple.get(payment.paymentStatus), + tuple.get(payment.paymentMethod), + tuple.get(application.createdAt), + examTicketResponse + ); + } + + private ApplicationExcelDto mapToExcel(Tuple tuple, Set subjects) { + Set subjectNames = subjects.stream() + .map(Subject::getSubjectName) + .collect(Collectors.toSet()); + + String lunchName = tuple.get(exam.lunchName); + String genderName = tuple.get(profile.gender).getGenderName(); + String gradeName = tuple.get(profile.grade).getGradeName(); + String educationName = tuple.get(profile.education).getEducationName(); + String appliedAt = tuple.get(application.createdAt) + .format(EXCEL_DT_FORMATTER); + + return new ApplicationExcelDto( + tuple.get(payment.paymentKey), + tuple.get(examApplication.examNumber), + tuple.get(profile.userName), + genderName, + tuple.get(profile.birth), + tuple.get(profile.phoneNumber), + tuple.get(application.parentPhoneNumber), + educationName, + tuple.get(profile.schoolInfo.schoolName), + gradeName, + lunchName, + subjectNames, + tuple.get(exam.schoolName), + tuple.get(exam.examDate), + tuple.get(examTicketImage.fileName), + tuple.get(payment.paymentStatus), + tuple.get(payment.paymentMethod), + appliedAt + ); + } + + private String getAdmissionTicketImageUrl(String s3Key) { + if (s3Key == null || s3Key.isBlank()) { + return null; + } + return s3Service.getPreSignedUrl(s3Key); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/admin/RefundQueryRepositoryImpl.java b/src/main/java/life/mosu/mosuserver/domain/admin/RefundQueryRepositoryImpl.java index 540977b2..27451cc6 100644 --- a/src/main/java/life/mosu/mosuserver/domain/admin/RefundQueryRepositoryImpl.java +++ b/src/main/java/life/mosu/mosuserver/domain/admin/RefundQueryRepositoryImpl.java @@ -1,84 +1,82 @@ -//package life.mosu.mosuserver.domain.admin; -// -//import static life.mosu.mosuserver.domain.base.BaseTimeEntity.formatDate; -// -//import com.querydsl.core.Tuple; -//import com.querydsl.jpa.impl.JPAQuery; -//import com.querydsl.jpa.impl.JPAQueryFactory; -//import java.util.List; -//import life.mosu.mosuserver.domain.application.QApplicationJpaEntity; -//import life.mosu.mosuserver.domain.applicationschoolX.QApplicationSchoolJpaEntity; -//import life.mosu.mosuserver.domain.payment.PaymentMethod; -//import life.mosu.mosuserver.domain.payment.QPaymentJpaEntity; -//import life.mosu.mosuserver.domain.profile.QProfileJpaEntity; -//import life.mosu.mosuserver.domain.refund.QRefundJpaEntity; -//import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; -//import lombok.RequiredArgsConstructor; -//import org.springframework.data.domain.Page; -//import org.springframework.data.domain.PageImpl; -//import org.springframework.data.domain.Pageable; -//import org.springframework.stereotype.Repository; -// -//@Repository -//@RequiredArgsConstructor -//public class RefundQueryRepositoryImpl implements RefundQueryRepository { -// -// private final JPAQueryFactory queryFactory; -// -// QRefundJpaEntity refund = QRefundJpaEntity.refundJpaEntity; -// QApplicationSchoolJpaEntity appSchool = QApplicationSchoolJpaEntity.applicationSchoolJpaEntity; -// QApplicationJpaEntity application = QApplicationJpaEntity.applicationJpaEntity; -// QProfileJpaEntity profile = QProfileJpaEntity.profileJpaEntity; -// QPaymentJpaEntity payment = QPaymentJpaEntity.paymentJpaEntity; -// -// @Override -// public Page searchAllRefunds(Pageable pageable) { -// long total = baseQuery().fetch().size(); -// -// List content = baseQuery() -// .offset(pageable.getOffset()) -// .limit(pageable.getPageSize()) -// .fetch() -// .stream() -// .map(this::mapToResponse) -// .toList(); -// -// return new PageImpl<>(content, pageable, total); -// -// } -// -// private JPAQuery baseQuery() { -// return queryFactory -// .select( -// refund.id, -// appSchool.examinationNumber, -// profile.userName, -// profile.phoneNumber, -// refund.createdAt, -// refund.agreedAt, -// payment.paymentMethod, -// refund.reason -// ) -// .from(refund) -// .leftJoin(appSchool).on(refund.applicationSchoolId.eq(appSchool.id)) -// .leftJoin(application).on(appSchool.applicationId.eq(application.id)) -// .leftJoin(profile).on(profile.userId.eq(application.userId)) -// .leftJoin(payment).on(payment.applicationSchoolId.eq(appSchool.id)); -// } -// -// private RefundListResponse mapToResponse(Tuple tuple) { -// PaymentMethod paymentMethod = tuple.get(payment.paymentMethod); -// return new RefundListResponse( -// tuple.get(refund.id), -// tuple.get(appSchool.examinationNumber), -// tuple.get(profile.userName), -// tuple.get(profile.phoneNumber), -// formatDate(tuple.get(refund.createdAt)), -// formatDate(tuple.get(refund.agreedAt)), -// paymentMethod != null ? paymentMethod.getName() : "N/A", -// tuple.get(refund.reason) -// ); -// } -// -// -//} \ No newline at end of file +package life.mosu.mosuserver.domain.admin; + +import static life.mosu.mosuserver.domain.base.BaseTimeEntity.formatDate; + +import com.querydsl.core.Tuple; +import com.querydsl.jpa.impl.JPAQuery; +import com.querydsl.jpa.impl.JPAQueryFactory; +import java.util.List; +import life.mosu.mosuserver.domain.application.QApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.QExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.payment.PaymentMethod; +import life.mosu.mosuserver.domain.payment.QPaymentJpaEntity; +import life.mosu.mosuserver.domain.profile.QProfileJpaEntity; +import life.mosu.mosuserver.domain.refund.QRefundJpaEntity; +import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; +import lombok.RequiredArgsConstructor; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageImpl; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Repository; + +@Repository +@RequiredArgsConstructor +public class RefundQueryRepositoryImpl implements RefundQueryRepository { + + private final JPAQueryFactory queryFactory; + + private final QRefundJpaEntity refund = QRefundJpaEntity.refundJpaEntity; + private final QExamApplicationJpaEntity examApplication = QExamApplicationJpaEntity.examApplicationJpaEntity; + private final QApplicationJpaEntity application = QApplicationJpaEntity.applicationJpaEntity; + private final QProfileJpaEntity profile = QProfileJpaEntity.profileJpaEntity; + private final QPaymentJpaEntity payment = QPaymentJpaEntity.paymentJpaEntity; + + @Override + public Page searchAllRefunds(Pageable pageable) { + long total = baseQuery().fetch().size(); + + List content = baseQuery() + .offset(pageable.getOffset()) + .limit(pageable.getPageSize()) + .fetch() + .stream() + .map(this::mapToResponse) + .toList(); + + return new PageImpl<>(content, pageable, total); + + } + + private JPAQuery baseQuery() { + return queryFactory + .select( + refund.id, + examApplication.examNumber, + profile.userName, + profile.phoneNumber, + refund.createdAt, + refund.agreedAt, + // payment.paymentMethod, + refund.reason + ) + .from(refund) + .leftJoin(examApplication).on(refund.examApplicationId.eq(examApplication.id)) + .leftJoin(application).on(examApplication.applicationId.eq(application.id)) + .leftJoin(profile).on(profile.userId.eq(application.userId)); + //TODO: payment method join + } + + private RefundListResponse mapToResponse(Tuple tuple) { + PaymentMethod paymentMethod = tuple.get(payment.paymentMethod); + return new RefundListResponse( + tuple.get(refund.id), + tuple.get(examApplication.examNumber), + tuple.get(profile.userName), + tuple.get(profile.phoneNumber), + formatDate(tuple.get(refund.createdAt)), + formatDate(tuple.get(refund.agreedAt)), + paymentMethod != null ? paymentMethod.getName() : "N/A", + tuple.get(refund.reason) + ); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java index f5f820b6..2067cf35 100644 --- a/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/examapplication/ExamApplicationJpaEntity.java @@ -61,4 +61,4 @@ public static ExamApplicationJpaEntity create( public void grantExamNumber(String examNumber) { this.examNumber = examNumber; } -} +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/domain/lunch/LunchJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/lunch/LunchJpaRepository.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/life/mosu/mosuserver/domain/refund/RefundJpaEntity.java b/src/main/java/life/mosu/mosuserver/domain/refund/RefundJpaEntity.java index e2a48538..feeae07e 100644 --- a/src/main/java/life/mosu/mosuserver/domain/refund/RefundJpaEntity.java +++ b/src/main/java/life/mosu/mosuserver/domain/refund/RefundJpaEntity.java @@ -27,7 +27,7 @@ public class RefundJpaEntity extends BaseTimeEntity { @Column(name = "exam_application_id") private Long examApplicationId; - @Column(name = "refund_reason", nullable = false) + @Column(name = "reason", nullable = false) private String reason; @Column(name = "refund_agreed") @@ -48,5 +48,4 @@ public RefundJpaEntity( this.refundAgreed = refundAgreed; this.agreedAt = agreedAt; } - } 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 2d0f37c6..0a662273 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/SecurityConfig.java @@ -39,7 +39,8 @@ public class SecurityConfig { "http://localhost:8080", "https://mosuedu.com", "http://api.mosuedu.com", - "https://api.mosuedu.com" + "https://api.mosuedu.com", + "https://www.mosuedu.com" ); 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 de2ba092..df3da4ce 100644 --- a/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java +++ b/src/main/java/life/mosu/mosuserver/global/config/WebMvcConfig.java @@ -24,8 +24,12 @@ public void addCorsMappings(CorsRegistry registry) { registry.addMapping("/**") .allowedMethods("GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS") .allowedHeaders("*") - .allowedOrigins("http://localhost:3000", "http://localhost:8080", - "http://api.mosuedu.com", "https://api.mosuedu.com") + .allowedOrigins( + "http://localhost:3000", + "http://localhost:8080", + "http://api.mosuedu.com", + "https://api.mosuedu.com", + "https://www.mosuedu.com") .allowCredentials(true) .maxAge(3600); } diff --git a/src/main/java/life/mosu/mosuserver/global/initializer/DatabaseInitializer.java b/src/main/java/life/mosu/mosuserver/global/initializer/DatabaseInitializer.java index a62faec4..b7c1b516 100644 --- a/src/main/java/life/mosu/mosuserver/global/initializer/DatabaseInitializer.java +++ b/src/main/java/life/mosu/mosuserver/global/initializer/DatabaseInitializer.java @@ -1,380 +1,339 @@ -//package life.mosu.mosuserver.global.initializer; -// -//import jakarta.annotation.PostConstruct; -//import java.time.LocalDate; -//import java.time.LocalDateTime; -//import java.util.ArrayList; -//import java.util.Collections; -//import java.util.HashSet; -//import java.util.List; -//import java.util.Random; -//import java.util.Set; -//import java.util.UUID; -//import life.mosu.mosuserver.domain.application.ApplicationJpaEntity; -//import life.mosu.mosuserver.domain.application.ApplicationJpaRepository; -//import life.mosu.mosuserver.domain.application.Lunch; -//import life.mosu.mosuserver.domain.application.Subject; -//import life.mosu.mosuserver.domain.applicationschool.ApplicationSchoolJpaEntity; -//import life.mosu.mosuserver.domain.applicationschool.ApplicationSchoolJpaRepository; -//import life.mosu.mosuserver.domain.event.DurationJpaVO; -//import life.mosu.mosuserver.domain.event.EventJpaEntity; -//import life.mosu.mosuserver.domain.event.EventJpaRepository; -//import life.mosu.mosuserver.domain.inquiry.InquiryJpaEntity; -//import life.mosu.mosuserver.domain.inquiry.InquiryJpaRepository; -//import life.mosu.mosuserver.domain.inquiryAnswer.InquiryAnswerJpaEntity; -//import life.mosu.mosuserver.domain.inquiryAnswer.InquiryAnswerJpaRepository; -//import life.mosu.mosuserver.domain.notice.NoticeJpaEntity; -//import life.mosu.mosuserver.domain.notice.NoticeJpaRepository; -//import life.mosu.mosuserver.domain.payment.PaymentAmountVO; -//import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; -//import life.mosu.mosuserver.domain.payment.PaymentMethod; -//import life.mosu.mosuserver.domain.payment.PaymentRepository; -//import life.mosu.mosuserver.domain.payment.PaymentStatus; -//import life.mosu.mosuserver.domain.profile.Education; -//import life.mosu.mosuserver.domain.profile.Gender; -//import life.mosu.mosuserver.domain.profile.Grade; -//import life.mosu.mosuserver.domain.profile.ProfileJpaEntity; -//import life.mosu.mosuserver.domain.profile.ProfileJpaRepository; -//import life.mosu.mosuserver.domain.profile.SchoolInfoJpaVO; -//import life.mosu.mosuserver.domain.exam.AddressJpaVO; -//import life.mosu.mosuserver.domain.exam.Area; -//import life.mosu.mosuserver.domain.school.LunchMenu; -//import life.mosu.mosuserver.domain.school.SchoolJpaEntity; -//import life.mosu.mosuserver.domain.school.SchoolJpaRepository; -//import life.mosu.mosuserver.domain.user.UserJpaEntity; -//import life.mosu.mosuserver.domain.user.UserJpaRepository; -//import life.mosu.mosuserver.domain.user.UserRole; -//import lombok.RequiredArgsConstructor; -//import lombok.extern.slf4j.Slf4j; -//import org.springframework.security.crypto.password.PasswordEncoder; -//import org.springframework.stereotype.Component; -// -//@Slf4j -//@Component -//@RequiredArgsConstructor -//public class DatabaseInitializer { -// -// private final UserJpaRepository userRepository; -// private final ProfileJpaRepository profileRepository; -// private final SchoolJpaRepository schoolRepository; -// private final ApplicationJpaRepository applicationRepository; -// private final ApplicationSchoolJpaRepository applicationSchoolRepository; -// private final InquiryJpaRepository inquiryJpaRepository; -// private final NoticeJpaRepository noticeJpaRepository; -// private final InquiryAnswerJpaRepository inquiryAnswerJpaRepository; -// private final EventJpaRepository eventRepository; -// private final PaymentRepository paymentRepository; -// private final PasswordEncoder passwordEncoder; -// -// @PostConstruct -// public void init() { -// if (userRepository.count() > 0 || schoolRepository.count() > 0) { -// log.info("이미 더미 데이터가 존재하여 전체 초기화를 건너뜁니다."); -// return; -// } -// -// log.info("전체 더미 데이터 초기화를 시작합니다..."); -// Random random = new Random(); -// -// List createdUsers = initializeUsersAndProfiles(random); -// List createdSchools = initializeSchools(); -// List createdAppSchools = initializeApplications(createdUsers, -// createdSchools, random); -// initializePayments(createdAppSchools); -// initializeBoardItems(createdUsers, random); -// -// log.info("모든 더미 데이터 초기화가 완료되었습니다."); -// } -// -// private List initializeUsersAndProfiles(Random random) { -// List createdUsers = new ArrayList<>(); -// for (int i = 1; i <= 10; i++) { -// UserJpaEntity user = UserJpaEntity.builder() -// .loginId("user" + i) -// .password(passwordEncoder.encode("password" + i + "!")) -// .gender((i % 2 == 0) ? Gender.MALE : Gender.FEMALE) -// .name((i % 2 == 0) ? "김철수" + i : "이영희" + i) -// .birth(LocalDate.of(1990 + (i % 5), (i % 12) + 1, (i % 28) + 1)) -// .agreedToTermsOfService(true) -// .agreedToPrivacyPolicy(true) -// .agreedToMarketing(random.nextBoolean()) -// .userRole((i == 1) ? UserRole.ROLE_ADMIN : UserRole.ROLE_USER) -// .build(); -// userRepository.save(user); -// createdUsers.add(user); -// -// ProfileJpaEntity profile = ProfileJpaEntity.builder() -// .userId(user.getId()) -// .userName(user.getName()) -// .gender(user.getGender()) -// .birth(user.getBirth()) -// .phoneNumber("010-4870-5466") -// .email("user" + i + "@example.com") -// .education(Education.values()[random.nextInt(Education.values().length)]) -// .schoolInfo(new SchoolInfoJpaVO("모수대학교" + (i % 3 + 1), "123-23", "서울시 모수구 모수동")) -// .grade(Grade.values()[random.nextInt(Grade.values().length)]) -// .build(); -// -// profile.registerRecommenderPhoneNumber((i % 3 == 0) ? "010-1234-5678" : null); -// profileRepository.save(profile); -// } -// log.info("User 및 Profile 데이터 {}건 생성 완료.", createdUsers.size()); -// return createdUsers; -// } -// -// private List initializeSchools() { -// List schools = new ArrayList<>(List.of( -// SchoolJpaEntity.builder() -// .schoolName("대치중학교") -// .area(Area.DAECHI) -// .address(new AddressJpaVO("06234", "서울특별시", "강남구 대치동 987")) -// .examDate(LocalDate.of(2025, 10, 19)) -// .capacity(532L) -// .deadlineTime(LocalDateTime.of(2025, 10, 10, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION1, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION2, 13000 -// ) -// ) -// ) -// .build(), -// SchoolJpaEntity.builder() -// .schoolName("목운중학교") -// .area(Area.MOKDONG) -// .address(new AddressJpaVO("07995", "서울특별시", "양천구 목동서로 369")) -// .examDate(LocalDate.of(2025, 10, 26)) -// .capacity(896L) -// .deadlineTime(LocalDateTime.of(2025, 10, 17, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION1, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION2, 13000 -// ) -// ) -// ) -// .build(), -// SchoolJpaEntity.builder() -// .schoolName("신서중학교") -// .area(Area.MOKDONG) -// .address(new AddressJpaVO("08018", "서울특별시", "양천구 신정로 250")) -// .examDate(LocalDate.of(2025, 11, 2)) -// .capacity(896L) -// .deadlineTime(LocalDateTime.of(2025, 10, 24, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION4, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION2, 13000 -// ) -// ) -// ) -// .build(), -// SchoolJpaEntity.builder() -// .schoolName("개원중학교") -// .area(Area.DAECHI) -// .address(new AddressJpaVO("06327", "서울특별시", "강남구 개포로 619")) -// .examDate(LocalDate.of(2025, 10, 26)) -// .capacity(840L) -// .deadlineTime(LocalDateTime.of(2025, 10, 17, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION4, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION2, 13000 -// ) -// ) -// ) -// .build(), -// SchoolJpaEntity.builder() -// .schoolName("문래중학교") -// .area(Area.MOKDONG) -// .address(new AddressJpaVO("07291", "서울특별시", "영등포구 문래로 195")) -// .examDate(LocalDate.of(2025, 10, 19)) -// .capacity(558L) -// .deadlineTime(LocalDateTime.of(2025, 10, 10, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION6, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION2, 13000 -// ) -// ) -// ) -// .build(), -// SchoolJpaEntity.builder() -// .schoolName("온곡중학교") -// .area(Area.NOWON) -// .address(new AddressJpaVO("01673", "서울특별시", "노원구 덕릉로 70길 99")) -// .examDate(LocalDate.of(2025, 10, 19)) -// .capacity(448L) -// .deadlineTime(LocalDateTime.of(2025, 10, 10, 23, 59)) -// .lunchMenu( -// Set.of( -// LunchMenu.of( -// Lunch.OPTION1, 12000 -// ), -// LunchMenu.of( -// Lunch.OPTION3, 13000 -// ) -// ) -// ) -// .build() -// )); -// schoolRepository.saveAll(schools); -// -// log.info("School 데이터 {}건 생성 완료.", schools.size()); -// return schools; -// } -// -// private List initializeApplications(List users, -// List schools, -// Random random) { -// List createdAppSchools = new ArrayList<>(); -// for (int i = 0; i < users.size(); i++) { -// UserJpaEntity user = users.get(i); -// ApplicationJpaEntity application = applicationRepository.save( -// ApplicationJpaEntity.builder() -// .userId(user.getId()) -// .guardianPhoneNumber("010-9876-" + String.format("%04d", 1000 + i)) -// .agreedToNotices(true) -// .agreedToRefundPolicy(true) -// .build() -// ); -// -// Collections.shuffle(schools); -// int schoolsToApply = random.nextInt(2) + 2; -// -// for (int j = 0; j < Math.min(schoolsToApply, schools.size()); j++) { -// SchoolJpaEntity school = schools.get(j); -// Set subjects = new HashSet<>(); -// subjects.add(Subject.values()[random.nextInt(Subject.values().length)]); -// if (random.nextBoolean()) { -// subjects.add(Subject.values()[random.nextInt(Subject.values().length)]); -// } -// -// ApplicationSchoolJpaEntity appSchool = applicationSchoolRepository.save( -// ApplicationSchoolJpaEntity.builder() -// .userId(user.getId()) -// .applicationId(application.getId()) -// .schoolId(school.getId()) -// .schoolName(school.getSchoolName()) -// .area(school.getArea()) -// .address(school.getAddress()) -// .examDate(LocalDate.of(2025, 10, 20 + i)) -// .lunch(Lunch.values()[random.nextInt(Lunch.values().length)]) -// .examinationNumber( -// String.format("EXAM-%d-%d", user.getId(), school.getId())) -// .subjects(subjects) -// .build() -// ); -// createdAppSchools.add(appSchool); -// } -// } -// log.info("Application 및 ApplicationSchool 데이터 생성 완료."); -// return createdAppSchools; -// } -// -// private void initializePayments(List applicationSchools) { -// List payments = new ArrayList<>(); -// int successCount = applicationSchools.size() / 2; -// -// for (int i = 0; i < applicationSchools.size(); i++) { -// ApplicationSchoolJpaEntity appSchool = applicationSchools.get(i); -// String orderId = "order-" + UUID.randomUUID().toString().substring(0, 8); -// -// if (i < successCount) { -// PaymentAmountVO paymentAmount = PaymentAmountVO.of( -// 12000, -// 10909, -// 1091, -// 12000, -// 0 -// ); -// -// PaymentJpaEntity payment = PaymentJpaEntity.of( -// appSchool.getId(), -// "pkey-" + UUID.randomUUID().toString().substring(0, 12), -// orderId, -// PaymentStatus.DONE, -// paymentAmount, -// PaymentMethod.CARD -// ); -// payments.add(payment); -// } else { -// PaymentJpaEntity payment = PaymentJpaEntity.ofFailure( -// appSchool.getId(), -// orderId, -// PaymentStatus.ABORTED, -// 12000 -// ); -// payments.add(payment); -// } -// } -// paymentRepository.saveAll(payments); -// log.info("Payment 데이터 {}건 생성 완료 (성공: {}, 실패: {}).", payments.size(), successCount, -// payments.size() - successCount); -// } -// -// private void initializeBoardItems(List users, Random random) { -// for (int i = 1; i <= 10; i++) { -// UserJpaEntity author = users.get(random.nextInt(users.size())); -// noticeJpaRepository.save(NoticeJpaEntity.builder() -// .title("공지사항 제목 " + i) -// .content("이것은 " + i + "번째 공지사항의 내용입니다.") -// .userId(author.getId()) -// .author(author.getName()) -// .build()); -// } -// -// List inquiries = new ArrayList<>(); -// for (int i = 1; i <= 10; i++) { -// UserJpaEntity author = users.get(random.nextInt(users.size())); -// inquiries.add(inquiryJpaRepository.save( -// InquiryJpaEntity.builder() -// .title("문의 제목 " + i) -// .content("안녕하세요. " + i + "번째 문의 내용입니다.") -// .userId(author.getId()) -// .author(author.getName()) -// .build()) -// ); -// } -// -// for (int i = 1; i <= 10; i++) { -// InquiryJpaEntity inquiryToAnswer = inquiries.get(random.nextInt(inquiries.size())); -// -// UserJpaEntity answerer = users.get(random.nextInt(users.size())); -// inquiryAnswerJpaRepository.save(InquiryAnswerJpaEntity.builder() -// .title("Re: " + inquiryToAnswer.getTitle()) -// .content("문의하신 내용에 대한 답변입니다. " + i + "번째 답변입니다.") -// .inquiryId(inquiryToAnswer.getId()) -// .userId(answerer.getId()) -// .build()); -// inquiryToAnswer.updateStatusToComplete(); -// inquiryJpaRepository.save(inquiryToAnswer); -// } -// -// for (int i = 1; i <= 10; i++) { -// LocalDate startDate = LocalDate.now().plusDays(i * 2); -// LocalDate endDate = startDate.plusDays(random.nextInt(7) + 3); -// eventRepository.save(EventJpaEntity.builder() -// .title("이벤트 제목 " + i) -// .duration(new DurationJpaVO(startDate, endDate)) -// .eventLink("https://example.com/event/" + i) -// .build()); -// } -// log.info("Board(Notice, Inquiry, Event) 데이터 생성 완료."); -// } -//} +package life.mosu.mosuserver.global.initializer; + +import jakarta.annotation.PostConstruct; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.Collections; +import java.util.HashSet; +import java.util.List; +import java.util.Random; +import java.util.Set; +import java.util.UUID; +import life.mosu.mosuserver.domain.application.ApplicationJpaEntity; +import life.mosu.mosuserver.domain.application.ApplicationJpaRepository; +import life.mosu.mosuserver.domain.application.Subject; +import life.mosu.mosuserver.domain.event.DurationJpaVO; +import life.mosu.mosuserver.domain.event.EventJpaEntity; +import life.mosu.mosuserver.domain.event.EventJpaRepository; +import life.mosu.mosuserver.domain.exam.AddressJpaVO; +import life.mosu.mosuserver.domain.exam.Area; +import life.mosu.mosuserver.domain.exam.ExamJpaEntity; +import life.mosu.mosuserver.domain.exam.ExamJpaRepository; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; +import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaRepository; +import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaEntity; +import life.mosu.mosuserver.domain.examapplication.ExamSubjectJpaRepository; +import life.mosu.mosuserver.domain.inquiry.InquiryJpaEntity; +import life.mosu.mosuserver.domain.inquiry.InquiryJpaRepository; +import life.mosu.mosuserver.domain.inquiryAnswer.InquiryAnswerJpaEntity; +import life.mosu.mosuserver.domain.inquiryAnswer.InquiryAnswerJpaRepository; +import life.mosu.mosuserver.domain.notice.NoticeJpaEntity; +import life.mosu.mosuserver.domain.notice.NoticeJpaRepository; +import life.mosu.mosuserver.domain.payment.PaymentAmountVO; +import life.mosu.mosuserver.domain.payment.PaymentJpaEntity; +import life.mosu.mosuserver.domain.payment.PaymentJpaRepository; +import life.mosu.mosuserver.domain.payment.PaymentMethod; +import life.mosu.mosuserver.domain.payment.PaymentStatus; +import life.mosu.mosuserver.domain.profile.Education; +import life.mosu.mosuserver.domain.profile.Gender; +import life.mosu.mosuserver.domain.profile.Grade; +import life.mosu.mosuserver.domain.profile.ProfileJpaEntity; +import life.mosu.mosuserver.domain.profile.ProfileJpaRepository; +import life.mosu.mosuserver.domain.profile.SchoolInfoJpaVO; +import life.mosu.mosuserver.domain.user.UserJpaEntity; +import life.mosu.mosuserver.domain.user.UserJpaRepository; +import life.mosu.mosuserver.domain.user.UserRole; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class DatabaseInitializer { + + // 모의고사 1회당 기본 응시료 + private static final int BASE_EXAM_FEE = 50000; + + private final UserJpaRepository userRepository; + private final ProfileJpaRepository profileRepository; + private final ExamJpaRepository examRepository; + private final ApplicationJpaRepository applicationRepository; + private final ExamApplicationJpaRepository examApplicationRepository; + private final ExamSubjectJpaRepository examSubjectRepository; + private final PaymentJpaRepository paymentRepository; + private final NoticeJpaRepository noticeRepository; + private final InquiryJpaRepository inquiryRepository; + private final InquiryAnswerJpaRepository inquiryAnswerRepository; + private final EventJpaRepository eventRepository; + private final PasswordEncoder passwordEncoder; + + @PostConstruct + public void init() { + if (userRepository.count() > 0 || examRepository.count() > 0) { + log.info("이미 더미 데이터가 존재하여 전체 초기화를 건너뜁니다."); + return; + } + + log.info("전체 더미 데이터 초기화를 시작합니다... 🚀"); + Random random = new Random(); + + // 1. 유저 및 프로필 생성 + List users = initializeUsersAndProfiles(random); + + // 2. 시험 정보 생성 + List exams = initializeExams(); + + // 3. 유저별 시험 신청 및 결제 정보 생성 + initializeApplicationsAndPayments(users, exams, random); + + // 4. 게시판 관련 데이터 생성 (공지, 문의, 이벤트) + initializeBoardItems(users, random); + + log.info("✅ 모든 더미 데이터 초기화가 완료되었습니다."); + } + + private List initializeUsersAndProfiles(Random random) { + List createdUsers = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + UserJpaEntity user = UserJpaEntity.builder() + .loginId("user" + i) + .password(passwordEncoder.encode("password" + i + "!")) + .gender((i % 2 == 0) ? Gender.MALE : Gender.FEMALE) + .name((i % 2 == 0) ? "김모수" + i : "이모수" + i) + .birth(LocalDate.of(2005 + (i % 3), (i % 12) + 1, (i % 28) + 1)) + .agreedToTermsOfService(true) + .agreedToPrivacyPolicy(true) + .agreedToMarketing(random.nextBoolean()) + .userRole((i == 1) ? UserRole.ROLE_ADMIN : UserRole.ROLE_USER) + .build(); + userRepository.save(user); + createdUsers.add(user); + + ProfileJpaEntity profile = ProfileJpaEntity.builder() + .userId(user.getId()) + .userName(user.getName()) + .gender(user.getGender()) + .birth(user.getBirth()) + .phoneNumber("010-1234-" + String.format("%04d", i)) + .email("user" + i + "@mosu.life") + .education(Education.ENROLLED) + .schoolInfo( + new SchoolInfoJpaVO( + "모수고등학교", + "서울시 모수구 모수동 123-45", + "12345" + ) + ) + .grade(Grade.values()[random.nextInt(Grade.values().length)]) + .build(); + profileRepository.save(profile); + } + log.info("👤 User 및 Profile 데이터 {}건 생성 완료.", createdUsers.size()); + return createdUsers; + } + + private List initializeExams() { + // 사용자가 제공한 확정된 시험 정보로 수정 + // 수정 가능한 리스트로 만들기 위해 new ArrayList<>()로 감싸기 + List exams = new ArrayList<>(List.of( + // 대치중학교 + createExam("대치중학교", Area.DAECHI, "06234", "강남구 대치동 987", + LocalDate.of(2025, 10, 19), 532, "제육김치덮밥", 8000), + + // 목운중학교 + createExam("목운중학교", Area.MOKDONG, "07995", "양천구 목동서로 369", + LocalDate.of(2025, 10, 26), 896, "함박스테이크", 9500), + + // 신서중학교 + createExam("신서중학교", Area.MOKDONG, "08018", "양천구 신정로 250", + LocalDate.of(2025, 11, 2), 896, "돈까스카레", 9000), + + // 개원중학교 (10/26 시험) + createExam("개원중학교", Area.DAECHI, "06327", "강남구 개포로 619", + LocalDate.of(2025, 10, 26), 840, "치킨마요덮밥", 8500), + // 개원중학교 (11/2 시험) + createExam("개원중학교", Area.DAECHI, "06327", "강남구 개포로 619", + LocalDate.of(2025, 11, 2), 840, "소불고기덮밥", 9000), + + // 문래중학교 + createExam("문래중학교", Area.MOKDONG, "07291", "영등포구 문래로 195", + LocalDate.of(2025, 10, 19), 558, null, null), // 도시락 미제공 + + // 온곡중학교 (10/19 시험) + createExam("온곡중학교", Area.NOWON, "01673", "노원구 덕릉로 70길 99", + LocalDate.of(2025, 10, 19), 448, "유부초밥&우동", 7500), + // 온곡중학교 (11/2 시험) + createExam("온곡중학교", Area.NOWON, "01673", "노원구 덕릉로 70길 99", + LocalDate.of(2025, 11, 2), 448, "참치김치찌개", 8000) + )); + examRepository.saveAll(exams); + log.info("🏫 Exam 데이터 {}건 생성 완료.", exams.size()); + return exams; + } + + private ExamJpaEntity createExam(String schoolName, Area area, String zipcode, String street, + LocalDate examDate, int capacity, String lunchName, Integer lunchPrice) { + return ExamJpaEntity.builder() + .schoolName(schoolName) + .area(area) + .address(new AddressJpaVO(zipcode, "서울특별시", street)) + .examDate(examDate) + .capacity(capacity) + .deadlineTime(examDate.minusDays(7).atTime(23, 59, 59)) + .lunchName(lunchName) + .lunchPrice(lunchPrice) + .build(); + } + + private void initializeApplicationsAndPayments(List users, + List exams, Random random) { + int successfulPayments = users.size() / 2; + int paymentCounter = 0; + + for (UserJpaEntity user : users) { + // 1. 유저별로 하나의 신청(Application) 묶음 생성 + ApplicationJpaEntity application = applicationRepository.save( + ApplicationJpaEntity.builder() + .userId(user.getId()) + .parentPhoneNumber("010-9876-" + String.format("%04d", user.getId())) + .agreedToNotices(true) + .agreedToRefundPolicy(true) + .build() + ); + + // 2. 해당 신청에 1~3개의 시험 응시(ExamApplication)를 추가 + Collections.shuffle(exams); + int examsToApplyCount = random.nextInt(3) + 1; // 1~3개 시험 신청 + int totalAmount = 0; + List currentExamApplications = new ArrayList<>(); + + for (int j = 0; j < Math.min(examsToApplyCount, exams.size()); j++) { + ExamJpaEntity exam = exams.get(j); + boolean isLunchChecked = (exam.getLunchPrice() != null) && random.nextBoolean(); + + totalAmount += BASE_EXAM_FEE; + + ExamApplicationJpaEntity examApplication = examApplicationRepository.save( + ExamApplicationJpaEntity.builder() + .applicationId(application.getId()) + .examId(exam.getId()) +// .examNumber(String.format("MOSU-%d-%d", application.getId(), +// exam.getId())) + .isLunchChecked( + ((j % 2 == 0) ? true : false) + ) + .build() + ); + currentExamApplications.add(examApplication); + } + + // 3. 각 시험 응시(ExamApplication)에 대한 선택 과목(ExamSubject) 생성 + for (ExamApplicationJpaEntity examApp : currentExamApplications) { + Set subjects = new HashSet<>(); + int subjectCount = random.nextInt(2) + 1; // 1~2개 과목 선택 + while (subjects.size() < subjectCount) { + subjects.add(Subject.values()[random.nextInt(Subject.values().length)]); + } + subjects.forEach(subject -> + examSubjectRepository.save( + new ExamSubjectJpaEntity(examApp.getId(), subject) + ) + ); + } + + // 4. 생성된 신청(Application) 묶음에 대한 결제(Payment) 정보 생성 + String orderId = "order-" + UUID.randomUUID().toString().substring(0, 12); + if (paymentCounter < successfulPayments) { + // 결제 성공 케이스 + PaymentJpaEntity payment = PaymentJpaEntity.of( + application.getId(), + "pkey-" + UUID.randomUUID().toString().substring(0, 18), + orderId, + PaymentStatus.DONE, + PaymentAmountVO.of( + totalAmount, totalAmount, totalAmount, 0, 0 + ), + PaymentMethod.CARD + ); + paymentRepository.save(payment); + } else { + // 결제 실패 케이스 (취소, 시간 초과 등) + PaymentStatus failureStatus = + random.nextBoolean() ? PaymentStatus.ABORTED : PaymentStatus.EXPIRED; + PaymentJpaEntity payment = PaymentJpaEntity.of( + application.getId(), + "pkey-" + UUID.randomUUID().toString().substring(0, 18), + orderId, + failureStatus, + PaymentAmountVO.of( + totalAmount, totalAmount, totalAmount, 0, 0 + ), + PaymentMethod.CARD + ); + paymentRepository.save(payment); + } + paymentCounter++; + } + log.info("📝 Application, ExamApplication, ExamSubject, Payment 데이터 생성 완료 (성공: {}, 실패: {}).", + successfulPayments, users.size() - successfulPayments); + } + + private void initializeBoardItems(List users, Random random) { + UserJpaEntity admin = users.stream().filter(u -> u.getUserRole() == UserRole.ROLE_ADMIN) + .findFirst().orElse(users.get(0)); + + // 공지사항 생성 + for (int i = 1; i <= 10; i++) { + noticeRepository.save(NoticeJpaEntity.builder() + .title("중요 공지사항 #" + i) + .content("제 " + i + "차 모의고사 관련 안내입니다. 내용을 필히 숙지해주시기 바랍니다.") + .userId(admin.getId()) + .author(admin.getName()) + .build()); + } + + // 문의 및 답변 생성 + List inquiries = new ArrayList<>(); + for (int i = 1; i <= 10; i++) { + UserJpaEntity author = users.get(random.nextInt(users.size())); + InquiryJpaEntity inquiry = inquiryRepository.save( + InquiryJpaEntity.builder() + .title("결제 관련 문의 드립니다 (" + i + ")") + .content("안녕하세요. " + i + "번째 문의 내용입니다. 확인 부탁드립니다.") + .userId(author.getId()) + .author(author.getName()) + .build() + ); + inquiries.add(inquiry); + } + + // 7개의 문의에만 답변 달기 + Collections.shuffle(inquiries); + for (int i = 0; i < 7; i++) { + InquiryJpaEntity inquiryToAnswer = inquiries.get(i); + InquiryAnswerJpaEntity inquiryAnswer = InquiryAnswerJpaEntity.builder() + .title("Re: " + inquiryToAnswer.getTitle()) + .content("문의하신 내용에 대한 답변입니다. 확인 후 추가 문의사항이 있으시면 다시 글 남겨주세요.") + .inquiryId(inquiryToAnswer.getId()) + .userId(admin.getId()) + .build(); + inquiryAnswerRepository.save(inquiryAnswer); + inquiryToAnswer.updateStatusToComplete(); + inquiryRepository.save(inquiryToAnswer); + } + + // 이벤트 생성 + for (int i = 1; i <= 10; i++) { + LocalDate startDate = LocalDate.now().plusDays(i * 5L); + LocalDate endDate = startDate.plusDays(random.nextInt(10) + 5); + eventRepository.save(EventJpaEntity.builder() + .title("여름방학 맞이 특별 이벤트 #" + i) + .duration(new DurationJpaVO(startDate, endDate)) + .eventLink("https://mosu.life/event/" + i) + .build()); + } + log.info("📋 Board(Notice, Inquiry, Event) 데이터 생성 완료."); + } +} \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/infra/respository/BulkRepository.java b/src/main/java/life/mosu/mosuserver/infra/respository/BulkRepository.java deleted file mode 100644 index e389cfe5..00000000 --- a/src/main/java/life/mosu/mosuserver/infra/respository/BulkRepository.java +++ /dev/null @@ -1,91 +0,0 @@ -//package life.mosu.mosuserver.infra.respository; -// -//import java.sql.PreparedStatement; -//import java.sql.ResultSet; -//import java.sql.SQLException; -//import java.sql.Statement; -//import java.sql.Timestamp; -//import java.time.LocalDateTime; -//import java.util.ArrayList; -//import java.util.List; -//import life.mosu.mosuserver.domain.application.Subject; -//import life.mosu.mosuserver.domain.examapplication.ExamApplicationJpaEntity; -//import life.mosu.mosuserver.global.exception.CustomRuntimeException; -//import life.mosu.mosuserver.global.exception.ErrorCode; -//import lombok.RequiredArgsConstructor; -//import org.springframework.dao.DataAccessException; -//import org.springframework.jdbc.core.ConnectionCallback; -//import org.springframework.jdbc.core.JdbcTemplate; -//import org.springframework.stereotype.Repository; -//import org.springframework.transaction.annotation.Transactional; -// -//@Repository -//@RequiredArgsConstructor -//public class ExamApplicationBulkRepository { -// -// private static final String SQL_INSERT_EXAM_APPLICATION = """ -// INSERT INTO exam_application -// (created_at, updated_at, application_id, user_id, -// examId, examination_number) -// VALUES (?,?,?,?,?,?) -// """; -// private static final String SQL_INSERT_SUBJECT = """ -// INSERT INTO exam_subject (exam_application_id, subjects) VALUES (?, ?) -// """; -// private final JdbcTemplate jdbcTemplate; -// -// -// @Transactional -// public List saveAllExamApplicationsWithSubjects( -// List entities) { -// -// List generatedIds = jdbcTemplate.execute((ConnectionCallback>) con -> { -// try (PreparedStatement ps = con.prepareStatement( -// SQL_INSERT_EXAM_APPLICATION, Statement.RETURN_GENERATED_KEYS)) { -// for (ExamApplicationJpaEntity e : entities) { -// ps.setTimestamp(1, Timestamp.valueOf(LocalDateTime.now())); -// ps.setTimestamp(2, Timestamp.valueOf(LocalDateTime.now())); -// ps.setLong(3, e.getApplicationId()); -// ps.setLong(4, e.getUserId()); -// ps.setLong(5, e.examId()); -// ps.setString(6, e.getExaminationNumber()); -// ps.addBatch(); -// } -// -// ps.executeBatch(); -// -// List ids = new ArrayList<>(); -// try (ResultSet rs = ps.getGeneratedKeys()) { -// while (rs.next()) { -// ids.add(rs.getLong(1)); -// } -// } -// return ids; -// -// } catch (SQLException e) { -// throw new CustomRuntimeException(ErrorCode.EXAM_APPLICATION_MULTI_INSERT_ERROR); -// } -// }); -// -// List subjectParams = new ArrayList<>(); -// -// for (int i = 0; i < entities.size(); i++) { -// Long applicationSchoolId = generatedIds.get(i); -// -// for (Subject subj : entities.get(i).getSubjects()) { -// subjectParams.add(new Object[]{applicationSchoolId, String.valueOf(subj)}); -// } -// } -// -// try { -// if (!subjectParams.isEmpty()) { -// jdbcTemplate.batchUpdate(SQL_INSERT_SUBJECT, subjectParams); -// } -// } catch (DataAccessException e) { -// throw new CustomRuntimeException(ErrorCode.EXAM_SUBJECT_MULTI_INSERT_ERROR); -// } -// -// return generatedIds; -// } -// -//} diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminController.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminController.java index 664097e1..a5b0b803 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminController.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminController.java @@ -6,11 +6,15 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.List; +import life.mosu.mosuserver.application.admin.AdminService; import life.mosu.mosuserver.global.util.ApiResponseWrapper; +import life.mosu.mosuserver.global.util.excel.SimpleExcelFile; +import life.mosu.mosuserver.presentation.admin.dto.ApplicationExcelDto; import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; +import life.mosu.mosuserver.presentation.admin.dto.StudentExcelDto; import life.mosu.mosuserver.presentation.admin.dto.StudentFilter; import life.mosu.mosuserver.presentation.admin.dto.StudentListResponse; import lombok.RequiredArgsConstructor; @@ -29,15 +33,16 @@ @RequestMapping("/admin") public class AdminController implements AdminControllerDocs { + private final AdminService adminService; @GetMapping("/students") //@PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity>> getStudents( @Valid @ModelAttribute StudentFilter filter, - Pageable pageable + @PageableDefault(size = 10) Pageable pageable ) { -// Page result = adminService.getStudents(filter, pageable); - return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "학생 목록 조회 성공", null)); + Page result = adminService.getStudents(filter, pageable); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "학생 목록 조회 성공", result)); } @GetMapping("/excel/students") @@ -51,29 +56,29 @@ public void downloadStudentInfo( "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName); -// List data = adminService.getStudentExcelData(); -// SimpleExcelFile excelFile = new SimpleExcelFile<>(data, -// StudentExcelDto.class); + List data = adminService.getStudentExcelData(); + SimpleExcelFile excelFile = new SimpleExcelFile<>(data, + StudentExcelDto.class); -// excelFile.write(response.getOutputStream()); + excelFile.write(response.getOutputStream()); } @GetMapping("/lunches") // @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity>> getLunchCounts() { -// List result = adminService.getLunchCounts(); + List result = adminService.getLunchCounts(); return ResponseEntity.ok( - ApiResponseWrapper.success(HttpStatus.OK, "학교별 도시락 신청 수 조회 성공", null)); + ApiResponseWrapper.success(HttpStatus.OK, "학교별 도시락 신청 수 조회 성공", result)); } @GetMapping("/applications") // @PreAuthorize("isAuthenticated() and hasRole('ADMIN')") public ResponseEntity>> getApplications( @Valid @ModelAttribute ApplicationFilter filter, - Pageable pageable + @PageableDefault(size = 10) Pageable pageable ) { -// Page result = adminService.getApplications(filter, pageable); - return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "신청 목록 조회 성공", null)); + Page result = adminService.getApplications(filter, pageable); + return ResponseEntity.ok(ApiResponseWrapper.success(HttpStatus.OK, "신청 목록 조회 성공", result)); } @GetMapping("/excel/applications") @@ -86,11 +91,11 @@ public void downloadApplicationInfo(HttpServletResponse response) throws IOExcep "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet"); response.setHeader("Content-Disposition", "attachment; filename*=UTF-8''" + fileName); -// List data = adminService.getApplicationExcelData(); -// SimpleExcelFile excelFile = new SimpleExcelFile<>(data, -// ApplicationExcelDto.class); -// -// excelFile.write(response.getOutputStream()); + List data = adminService.getApplicationExcelData(); + SimpleExcelFile excelFile = new SimpleExcelFile<>(data, + ApplicationExcelDto.class); + + excelFile.write(response.getOutputStream()); } @GetMapping("/refunds") diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminControllerDocs.java b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminControllerDocs.java index 9d513682..88a40e30 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/AdminControllerDocs.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/AdminControllerDocs.java @@ -13,23 +13,25 @@ import life.mosu.mosuserver.global.util.ApiResponseWrapper; import life.mosu.mosuserver.presentation.admin.dto.ApplicationFilter; import life.mosu.mosuserver.presentation.admin.dto.ApplicationListResponse; +import life.mosu.mosuserver.presentation.admin.dto.RefundListResponse; import life.mosu.mosuserver.presentation.admin.dto.SchoolLunchResponse; import life.mosu.mosuserver.presentation.admin.dto.StudentFilter; import life.mosu.mosuserver.presentation.admin.dto.StudentListResponse; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; +import org.springframework.data.web.PageableDefault; import org.springframework.http.ResponseEntity; @Tag(name = "Admin API", description = "관리자용 데이터 조회 및 엑셀 다운로드 API 명세") public interface AdminControllerDocs { - @Operation(summary = "학생 목록 조회", description = "필터 조건에 따른 학생 목록을 페이징하여 조회합니다.") + @Operation(summary = "학생 목록 및 단건 조회", description = "필터 조건에 따라 단건 데이터가 조회됩니다. 전체 목록을 조회하는 필터는 admin/applications?page=0 해당 방식으로 사용하시면 됩니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "학생 목록 조회 성공", content = @Content(schema = @Schema(implementation = StudentListResponse.class))) }) ResponseEntity>> getStudents( - @Parameter(description = "학생 목록 조회 필터") + @Parameter(description = "(단건 조회시 사용)학생 목록 조회 필터") StudentFilter filter, @Parameter(hidden = true) @@ -44,20 +46,20 @@ void downloadStudentInfo( @Parameter(hidden = true) HttpServletResponse response ) throws IOException; - @Operation(summary = "학교별 도시락 신청 수 조회", description = "학교별 도시락 신청 수를 조회합니다.") + @Operation(summary = "학교별 도시락 신청자 수 조회", description = "학교별 도시락 신청자 수를 조회합니다.") @ApiResponses(value = { - @ApiResponse(responseCode = "200", description = "도시락 신청 수 조회 성공", + @ApiResponse(responseCode = "200", description = "도시락 신청자 수 조회 성공", content = @Content(schema = @Schema(implementation = SchoolLunchResponse.class))) }) ResponseEntity>> getLunchCounts(); - @Operation(summary = "신청 목록 조회", description = "필터 조건에 따른 신청 목록을 페이징하여 조회합니다.") + @Operation(summary = "신청 목록 및 단건 조회", description = "필터 조건에 따라 단건 데이터가 조회됩니다. 전체 목록을 조회하는 필터는 admin/applications?page=0 해당 방식으로 사용하시면 됩니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "신청 목록 조회 성공", content = @Content(schema = @Schema(implementation = ApplicationListResponse.class))) }) ResponseEntity>> getApplications( - @Parameter(description = "신청 목록 조회 필터") + @Parameter(description = "신청자 단건 조회 필터") ApplicationFilter filter, @Parameter(hidden = true) @@ -72,4 +74,11 @@ void downloadApplicationInfo( @Parameter(hidden = true) HttpServletResponse response ) throws IOException; + @Operation(summary = "환불 신청 수 조회 (구현중)", description = "환불 신청 수 조회 입니다") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "환불 신청 수 조회 성공") + }) + public ResponseEntity>> getRefundCounts( + @PageableDefault(size = 15) Pageable pageable + ); } \ No newline at end of file diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationExcelDto.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationExcelDto.java index e2315f67..4d403d8c 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationExcelDto.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationExcelDto.java @@ -6,83 +6,26 @@ import life.mosu.mosuserver.domain.payment.PaymentMethod; import life.mosu.mosuserver.domain.payment.PaymentStatus; import life.mosu.mosuserver.global.annotation.ExcelColumn; -import lombok.AllArgsConstructor; -import lombok.Getter; @Schema(description = "신청 엑셀 데이터 DTO") -@AllArgsConstructor -@Getter -public class ApplicationExcelDto { +public record ApplicationExcelDto( + @Schema(description = "결제 번호", example = "PAY-20250710-0001") @ExcelColumn(headerName = "결제 번호") String paymentNumber, + @Schema(description = "수험 번호", example = "2025-00001") @ExcelColumn(headerName = "수험 번호") String examinationNumber, + @Schema(description = "수험자 이름", example = "홍길동") @ExcelColumn(headerName = "이름") String name, + @Schema(description = "성별", example = "남자") @ExcelColumn(headerName = "성별") String gender, + @Schema(description = "생년월일", example = "2005-05-10") @ExcelColumn(headerName = "생년월일") LocalDate birth, + @Schema(description = "전화번호", example = "01012345678") @ExcelColumn(headerName = "전화번호") String phoneNumber, + @Schema(description = "보호자 전화번호", example = "01098765432") @ExcelColumn(headerName = "보호자 전화번호") String guardianPhoneNumber, + @Schema(description = "학력", example = "고등학교 재학") @ExcelColumn(headerName = "학력") String educationLevel, + @Schema(description = "학교명", example = "서울고등학교") @ExcelColumn(headerName = "학교명") String schoolName, + @Schema(description = "학년", example = "3학년") @ExcelColumn(headerName = "학년") String grade, + @Schema(description = "도시락 신청 여부", example = "신청함") @ExcelColumn(headerName = "도시락") String lunch, + @Schema(description = "응시 과목 목록", example = "[\"국어\", \"수학\"]") @ExcelColumn(headerName = "응시 과목") Set subjects, + @Schema(description = "시험 학교", example = "서울고등학교") @ExcelColumn(headerName = "시험 학교") String examSchoolName, + @Schema(description = "시험 일자", example = "2025-08-10") @ExcelColumn(headerName = "시험 일자") LocalDate examDate, + @Schema(description = "수험표 이미지 URL", example = "https://s3.amazonaws.com/bucket/admission/2025-00001.jpg") @ExcelColumn(headerName = "수험표 사진") String admissionTicketImage, + @Schema(description = "결제 상태", example = "COMPLETED") @ExcelColumn(headerName = "결제 상태") PaymentStatus paymentStatus, + @Schema(description = "결제 방법", example = "CARD") @ExcelColumn(headerName = "결제 방법") PaymentMethod paymentMethod, + @Schema(description = "신청 일시", example = "2025-07-10 15:30:00") @ExcelColumn(headerName = "신청 일시") String applicationDate) { - @Schema(description = "결제 번호", example = "PAY-20250710-0001") - @ExcelColumn(headerName = "결제 번호") - private final String paymentNumber; - - @Schema(description = "수험 번호", example = "2025-00001") - @ExcelColumn(headerName = "수험 번호") - private final String examinationNumber; - - @Schema(description = "수험자 이름", example = "홍길동") - @ExcelColumn(headerName = "이름") - private final String name; - - @Schema(description = "성별", example = "남자") - @ExcelColumn(headerName = "성별") - private final String gender; - - @Schema(description = "생년월일", example = "2005-05-10") - @ExcelColumn(headerName = "생년월일") - private final LocalDate birth; - - @Schema(description = "전화번호", example = "01012345678") - @ExcelColumn(headerName = "전화번호") - private final String phoneNumber; - - @Schema(description = "보호자 전화번호", example = "01098765432") - @ExcelColumn(headerName = "보호자 전화번호") - private final String guardianPhoneNumber; - - @Schema(description = "학력", example = "고등학교 재학") - @ExcelColumn(headerName = "학력") - private final String educationLevel; - - @Schema(description = "학교명", example = "서울고등학교") - @ExcelColumn(headerName = "학교명") - private final String schoolName; - - @Schema(description = "학년", example = "3학년") - @ExcelColumn(headerName = "학년") - private final String grade; - - @Schema(description = "도시락 신청 여부", example = "신청함") - @ExcelColumn(headerName = "도시락") - private final String lunch; - - @Schema(description = "응시 과목 목록", example = "[\"국어\", \"수학\"]") - @ExcelColumn(headerName = "응시 과목") - private final Set subjects; - - @Schema(description = "시험 학교", example = "서울고등학교") - @ExcelColumn(headerName = "시험 학교") - private final String examSchoolName; - - @Schema(description = "시험 일자", example = "2025-08-10") - @ExcelColumn(headerName = "시험 일자") - private final LocalDate examDate; - - @Schema(description = "수험표 이미지 URL", example = "https://s3.amazonaws.com/bucket/admission/2025-00001.jpg") - @ExcelColumn(headerName = "수험표 사진") - private final String admissionTicketImage; - - @Schema(description = "결제 상태", example = "COMPLETED") - @ExcelColumn(headerName = "결제 상태") - private final PaymentStatus paymentStatus; - - @Schema(description = "결제 방법", example = "CARD") - @ExcelColumn(headerName = "결제 방법") - private final PaymentMethod paymentMethod; - - @Schema(description = "신청 일시", example = "2025-07-10 15:30:00") - @ExcelColumn(headerName = "신청 일시") - private final String applicationDate; } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationFilter.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationFilter.java index cd1b68d6..296829d6 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationFilter.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationFilter.java @@ -2,7 +2,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; @Schema(description = "신청 목록 필터 DTO") public record ApplicationFilter( @@ -10,8 +9,7 @@ public record ApplicationFilter( @Schema(description = "이름 필터", example = "홍길동") String name, - @Schema(description = "전화번호 필터", example = "01012345678") - @PhoneNumberPattern + @Schema(description = "전화번호 필터", example = "010-1234-5678") String phone, @Schema(description = "신청 일자 필터", example = "2025-07-10") diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationListResponse.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationListResponse.java index 9024d445..79b75f13 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationListResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ApplicationListResponse.java @@ -3,7 +3,7 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.time.LocalDateTime; -import java.util.Set; +import java.util.List; import life.mosu.mosuserver.domain.payment.PaymentMethod; import life.mosu.mosuserver.domain.payment.PaymentStatus; @@ -40,11 +40,11 @@ public record ApplicationListResponse( @Schema(description = "학년", example = "고등학교 1학년") String grade, - @Schema(description = "도시락 신청 여부", example = "NONE") - String lunch, + @Schema(description = "도시락 신청 여부", example = "true, false") + Boolean lunch, @Schema(description = "응시 과목 목록", example = "[\"생활과 윤리\", \"정치와 법\"]") - Set subjects, + List subjects, @Schema(description = "시험 학교 이름", example = "서울고등학교") String examSchoolName, diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java index 46ced76d..cc2f4e18 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/ExamTicketResponse.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import java.time.LocalDate; import java.util.List; -import life.mosu.mosuserver.domain.application.Subject; public record ExamTicketResponse( @Schema(description = "수험표 이미지 URL", example = "https://s3.amazonaws.com/bucket/admission/2025-00001.jpg") @@ -19,7 +18,7 @@ public record ExamTicketResponse( String examNumber, @Schema(description = "응시 과목 목록", example = "[\"생명과학\", \"지구과학\"]") - List subjects, + List subjects, @Schema(description = "응시 학교명", example = "대치중학교") String schoolName @@ -30,7 +29,7 @@ public static ExamTicketResponse of( String userName, LocalDate birth, String examNumber, - List subjects, + List subjects, String schoolName ) { return new ExamTicketResponse( @@ -47,7 +46,7 @@ public static ExamTicketResponse ofWithoutExamTicket( String userName, LocalDate birth, String examNumber, - List subjects, + List subjects, String schoolName) { return new ExamTicketResponse( diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/SchoolLunchResponse.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/SchoolLunchResponse.java index ffa9e854..1d9d34b2 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/SchoolLunchResponse.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/SchoolLunchResponse.java @@ -13,4 +13,8 @@ public record SchoolLunchResponse( ) { + public static SchoolLunchResponse of(String schoolName, + Long count) { + return new SchoolLunchResponse(schoolName, count); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/StudentFilter.java b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/StudentFilter.java index 9bab8d28..cc932ee4 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/admin/dto/StudentFilter.java +++ b/src/main/java/life/mosu/mosuserver/presentation/admin/dto/StudentFilter.java @@ -1,7 +1,6 @@ package life.mosu.mosuserver.presentation.admin.dto; import io.swagger.v3.oas.annotations.media.Schema; -import life.mosu.mosuserver.global.annotation.PhoneNumberPattern; @Schema(description = "학생 목록 필터 DTO") public record StudentFilter( @@ -10,10 +9,9 @@ public record StudentFilter( String name, @Schema(description = "전화번호 필터", example = "01012345678") - @PhoneNumberPattern String phone, - @Schema(description = "정렬 순서 (desc 또는 asc)", example = "desc", defaultValue = "desc") + @Schema(description = "정렬 순서 (개발중)", example = "desc", defaultValue = "desc") String order ) { diff --git a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java index adad158b..25910415 100644 --- a/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java +++ b/src/main/java/life/mosu/mosuserver/presentation/application/dto/ExamApplicationRequest.java @@ -1,8 +1,13 @@ package life.mosu.mosuserver.presentation.application.dto; +import life.mosu.mosuserver.application.examapplication.dto.RegisterExamApplicationEvent.TargetExam; + public record ExamApplicationRequest( Long examId, Boolean isLunchChecked ) { + public TargetExam toTargetExam() { + return new TargetExam(examId, isLunchChecked); + } } diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaDetail.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AreaMeta.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/AvailableSchoolResponse.java new file mode 100644 index 00000000..e69de29b diff --git a/src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java b/src/main/java/life/mosu/mosuserver/presentation/school/dto/LunchInfo.java new file mode 100644 index 00000000..e69de29b From df3457ae3dabe684368f43f41085bd1a99fb0f41 Mon Sep 17 00:00:00 2001 From: chominju02 Date: Wed, 23 Jul 2025 07:40:34 +0900 Subject: [PATCH 28/28] chore : resolve the conflict --- .../life/mosu/mosuserver/application/exam/ExamService.java | 6 ++++-- .../life/mosu/mosuserver/domain/exam/ExamJpaRepository.java | 1 + 2 files changed, 5 insertions(+), 2 deletions(-) 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 cda83644..57ee203c 100644 --- a/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java +++ b/src/main/java/life/mosu/mosuserver/application/exam/ExamService.java @@ -36,8 +36,10 @@ public List getByArea(String areaName) { return ExamResponse.fromList(foundExams); } - public List getDistinctAreas() { - return examJpaRepository.findDistinctAreas(); + public List getDistinctAreas() { + return examJpaRepository.findDistinctAreas().stream() + .map(Area::getAreaName) + .toList(); } public List getExams() { diff --git a/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaRepository.java b/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaRepository.java index 5577bd2f..84fbfdbf 100644 --- a/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaRepository.java +++ b/src/main/java/life/mosu/mosuserver/domain/exam/ExamJpaRepository.java @@ -44,4 +44,5 @@ public interface ExamJpaRepository extends JpaRepository { """) List countApplicationsGroupedBySchoolName(); + List findByIdIn(List examIds); }