diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index c705601..d74aff4 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -42,7 +42,6 @@ jobs: - name: Grant execute permission for gradlew run: chmod +x ./gradlew - - name: application-secret.yml 생성 run: | echo "$APPLICATION_SECRET" > src/main/resources/application-secret.yml @@ -56,10 +55,17 @@ jobs: - name: Docker Buildx 설치 uses: docker/setup-buildx-action@v2 - + + - name: Start services for test + run: docker compose up -d + - name: Run tests with Gradle run: ./gradlew test + - name: Stop services after test + if: always() + run: docker compose down + # 테스트 실패 시 리포트를 아티팩트로 업로드하여 디버깅을 돕습니다. - name: Upload test reports if: failure() @@ -71,7 +77,7 @@ jobs: # 2. 릴리스 : main/develop 브랜치로 Push될 때만 실행 makeTagAndRelease: name: Create Tag and Release - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: backend-ci runs-on: ubuntu-latest permissions: @@ -101,7 +107,7 @@ jobs: # 3. 빌드 및 배포: main/develop 브랜치로 Push될 때만 실행 buildImageAndPush: name: 도커 이미지 빌드와 푸시 - # if: github.event_name == 'push' + if: github.event_name == 'push' needs: makeTagAndRelease runs-on: ubuntu-latest steps: @@ -143,4 +149,4 @@ jobs: no-cache: true tags: | ghcr.io/${{ env.OWNER_LC }}/catch-course:${{ needs.makeTagAndRelease.outputs.tag_name }} - ghcr.io/${{ env.OWNER_LC }}/catch-course:latest \ No newline at end of file + ghcr.io/${{ env.OWNER_LC }}/catch-course:latest diff --git a/backend/build.gradle b/backend/build.gradle index d9eaab1..5ee546c 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -76,6 +76,10 @@ dependencies { // mysql runtimeOnly 'com.mysql:mysql-connector-j' + + // 모니터링 + implementation 'org.springframework.boot:spring-boot-starter-actuator' + implementation 'io.micrometer:micrometer-registry-prometheus' } tasks.named('test') { diff --git a/backend/docker-compose-prod.yml b/backend/docker-compose-prod.yml index 6308e82..127e43c 100644 --- a/backend/docker-compose-prod.yml +++ b/backend/docker-compose-prod.yml @@ -16,6 +16,7 @@ services: redis-prod: image: redis:latest container_name: redis-prod + command: ["redis-server", "--notify-keyspace-events", "Ex"] restart: always ports: - "6380:6379" diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index aab3573..b4ca17b 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -1,9 +1,10 @@ -version: '3.8' +# docker-compose.yml services: redis-dev: image: redis:latest container_name: redis-dev + command: ["redis-server", "--notify-keyspace-events", "Ex"] restart: always ports: - "6379:6379" @@ -54,6 +55,46 @@ services: networks: - dev-network + prometheus-dev: + image: prom/prometheus:latest + container_name: prometheus-dev + restart: always + ports: + - "9090:9090" + volumes: + - ./prometheus.yml:/etc/prometheus/prometheus.yml + command: + - '--config.file=/etc/prometheus/prometheus.yml' + extra_hosts: + - "host.docker.internal:host-gateway" + networks: + - dev-network + + grafana-dev: + image: grafana/grafana-oss:latest + container_name: grafana-dev + restart: always + ports: + - "3001:3000" + depends_on: + - prometheus-dev + networks: + - dev-network + + node_exporter: + image: quay.io/prometheus/node-exporter:latest + container_name: node_exporter + platform: linux/arm64 + restart: unless-stopped + command: + - '--path.rootfs=/host' + volumes: + - '/:/host:ro' + networks: + - dev-network + ports: + - "9100:9100" + networks: dev-network: driver: bridge \ No newline at end of file diff --git a/backend/prometheus.yml b/backend/prometheus.yml new file mode 100644 index 0000000..85090aa --- /dev/null +++ b/backend/prometheus.yml @@ -0,0 +1,12 @@ +global: + scrape_interval: 10s + +scrape_configs: + - job_name: 'catch-course-dev' + metrics_path: '/actuator/prometheus' + static_configs: + - targets: ['host.docker.internal:8080'] + + - job_name: 'node-exporter' + static_configs: + - targets: ['host.docker.internal:9100'] \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java index 0c3c6c9..6afe298 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/controller/CourseController.java @@ -73,7 +73,7 @@ public RsData getItem(@PathVariable long id) { public RsData delete(@PathVariable long id) { Member dummyMember = rq.getDummyMember(); // 더미 유저 객체(id,username,authorities 만 있음, 필요하면 DB에서 꺼내씀) - Course course = courseService.getItem(id) + Course course = courseService.getItemWithPessimisticLock(id) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); course.canDelete(dummyMember); @@ -100,7 +100,7 @@ public RsData modify(@PathVariable long id, @RequestBody @Valid ModifyReqB ) { Member dummyMember = rq.getDummyMember(); - Course course = courseService.getItem(id) + Course course = courseService.getItemWithPessimisticLock(id) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); if (!course.getInstructor().getId().equals(dummyMember.getId())) { @@ -119,7 +119,8 @@ public RsData modify(@PathVariable long id, @RequestBody @Valid ModifyReqB record WriteReqBody( @NotBlank @Length(min = 3) String title, @NotBlank @Length(min = 3) String content, - @NotNull @Min(1) long capacity + @NotNull @Min(1) long capacity, + @NotNull @Min(1000) long price ) { } @@ -130,7 +131,7 @@ record WriteReqBody( public RsData write(@RequestBody @Valid WriteReqBody body) { Member dummyMember = rq.getDummyMember(); - Course course = courseService.write(dummyMember, body.title(), body.content(), body.capacity()); + Course course = courseService.write(dummyMember, body.title(), body.content(), body.capacity(),body.price()); return new RsData<>( "200-1", diff --git a/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java b/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java index be85e83..0988eaa 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/entity/Course.java @@ -26,6 +26,7 @@ public class Course extends BaseTime { private String content; // 강의 내용 private long capacity; // 정원 private long currentRegistration; // 현재 등록 인원 + private Long price; public void canModify(Member member) { if (member == null) { diff --git a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java index 1769357..2ec01ed 100644 --- a/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java +++ b/backend/src/main/java/com/Catch_Course/domain/course/service/CourseService.java @@ -22,7 +22,7 @@ public class CourseService { private final CourseRepository courseRepository; @Transactional - public Course write(Member member, String title, String content, long capacity) { + public Course write(Member member, String title, String content, long capacity, long price) { return courseRepository.save( Course @@ -32,6 +32,7 @@ public Course write(Member member, String title, String content, long capacity) .content(content) .capacity(capacity) .currentRegistration(0) + .price(price) .build() ); } @@ -58,6 +59,10 @@ public Optional getItem(long courseId) { return courseRepository.findById(courseId); } + public Optional getItemWithPessimisticLock(long courseId) { + return courseRepository.findByIdWithPessimisticLock(courseId); + } + public long count() { return courseRepository.count(); } @@ -76,6 +81,6 @@ public void modify(Course course, String title, String content, long capacity) { public Course findById(long courseId) { return courseRepository.findById(courseId) - .orElseThrow(() -> new ServiceException("404-1","존재하지 않는 강의입니다.")); + .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); } } diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java new file mode 100644 index 0000000..ba8986e --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/PaymentController.java @@ -0,0 +1,107 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.dto.PaymentDto; +import com.Catch_Course.domain.payments.service.PaymentService; +import com.Catch_Course.global.Rq; +import com.Catch_Course.global.dto.RsData; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.RequiredArgsConstructor; +import org.hibernate.validator.constraints.Length; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@Tag(name = "paymentController", description = "결제 관련 API") +@RestController +@RequestMapping("/api/payment") +@RequiredArgsConstructor +public class PaymentController { + + private final PaymentService paymentService; + private final Rq rq; + + + @Operation(summary = "수강신청 정보로 결제 정보 조회") + @GetMapping("/{reservationId}") + public RsData getPayment(@PathVariable Long reservationId) { + + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.getPayment(member, reservationId); + + return new RsData<>( + "200-1", + "결제 정보 조회가 완료되었습니다.", + paymentDto + ); + } + + @Operation(summary = "결제 목록 조회") + @GetMapping() + public RsData> getPayments() { + + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + List paymentDtos = paymentService.getPayments(member); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDtos + ); + } + + + @Operation(summary = "결제 생성 및 요청") + @PostMapping("/request") + public RsData requestPayment(@RequestParam Long reservationId) { + + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.requestPayment(member, reservationId); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDto + ); + } + + record confirmPaymentReqBody(@NotBlank String paymentKey, + @NotBlank @Length(min = 3) String orderId, + @NotNull Long amount) { + } + + @Operation(summary = "결제 승인") + @PostMapping("/confirm") + public RsData confirmPayment(@RequestBody @Valid confirmPaymentReqBody body) { + + PaymentDto paymentDto = paymentService.confirmPayment(body.paymentKey, body.orderId, body.amount); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + paymentDto + ); + } + + @Operation(summary = "결제 취소") + @DeleteMapping("/{reservationId}") + public RsData getReservations(@PathVariable Long reservationId) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + PaymentDto paymentDto = paymentService.deletePaymentRequest(member, reservationId); + + return new RsData<>( + "200-1", + "결제 취소요청이 접수되었습니다.", + paymentDto + ); + } + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java b/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java new file mode 100644 index 0000000..6d3ee88 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/controller/WebhookController.java @@ -0,0 +1,40 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.payments.service.PaymentService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +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; + +import java.util.Map; + +@RestController +@RequestMapping("/api/webhooks") +@RequiredArgsConstructor +@Slf4j +public class WebhookController { + + private final PaymentService paymentService; + + // Toss Payments가 보내주는 Webhook 요청을 처리하는 엔드포인트 + @PostMapping("/toss") + public ResponseEntity handleTossWebhook(@RequestBody Map payload) { + log.info("Toss Payments 웹훅 수신: {}", payload); + + // Webhook 페이로드에서 이벤트 타입과 주문 ID 추출 + String eventType = (String) payload.get("eventType"); + Map data = (Map) payload.get("data"); + String orderId = (String) data.get("orderId"); + + // "결제 성공" 이벤트일 때만 처리 + if ("PAYMENT_CONFIRMED".equalsIgnoreCase(eventType)) { + paymentService.syncPaymentStatus(orderId); + } + + // PG사에게 정상적으로 수신했음을 알림 (HTTP 200 OK) + return ResponseEntity.ok().build(); + } +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java new file mode 100644 index 0000000..9a6cec0 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentDto.java @@ -0,0 +1,33 @@ +package com.Catch_Course.domain.payments.dto; + +import com.Catch_Course.domain.payments.entity.Payment; +import com.fasterxml.jackson.annotation.JsonProperty; +import lombok.Getter; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +@Getter +@NoArgsConstructor +public class PaymentDto { + private long reservationId; + private String courseTitle; + private String instructor; + private String orderId; + private long amount; + private String paymentKey; + private String status; + @JsonProperty("createdDatetime") + private LocalDateTime createdDate; + + public PaymentDto(Payment payment) { + this.reservationId = payment.getReservation().getId(); + this.courseTitle = payment.getReservation().getCourse().getTitle(); + this.instructor = payment.getReservation().getCourse().getInstructor().getNickname(); + this.orderId = payment.getMerchantUid(); + this.amount = payment.getAmount(); + this.paymentKey = payment.getPaymentKey(); + this.status = payment.getStatus().toString(); + this.createdDate = payment.getCreatedDate(); + } +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java new file mode 100644 index 0000000..4361b52 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/dto/PaymentRequest.java @@ -0,0 +1,19 @@ +package com.Catch_Course.domain.payments.dto; + +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class PaymentRequest { + private String paymentKey; + private String orderId; + private Long amount; + + public PaymentRequest(String paymentKey, String orderId, Long amount) { + this.paymentKey = paymentKey; + this.orderId = orderId; + this.amount = amount; + } + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java new file mode 100644 index 0000000..b3ab57d --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/CancelHistory.java @@ -0,0 +1,26 @@ +package com.Catch_Course.domain.payments.entity; + +import com.Catch_Course.global.entity.BaseTime; +import jakarta.persistence.Entity; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder +public class CancelHistory extends BaseTime { + private Long paymentId; + private Long reservationId; + private String orderId; + private String memberNickname; + private String courseTitle; + private Long amount; +} + + diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java new file mode 100644 index 0000000..a883a69 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/Payment.java @@ -0,0 +1,41 @@ +package com.Catch_Course.domain.payments.entity; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.global.entity.BaseTime; +import jakarta.persistence.*; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.SuperBuilder; + +@Entity +@AllArgsConstructor +@NoArgsConstructor +@Getter +@Setter +@SuperBuilder +public class Payment extends BaseTime { + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "reservation_id", nullable = false, unique = true) + private Reservation reservation; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id", nullable = false) + private Member member; + + @Column(unique = true, nullable = false) + private String merchantUid; // 가맹점 주문번호(toss payments orderId) + + @Column(nullable = false) + private long amount; // 금액 + + private String paymentKey; + + @Enumerated(EnumType.STRING) + @Column(nullable = false) + private PaymentStatus status; + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java new file mode 100644 index 0000000..717b46f --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/entity/PaymentStatus.java @@ -0,0 +1,9 @@ +package com.Catch_Course.domain.payments.entity; + +public enum PaymentStatus { + PENDING, // 결제 대기 + PAID, // 결제 완료 + CANCEL_REQUESTED, // 결제 취소 요청 + CANCELLED, // 결제 취소 + FAILED, // 결제 실패 +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java new file mode 100644 index 0000000..2855520 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/CancelHistoryRepository.java @@ -0,0 +1,10 @@ +package com.Catch_Course.domain.payments.repository; + +import com.Catch_Course.domain.payments.entity.CancelHistory; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface CancelHistoryRepository extends JpaRepository { + +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java new file mode 100644 index 0000000..ae6f090 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/repository/PaymentRepository.java @@ -0,0 +1,28 @@ +package com.Catch_Course.domain.payments.repository; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.reservation.entity.Reservation; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +@Repository +public interface PaymentRepository extends JpaRepository { + Optional findByReservation(Reservation reservation); + + Optional findByMerchantUid(String orderId); + + List findByMemberAndStatus(Member member, PaymentStatus paymentStatus); + + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 Lock, FOR UPDATE 전부 차단 + @Query("select r from Reservation r where r.id= :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); +} diff --git a/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java b/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java new file mode 100644 index 0000000..a191a0e --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/domain/payments/service/PaymentService.java @@ -0,0 +1,199 @@ +package com.Catch_Course.domain.payments.service; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.dto.PaymentDto; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import com.Catch_Course.global.kafka.producer.PaymentCancelProducer; +import com.Catch_Course.global.payment.TossPaymentsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.List; +import java.util.Optional; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +@Slf4j +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final ReservationRepository reservationRepository; + private final TossPaymentsService tossPaymentsService; + private final PaymentCancelProducer paymentCancelProducer; + private final ApplicationEventPublisher eventPublisher; + + public PaymentDto getPayment(Member member, Long reservationId) { + + // reservation 이력 조회 + Reservation reservation = reservationRepository.findByIdAndStudentAndStatus(reservationId, member, ReservationStatus.COMPLETED) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); + + Payment payment = paymentRepository.findByReservation(reservation) + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); + + return new PaymentDto(payment); + } + + public List getPayments(Member member) { + + List payments = paymentRepository.findByMemberAndStatus(member, PaymentStatus.PAID); + + if (payments.isEmpty()) { + throw new ServiceException("404-5", "결제 정보가 없습니다."); + } + + return payments.stream() + .map(PaymentDto::new) + .toList(); + } + + @Transactional + public PaymentDto requestPayment(Member member, Long reservationId) { + + Reservation reservation = reservationRepository.findByIdAndStudentAndStatusWithPessimisticLock(reservationId, member, ReservationStatus.PENDING) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); + + Optional opPayment = paymentRepository.findByReservation(reservation); + + if (opPayment.isPresent()) { + Payment payment = opPayment.get(); + if (payment.getStatus().equals(PaymentStatus.PAID)) { + throw new ServiceException("409-2", "이미 처리된 결제입니다."); + } else if (payment.getStatus().equals(PaymentStatus.CANCELLED)) { + // todo: 취소된 결제는 로그 남겨서 삭제처리 + throw new ServiceException("409-2", "이미 취소된 결제입니다."); + } else { + // 이미 테이블에 있는 경우(FAIL 이나 PENDING 상태) + payment.setStatus(PaymentStatus.PENDING); + return new PaymentDto(paymentRepository.save(payment)); + } + } + + String merchantUid = UUID.randomUUID().toString(); + long amount = reservation.getPrice(); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(member) + .merchantUid(merchantUid) + .amount(amount) + .status(PaymentStatus.PENDING) + .build(); + + return new PaymentDto(paymentRepository.save(payment)); + } + + @Transactional(noRollbackFor = ServiceException.class) + public PaymentDto confirmPayment(String paymentKey, String orderId, Long amount) { + + Payment payment = paymentRepository.findByMerchantUid(orderId) + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); + + if (payment.getStatus() != PaymentStatus.PENDING) { + throw new ServiceException("409-2", "이미 처리된 결제입니다."); + } + if (payment.getAmount() != amount) { + throw new ServiceException("400-4", "결제 금액이 일치하지 않습니다."); + } + try { + // 최종 승인 + tossPaymentsService.confirm(paymentKey, orderId, amount); + + // 상태 변경 + payment.setStatus(PaymentStatus.PAID); + payment.setPaymentKey(paymentKey); + payment.getReservation().setStatus(ReservationStatus.COMPLETED); + } catch (ServiceException e) { + // 결제 실패 + // todo: 메세지를 남겨서 후처리 + log.error("결제 승인 실패: orderId={}, reason={}", orderId, e.getMessage()); + + payment.setStatus(PaymentStatus.FAILED); + throw new ServiceException("500-1", "결제 중 알 수 없는 오류가 발생했습니다."); + } finally { + // 상태 저장 + paymentRepository.save(payment); + } + + return new PaymentDto(paymentRepository.save(payment)); + } + + @Transactional + public PaymentDto deletePaymentRequest(Member member, Long reservationId) { + // reservation 이력 조회 + Reservation reservation = reservationRepository.findByIdAndStudentAndStatusWithPessimisticLock(reservationId, member, ReservationStatus.COMPLETED) + .orElseThrow(() -> new ServiceException("404-3", "수강신청 이력이 없습니다.")); + + Payment payment = paymentRepository.findByReservation(reservation) + .orElseThrow(() -> new ServiceException("404-5", "결제 정보가 없습니다.")); + + // 이미 취소 요청 중 + if (payment.getStatus() == PaymentStatus.CANCEL_REQUESTED) { + throw new ServiceException("409-4", "이미 취소 처리중인 결제입니다."); + } else if (payment.getStatus() == PaymentStatus.CANCELLED) { + throw new ServiceException("409-2", "이미 취소된 결제입니다."); + } + + // 취소 요청 상태 + payment.setStatus(PaymentStatus.CANCEL_REQUESTED); + + // 메세지 직접 발행 대신 내부 이벤트로 발행 + String cancelReason = "고객 요청"; + PaymentCancelRequest request = new PaymentCancelRequest(payment, cancelReason, reservation, member); + eventPublisher.publishEvent(request); + + return new PaymentDto(paymentRepository.save(payment)); + } + + // deletePayment 메서드가 커밋면 메세지 발행 + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Transactional(propagation = Propagation.NOT_SUPPORTED) // 트랜잭션에서 제외 + public void handlePaymentCancelEvent(PaymentCancelRequest request) { + log.info("DB 트랜잭션 커밋 완료. Kafka 에 결제 취소 메시지를 발행. DTO: {}", request); + paymentCancelProducer.send(request); + } + + @Transactional + public void syncPaymentStatus(String orderId) { + log.info("결제 상태 동기화 시작: orderId={}", orderId); + + // 1. 우리 DB에서 해당 주문 ID를 가진 Payment를 조회 + Optional opPayment = paymentRepository.findByMerchantUid(orderId); + + if (opPayment.isEmpty()) { + log.warn("웹훅으로 수신된 주문 ID에 해당하는 결제 정보가 DB에 없습니다. orderId={}", orderId); + return; + } + + Payment payment = opPayment.get(); + + // 2. 이미 처리된 건(PAID, CANCELLED 등)이면 로직 종료 + if (payment.getStatus() != PaymentStatus.PENDING) { + log.info("이미 처리된 결제 건입니다. 동기화를 종료합니다. status={}", payment.getStatus()); + return; + } + + // 3. PENDING 상태라면, 최종적으로 '결제 완료' 상태로 변경 + // (실제로는 Toss API로 한 번 더 확인하는 로직을 추가하면 더 안전함) + log.info("PENDING 상태의 결제를 PAID로 변경합니다. paymentId={}", payment.getId()); + payment.setStatus(PaymentStatus.PAID); + payment.getReservation().setStatus(ReservationStatus.COMPLETED); + + // 변경 감지에 의해 트랜잭션 커밋 시점에 DB에 반영됨 + } +} diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java index f428863..2ad4042 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/controller/ReservationController.java @@ -45,18 +45,18 @@ public RsData reserve(@RequestParam Long courseId) { public RsData cancelReservation(@RequestParam Long courseId) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 - ReservationDto reservationDto = reservationService.cancelReserve(member, courseId); + ReservationDto reservationDto = reservationService.cancelReserveRequest(member, courseId); return new RsData<>( "200-1", - "수강 취소되었습니다.", + "수강 취소 요청이 접수되었습니다.", reservationDto ); } - @Operation(summary = "수강 목록 조회") + @Operation(summary = "수강 목록 조회(결제 완료)") @GetMapping("/me") - public RsData getReservations(@RequestParam(defaultValue = "1") int page, + public RsData getReservationsCompleted(@RequestParam(defaultValue = "1") int page, @RequestParam(defaultValue = "5") int pageSize) { Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 @@ -69,4 +69,19 @@ public RsData getReservations(@RequestParam(defaultValue = "1") int pag ); } + @Operation(summary = "수강 목록 조회(결제 대기)") + @GetMapping("/me/pending") + public RsData getReservations(@RequestParam(defaultValue = "1") int page, + @RequestParam(defaultValue = "5") int pageSize) { + Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체 + + Page reservationPage = reservationService.getReservationsPending(member, page, pageSize); + + return new RsData<>( + "200-1", + "신청 목록 조회가 완료되었습니다.", + new PageDto(reservationPage) + ); + } + } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java index 043ceed..c08a8ec 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/dto/ReservationDto.java @@ -8,20 +8,24 @@ @Getter public class ReservationDto { + private long reservationId; private long courseId; // 강의 id private String courseTitle; // 강의 제목 private long studentId; // 학생 id private String studentName; // 학생 이름 private String status; // 예약 상태 + private Long price; @JsonProperty("createdDatetime") private LocalDateTime createdDate; public ReservationDto(Reservation reservation) { + this.reservationId = reservation.getId(); this.courseId = reservation.getCourse().getId(); this.courseTitle = reservation.getCourse().getTitle(); this.studentId = reservation.getStudent().getId(); this.studentName = reservation.getStudent().getNickname(); this.status = reservation.getStatus().toString(); + this.price = reservation.getPrice(); this.createdDate = reservation.getCreatedDate(); } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java index 667d31f..2f01d29 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/Reservation.java @@ -2,6 +2,7 @@ import com.Catch_Course.domain.course.entity.Course; import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.entity.Payment; import com.Catch_Course.global.entity.BaseTime; import jakarta.persistence.*; import lombok.AllArgsConstructor; @@ -30,4 +31,17 @@ public class Reservation extends BaseTime { @Enumerated(EnumType.STRING) private ReservationStatus status; // 신청 상태 + + @OneToOne(mappedBy = "reservation", fetch = FetchType.LAZY) + private Payment payment; + + @Column(nullable = false) + private Long price; + + public Reservation(Member student, Course course, ReservationStatus status, Long price) { + this.student = student; + this.course = course; + this.status = status; + this.price = price; + } } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java index 7ec75dd..b9d7ee7 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/entity/ReservationStatus.java @@ -3,5 +3,7 @@ public enum ReservationStatus { COMPLETED, // 신청 완료 WAITING, // 대기 - FAILED // 실패 + FAILED, // 실패 + PENDING, // 결제 대기 + CANCELLED // 취소 } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java index 9f365f5..d87fccd 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/repository/ReservationRepository.java @@ -4,10 +4,14 @@ import com.Catch_Course.domain.member.entity.Member; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import jakarta.persistence.LockModeType; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import java.util.Optional; @@ -19,4 +23,25 @@ public interface ReservationRepository extends JpaRepository @EntityGraph(attributePaths = {"course", "student"}) Page findAllByStudentAndStatus(Member member, ReservationStatus reservationStatus, Pageable pageable); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @EntityGraph(attributePaths = {"course", "student", "payment"}) + @Query("select r from Reservation r where r.id = :reservationId and r.student = :member and r.status = :reservationStatus") + Optional findByIdAndStudentAndStatusWithPessimisticLock( + @Param("reservationId") Long reservationId, + @Param("member") Member member, + @Param("reservationStatus") ReservationStatus reservationStatus); + + @EntityGraph(attributePaths = {"course", "student", "payment"}) + Optional findByIdAndStudentAndStatus(Long reservationId, Member member, ReservationStatus reservationStatus); + + // Reservation 조회 시 연관된 Course도 함께 가져오는 메서드 + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("SELECT r FROM Reservation r JOIN FETCH r.course WHERE r.id = :id") + Optional findWithCourseByIdWithPessimisticLock(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) // 비관적 Lock, FOR UPDATE 전부 차단 + @Query("select r from Reservation r where r.id= :id") + Optional findByIdWithPessimisticLock(@Param("id") Long id); + } diff --git a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java index 12c1986..e0b4e5c 100644 --- a/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java +++ b/backend/src/main/java/com/Catch_Course/domain/reservation/service/ReservationService.java @@ -6,16 +6,16 @@ import com.Catch_Course.domain.member.repository.MemberRepository; import com.Catch_Course.domain.notification.dto.NotificationDto; import com.Catch_Course.domain.notification.service.NotificationService; +import com.Catch_Course.domain.payments.service.PaymentService; import com.Catch_Course.domain.reservation.dto.ReservationDto; -import com.Catch_Course.domain.reservation.entity.DeletedHistory; import com.Catch_Course.domain.reservation.entity.Reservation; import com.Catch_Course.domain.reservation.entity.ReservationStatus; -import com.Catch_Course.domain.reservation.repository.DeleteHistoryRepository; import com.Catch_Course.domain.reservation.repository.ReservationRepository; import com.Catch_Course.global.exception.ServiceException; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; import com.Catch_Course.global.kafka.dto.ReservationRequest; -import com.Catch_Course.global.kafka.producer.ReservationDeletedProducer; +import com.Catch_Course.global.kafka.producer.RedisExpirationProducer; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; import com.Catch_Course.global.kafka.producer.ReservationProducer; import com.Catch_Course.global.sse.service.SseService; import lombok.RequiredArgsConstructor; @@ -23,10 +23,12 @@ import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.util.Optional; +import java.util.concurrent.TimeUnit; @Service @RequiredArgsConstructor @@ -38,10 +40,13 @@ public class ReservationService { private final ReservationRepository reservationRepository; private final MemberRepository memberRepository; private final ReservationProducer reservationProducer; - private final ReservationDeletedProducer reservationDeletedProducer; - private final DeleteHistoryRepository deleteHistoryRepository; + private final ReservationCancelProducer reservationCancelProducer; private final NotificationService notificationService; private final SseService sseService; + private final PaymentService paymentService; + private final RedisTemplate redisTemplate; + private final RedisExpirationProducer redisExpirationProducer; + private final int TIME_LIMIT = 30; public Reservation addToQueue(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 비관적 Lock 을 걸고 조회 @@ -49,7 +54,7 @@ public Reservation addToQueue(Member member, Long courseId) { Optional optionalReservation = reservationRepository.findByStudentAndCourse(member, course); if (optionalReservation.isPresent()) { - handleDuplicateReservation(optionalReservation.get()); + handleDuplicateReservation(optionalReservation.get(), member, courseId); return optionalReservation.get(); } @@ -58,6 +63,7 @@ public Reservation addToQueue(Member member, Long courseId) { .student(member) .course(course) .status(ReservationStatus.WAITING) + .price(course.getPrice()) .build(); // 메세지 전송 @@ -65,17 +71,22 @@ public Reservation addToQueue(Member member, Long courseId) { return reservationRepository.save(reservation); } - private void handleDuplicateReservation(Reservation reservation) { + private void handleDuplicateReservation(Reservation reservation, Member member, Long courseId) { ReservationStatus status = reservation.getStatus(); - if (status.equals(ReservationStatus.COMPLETED)) { + if (status.equals(ReservationStatus.COMPLETED) || status.equals(ReservationStatus.PENDING)) { throw new ServiceException("409-1", "이미 신청한 강의입니다."); } else if (status.equals(ReservationStatus.WAITING)) { throw new ServiceException("409-3", "이미 대기열에 등록된 신청입니다."); + } else { + // 대기열 등록 + reservation.setStatus(ReservationStatus.WAITING); + reservationProducer.send(String.valueOf(courseId), new ReservationRequest(member.getId(), courseId)); + reservationRepository.save(reservation); } } - public ReservationDto cancelReserve(Member member, Long courseId) { + public ReservationDto cancelReserveRequest(Member member, Long courseId) { Course course = courseRepository.findByIdWithPessimisticLock(courseId) // 잠금 .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); @@ -83,16 +94,20 @@ public ReservationDto cancelReserve(Member member, Long courseId) { Reservation reservation = reservationRepository.findByStudentAndCourse(member, course) .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); - ReservationDto reservationDto = new ReservationDto(reservation); - - course.decreaseReservation(); - courseRepository.save(course); - - // 수강 취소는 메세지를 남겨서 저장 - reservationDeletedProducer.send(new ReservationDeletedRequest(member.getId(), courseId)); - reservationRepository.delete(reservation); + // 결제 취소 + if (reservation.getStatus().equals(ReservationStatus.COMPLETED)) { + log.info("결제 완료 건에 대한 취소 요청 접수. paymentId: {}", reservation.getPayment().getId()); + paymentService.deletePaymentRequest(reservation.getStudent(), reservation.getId()); + }else if (reservation.getStatus().equals(ReservationStatus.CANCELLED)) { + throw new ServiceException("409-5", "이미 취소된 신청입니다."); + } else if (reservation.getStatus().equals(ReservationStatus.PENDING)) { + // 수강 취소는 메세지를 남겨서 저장 + reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), member.getId(), courseId)); + } else { + throw new ServiceException("409-6", "취소할 수 없는 신청입니다."); + } - return reservationDto; + return new ReservationDto(reservation); } @Transactional(readOnly = true) @@ -108,11 +123,24 @@ public Page getReservations(Member member, int page, int pageSize) return reservations; } + @Transactional(readOnly = true) + public Page getReservationsPending(Member member, int page, int pageSize) { + Pageable pageable = PageRequest.of(page - 1, pageSize); + + Page reservations = reservationRepository.findAllByStudentAndStatus(member, ReservationStatus.PENDING, pageable); + + if (reservations.isEmpty()) { + throw new ServiceException("404-3", "수강신청 이력이 없습니다."); + } + + return reservations; + } + /** * Kafka Consumer에 의해 호출 될 실제 수강 신청 처리 메서드 */ public void processReservation(Long courseId, Long memberId) { - try{ + try { // 락을 걸어 강의 정보 조회(동시성 제어) Course course = courseRepository.findByIdWithPessimisticLock(courseId) .orElseThrow(() -> new ServiceException("404-1", "존재하지 않는 강의입니다.")); @@ -126,9 +154,9 @@ public void processReservation(Long courseId, Long memberId) { if (course.isFull()) { reservation.setStatus(ReservationStatus.FAILED); reservationRepository.save(reservation); - NotificationDto notificationDto = new NotificationDto(reservation,"수강 신청 실패: 정원이 마감되었습니다."); + NotificationDto notificationDto = new NotificationDto(reservation, "수강 신청 실패: 정원이 마감되었습니다."); notificationService.saveNotification(memberId, notificationDto); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); return; } @@ -136,23 +164,40 @@ public void processReservation(Long courseId, Long memberId) { course.increaseReservation(); courseRepository.save(course); - reservation.setStatus(ReservationStatus.COMPLETED); - NotificationDto notificationDto = new NotificationDto(reservation,"수강 신청이 성공하였습니다."); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + reservation.setStatus(ReservationStatus.PENDING); + + // Redis에 만료 타이머 등록 + String expirationKey = "reservation:expire:" + reservation.getId(); + redisTemplate.opsForValue().set(expirationKey, "", TIME_LIMIT, TimeUnit.MINUTES); + log.info("Redis 만료 타이머 등록. Key: {}, 만료 시간: {} 분", expirationKey, TIME_LIMIT); + + NotificationDto notificationDto = new NotificationDto(reservation, "수강 신청이 성공하였습니다."); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); + notificationService.saveNotification(memberId, notificationDto); reservationRepository.save(reservation); - }catch (ServiceException e) { - NotificationDto notificationDto = new NotificationDto(ReservationStatus.FAILED,"수강 신청 처리 중 오류가 발생했습니다. "+ e.getMessage()); - sseService.sendToClient(memberId,"ReservationResult", notificationDto); + } catch (ServiceException e) { + NotificationDto notificationDto = new NotificationDto(ReservationStatus.FAILED, "수강 신청 처리 중 오류가 발생했습니다. " + e.getMessage()); + sseService.sendToClient(memberId, "ReservationResult", notificationDto); } } - public void saveDeleteHistory(Long memberId, Long courseId) { - // 기록으로 남김 - deleteHistoryRepository.save(DeletedHistory.builder() - .memberId(memberId) - .courseId(courseId) - .build() - ); + public void expireReservation(Long reservationId) { + log.info("만료 처리 서비스 시작. 예약 ID: {}", reservationId); + try { + Optional reservation = reservationRepository.findByIdWithPessimisticLock(reservationId); + + if(reservation.isPresent()) { + Long memberId = reservation.get().getStudent().getId(); + Long courseId = reservation.get().getCourse().getId(); + redisExpirationProducer.send(new ReservationCancelRequest(reservationId, memberId, courseId)); + log.info("만료 처리 Kafka 메시지 전송 성공. 예약 ID: {}", reservationId); + } + log.warn("만료 처리 시점에 예약을 찾을 수 없음. ID: {}", reservationId); + + } catch (Exception e) { + log.error("만료 처리 중 예외 발생. 예약 ID: {}", reservationId, e); + } } + } diff --git a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java index 4c62ea0..e2fb318 100644 --- a/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java +++ b/backend/src/main/java/com/Catch_Course/global/init/BaseInitData.java @@ -55,15 +55,15 @@ public void courseInit() { Member user2 = memberService.findByUsername("user2").get(); - courseService.write(user2, "수학 강의", "일반 수학 기초", 50); - courseService.write(user2, "국어 강의", "비문학 기초", 100); - courseService.write(user2, "인기 폭발 강의", "늦으면 없다", 1); + courseService.write(user2, "수학 강의", "일반 수학 기초", 50,10000); + courseService.write(user2, "국어 강의", "비문학 기초", 100,20000); + courseService.write(user2, "인기 폭발 강의", "늦으면 없다", 1,5000); for (int i = 2; i < 50; i++) { int userId = (i % 5) + 2; Member tempUser = memberService.findByUsername("user%d".formatted(userId)).get(); - courseService.write(tempUser, "강의 제목 %d".formatted(i), "강의 내용 %d".formatted(i), 50); + courseService.write(tempUser, "강의 제목 %d".formatted(i), "강의 내용 %d".formatted(i), 50,1000 * i); } - courseService.write(user2, "인기 폭발123 강의", "늦으면 없213다", 1); + courseService.write(user2, "인기 폭발123 강의", "늦으면 없213다", 1,500000); } } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java new file mode 100644 index 0000000..2cb145d --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/KafkaConsumerConfig.java @@ -0,0 +1,33 @@ +package com.Catch_Course.global.kafka.consumer; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.kafka.config.ConcurrentKafkaListenerContainerFactory; +import org.springframework.kafka.core.ConsumerFactory; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.listener.DeadLetterPublishingRecoverer; +import org.springframework.kafka.listener.DefaultErrorHandler; +import org.springframework.util.backoff.FixedBackOff; + +@Configuration +@EnableKafka +public class KafkaConsumerConfig { + @Bean + public ConcurrentKafkaListenerContainerFactory kafkaListenerContainerFactory( + ConsumerFactory consumerFactory, + KafkaTemplate kafkaTemplate + ) { + ConcurrentKafkaListenerContainerFactory factory = new ConcurrentKafkaListenerContainerFactory<>(); + factory.setConsumerFactory(consumerFactory); + + // 10초 간격으로 최대 3번 재시도, 모두 실패하면 DLT로 메시지 전송 + DefaultErrorHandler errorHandler = new DefaultErrorHandler( + new DeadLetterPublishingRecoverer(kafkaTemplate), + new FixedBackOff(10000L, 2) // (10초 간격, 최대 2번 + 최초 1번 = 총 3번 시도) + ); + factory.setCommonErrorHandler(errorHandler); + + return factory; + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java deleted file mode 100644 index c13d40e..0000000 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/MyErrorHandler.java +++ /dev/null @@ -1,18 +0,0 @@ -package com.Catch_Course.global.kafka.consumer; - -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.listener.KafkaListenerErrorHandler; -import org.springframework.kafka.listener.ListenerExecutionFailedException; -import org.springframework.messaging.Message; -import org.springframework.stereotype.Component; - -@Component -@Slf4j -public class MyErrorHandler implements KafkaListenerErrorHandler { - - @Override - public Object handleError(Message message, ListenerExecutionFailedException exception) { - log.error("Error processing message: {}", message, exception); - return null; // 예외를 처리했으니 다음 메시지를 소비하도록 합니다. - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java new file mode 100644 index 0000000..fd9e695 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/PaymentCancelConsumer.java @@ -0,0 +1,86 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.member.repository.MemberRepository; +import com.Catch_Course.domain.payments.entity.CancelHistory; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.CancelHistoryRepository; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; +import com.Catch_Course.global.payment.TossPaymentsService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentCancelConsumer { + private final TossPaymentsService tossPaymentsService; + private final PaymentRepository paymentRepository; + private final ReservationRepository reservationRepository; + private final MemberRepository memberRepository; + private final CancelHistoryRepository cancelHistoryRepository; + private final ReservationCancelProducer reservationCancelProducer; + // 구독 + @KafkaListener(topics = "payment_cancel", groupId = "course") + @Transactional + public void consume(PaymentCancelRequest paymentCancelRequest) { + log.info("결제 취소 요청 처리 시작: {}", paymentCancelRequest); + + Payment payment = paymentRepository.findByIdWithPessimisticLock(paymentCancelRequest.getPaymentId()) + .orElseThrow(() -> new ServiceException("404-5","결제 정보가 존재하지 않습니다.")); + + if(payment.getStatus() == PaymentStatus.CANCELLED) { + log.warn("이미 취소 처리된 결제입니다. 중복 처리를 방지합니다. paymentId: {}", paymentCancelRequest.getPaymentId()); + return; + } + + // 외부 API 요청 + tossPaymentsService.cancel(payment.getPaymentKey(),paymentCancelRequest.getCancelReason()); + + // DB 후처리 로직 + cancelProcess(payment,paymentCancelRequest); + } + + public void cancelProcess(Payment payment,PaymentCancelRequest paymentCancelRequest) { + + Reservation reservation = reservationRepository.findWithCourseByIdWithPessimisticLock(paymentCancelRequest.getReservationId()) + .orElseThrow(() -> new ServiceException("404-3","수강신청 이력이 없습니다.")); + + Member member = memberRepository.findById(paymentCancelRequest.getMemberId()) + .orElseThrow(() -> new ServiceException("404-4","회원을 찾을 수 없습니다.")); + + // 상태 변경 + payment.setStatus(PaymentStatus.CANCELLED); + paymentRepository.save(payment); + + // 예약 취소 메세지 전송 + reservationCancelProducer.send(new ReservationCancelRequest(reservation.getId(), member.getId(), paymentCancelRequest.getCourseId())); + + // 결제 취소 이력 저장 + saveCancelHistory(payment, reservation, member); + + log.info("결제 취소 DB 후속 처리 완료: paymentId={}", paymentCancelRequest.getPaymentId()); + } + + public void saveCancelHistory(Payment payment, Reservation reservation, Member member) { + + cancelHistoryRepository.save(CancelHistory.builder() + .paymentId(payment.getId()) + .reservationId(reservation.getId()) + .orderId(payment.getMerchantUid()) + .memberNickname(member.getNickname()) + .courseTitle(reservation.getCourse().getTitle()) + .amount(payment.getAmount()) + .build()); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java new file mode 100644 index 0000000..834eb11 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/RedisExpirationConsumer.java @@ -0,0 +1,62 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class RedisExpirationConsumer { + + private final ReservationRepository reservationRepository; + private final CourseRepository courseRepository; + + // 구독 + @KafkaListener(topics = "course-reservation-expired", groupId = "course") + @Transactional + public void consume(ReservationCancelRequest reservationCancelRequest) { + + log.info("결제 기한 만료 처리 시작: {}", reservationCancelRequest); + try { + // 실제 로직 호출 + Optional optionalReservation = reservationRepository.findByIdWithPessimisticLock(reservationCancelRequest.getReservationId()); + + if (optionalReservation.isEmpty()) { + log.warn("취소할 예약을 찾을 수 없습니다. ID: {}", reservationCancelRequest.getReservationId()); + return; + } + + Reservation reservation = optionalReservation.get(); + if (!reservation.getStatus().equals(ReservationStatus.PENDING)) { + log.info("예약 ID {}는 PENDING 상태가 아니므로 취소하지 않습니다. 현재 상태: {}", reservation.getId(), reservation.getStatus()); + return; + } + + cancelProcess(reservation, reservation.getCourse()); + log.info("{}의 수강 취소가 성공적으로 처리되었습니다.", reservationCancelRequest.getCourseId()); + } catch (Exception e) { + log.error("Error Request: {}", reservationCancelRequest, e); + } + } + + private void cancelProcess(Reservation reservation, Course course) { + // 현재 인원 감소 + course.decreaseReservation(); + courseRepository.save(course); + + // 상태 변경 + reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java new file mode 100644 index 0000000..673ba5f --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationCancelConsumer.java @@ -0,0 +1,69 @@ +package com.Catch_Course.global.kafka.consumer; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.reservation.entity.DeletedHistory; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.DeleteHistoryRepository; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ReservationCancelConsumer { + + private final DeleteHistoryRepository deleteHistoryRepository; + private final ReservationRepository reservationRepository; + private final CourseRepository courseRepository; + + // 구독 + @KafkaListener(topics = "course-reservation-deleted", groupId = "course") + @Transactional + public void consume(ReservationCancelRequest reservationCancelRequest) { + + log.info("수강 취소 처리 시작: {}", reservationCancelRequest); + try { + // 실제 로직 호출 + Reservation reservation = reservationRepository.findByIdWithPessimisticLock(reservationCancelRequest.getReservationId()) + .orElseThrow(() -> new ServiceException("404-3", "수강 신청 이력이 없습니다.")); + + if (reservation.getStatus() == ReservationStatus.CANCELLED) { + log.warn("이미 취소된 예약입니다: {}", reservation.getId()); + return; + } + + cancelProcess(reservation, reservation.getCourse()); + saveDeleteHistory(reservationCancelRequest.getMemberId(), reservationCancelRequest.getCourseId()); + log.info("{}의 수강 취소 이력이 성공적으로 처리되었습니다.", reservationCancelRequest.getCourseId()); + } catch (Exception e){ + log.error("Error Request: {}", reservationCancelRequest, e); + } + } + + private void cancelProcess(Reservation reservation, Course course) { + // 현재 인원 감소 + course.decreaseReservation(); + courseRepository.save(course); + + // 상태 변경 + reservation.setStatus(ReservationStatus.CANCELLED); + reservationRepository.save(reservation); + } + + public void saveDeleteHistory(Long memberId, Long courseId) { + // 기록으로 남김 + deleteHistoryRepository.save(DeletedHistory.builder() + .memberId(memberId) + .courseId(courseId) + .build() + ); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java index b35918e..365360d 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationConsumer.java @@ -6,6 +6,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; @Slf4j @Component @@ -15,9 +16,10 @@ public class ReservationConsumer { private final ReservationService reservationService; // 구독 - @KafkaListener(topics = "course-reservation", groupId = "course", errorHandler = "myErrorHandler") + @KafkaListener(topics = "course-reservation", groupId = "course") + @Transactional public void consume(ReservationRequest reservationRequest) { - log.info("Consuming reservation request: {}", reservationRequest); + log.info("수강신청 처리 시작 : {}", reservationRequest); reservationService.processReservation(reservationRequest.getCourseId(),reservationRequest.getMemberId()); } } \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java b/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java deleted file mode 100644 index d83c5dc..0000000 --- a/backend/src/main/java/com/Catch_Course/global/kafka/consumer/ReservationDeletedConsumer.java +++ /dev/null @@ -1,31 +0,0 @@ -package com.Catch_Course.global.kafka.consumer; - -import com.Catch_Course.domain.reservation.service.ReservationService; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.stereotype.Component; - -@Slf4j -@Component -@RequiredArgsConstructor -public class ReservationDeletedConsumer { - - private final ReservationService reservationService; - - // 구독 - @KafkaListener(topics = "course-reservation-deleted", groupId = "course") - public void consume(ReservationDeletedRequest reservationDeletedRequest) { - - log.info("Consuming reservation request: {}", reservationDeletedRequest); - try { - // 실제 로직 호출 - reservationService.saveDeleteHistory(reservationDeletedRequest.getMemberId(), reservationDeletedRequest.getCourseId()); - log.info("{}의 수강 취소 이력이 성공적으로 처리되었습니다.", reservationDeletedRequest.getCourseId()); - } catch (Exception e){ - log.error("Error Request: {}", reservationDeletedRequest, e); - } - - } -} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java new file mode 100644 index 0000000..49819c2 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/dto/PaymentCancelRequest.java @@ -0,0 +1,29 @@ +package com.Catch_Course.global.kafka.dto; + +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.reservation.entity.Reservation; +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class PaymentCancelRequest { + private Long paymentId; + private String paymentKey; + private String cancelReason; + private Long reservationId; + private Long memberId; + private Long courseId; + + public PaymentCancelRequest(Payment payment, String cancelReason, Reservation reservation, Member member) { + this.paymentId = payment.getId(); + this.paymentKey = payment.getPaymentKey(); + this.cancelReason = cancelReason; + this.reservationId = reservation.getId(); + this.memberId = member.getId(); + this.courseId = reservation.getCourse().getId(); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java b/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java similarity index 76% rename from backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java rename to backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java index 2eab40b..0211b19 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationDeletedRequest.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/dto/ReservationCancelRequest.java @@ -7,7 +7,8 @@ @Getter @NoArgsConstructor @AllArgsConstructor -public class ReservationDeletedRequest { +public class ReservationCancelRequest { + private Long reservationId; private Long memberId; private Long courseId; } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java new file mode 100644 index 0000000..2b2a8c1 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/PaymentCancelProducer.java @@ -0,0 +1,22 @@ +package com.Catch_Course.global.kafka.producer; + +import com.Catch_Course.global.kafka.dto.PaymentCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class PaymentCancelProducer { + + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "payment_cancel"; + + public void send(PaymentCancelRequest paymentCancelRequest) { + String key = String.valueOf(paymentCancelRequest.getPaymentId()); + log.info("결제 취소 요청 메세지 전송: {}", paymentCancelRequest); + kafkaTemplate.send(TOPIC, key, paymentCancelRequest); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java new file mode 100644 index 0000000..c9ae3fe --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/RedisExpirationProducer.java @@ -0,0 +1,21 @@ +package com.Catch_Course.global.kafka.producer; + +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Component; + +@Component +@Slf4j +@RequiredArgsConstructor +public class RedisExpirationProducer { + + private final KafkaTemplate kafkaTemplate; + private static final String TOPIC = "course-reservation-expired"; + + public void send(ReservationCancelRequest request) { + log.info("결제 기한 만료로 인한 수강 취소 요청 메세지 전송: {}", request); + kafkaTemplate.send(TOPIC, request); + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java similarity index 59% rename from backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java rename to backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java index d0a9f4d..aa2e405 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationDeletedProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationCancelProducer.java @@ -1,6 +1,6 @@ package com.Catch_Course.global.kafka.producer; -import com.Catch_Course.global.kafka.dto.ReservationDeletedRequest; +import com.Catch_Course.global.kafka.dto.ReservationCancelRequest; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; @@ -9,15 +9,15 @@ @Component @Slf4j @RequiredArgsConstructor -public class ReservationDeletedProducer { +public class ReservationCancelProducer { // 수강 취소하는 이력 메세지로 저장하여 관리 - private final KafkaTemplate kafkaTemplate; + private final KafkaTemplate kafkaTemplate; private static final String TOPIC = "course-reservation-deleted"; - public void send(ReservationDeletedRequest request) { - log.info("Sending reservation deleted request: {}", request); + public void send(ReservationCancelRequest request) { + log.info("수강 취소 요청 메세지 전송: {}", request); kafkaTemplate.send(TOPIC, request); } diff --git a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java index eb7e4ee..1fee99d 100644 --- a/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java +++ b/backend/src/main/java/com/Catch_Course/global/kafka/producer/ReservationProducer.java @@ -15,7 +15,7 @@ public class ReservationProducer { private static final String TOPIC = "course-reservation"; public void send(String key, ReservationRequest reservationRequest) { - log.info("Sending reservation request: {}", reservationRequest); + log.info("수강신청 요청 메세지 전송: {}", reservationRequest); kafkaTemplate.send(TOPIC,key, reservationRequest); } } \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java new file mode 100644 index 0000000..ff06d4e --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/payment/TossPaymentsService.java @@ -0,0 +1,89 @@ +package com.Catch_Course.global.payment; + +import com.Catch_Course.global.exception.ServiceException; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpEntity; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Service; +import org.springframework.web.client.HttpClientErrorException; +import org.springframework.web.client.RestTemplate; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; +import java.util.Collections; +import java.util.Map; + +@Slf4j +@Service +public class TossPaymentsService { + + private final String secretKey; + private final RestTemplate restTemplate; + private static final String TOSS_CONFIRM_URL = "https://api.tosspayments.com/v1/payments/confirm"; + + public TossPaymentsService(@Value("${custom.toss.payment.secret}") String secretKey, RestTemplate restTemplate) { + this.secretKey = secretKey; + this.restTemplate = restTemplate; + } + + public void confirm(String paymentKey, String orderId, Long amount) { + log.info("Toss Payments 결제 승인 요청 시작: orderId={}", orderId); + + HttpHeaders headers = createHeaders(); + Map requestBody = Map.of( + "paymentKey", paymentKey, + "orderId", orderId, + "amount", amount + ); + + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + try { + // [추가] API 호출 직전, 실제 전송되는 헤더 값을 로그로 확인합니다. + log.info("전송될 Authorization 헤더: {}", headers.getFirst("Authorization")); + + // API 호출 + restTemplate.postForObject(TOSS_CONFIRM_URL, requestEntity, Map.class); + log.info("Toss Payments 결제 승인 성공: orderId={}", orderId); + } catch (Exception e) { + log.error("Toss Payments API 연동 실패: {}", e.getMessage()); + throw new ServiceException("400-5", "결제 승인 중 오류가 발생했습니다. 잠시 후 다시 시도해주세요."); + } + } + + private HttpHeaders createHeaders() { + + HttpHeaders headers = new HttpHeaders(); + // Base64 인코딩 시, 생성자에서 주입받은 final secretKey를 사용합니다. + String encodedKey = Base64.getEncoder().encodeToString((this.secretKey.trim() + ":").getBytes(StandardCharsets.UTF_8)); + headers.set("Authorization", "Basic " + encodedKey); + headers.setContentType(MediaType.APPLICATION_JSON); + headers.setAccept(Collections.singletonList(MediaType.APPLICATION_JSON)); + return headers; + } + + + public void cancel(String paymentKey, String cancelReason) { + + log.info("Toss Payments 결제 취소 요청 시작: paymentKey={}", paymentKey); + + // 1. Toss Payments 취소 API URL + String url = "https://api.tosspayments.com/v1/payments/" + paymentKey + "/cancel"; + HttpHeaders headers = createHeaders(); + Map requestBody = Map.of("cancelReason", cancelReason); + HttpEntity> requestEntity = new HttpEntity<>(requestBody, headers); + + try { + restTemplate.postForObject(url, requestEntity, Map.class); + log.info("Toss Payments 결제 취소 성공: paymentKey={}", paymentKey); + } catch (HttpClientErrorException e) { + log.error("Toss Payments API 연동 실패 (HTTP Status: {}): {}", e.getStatusCode(), e.getResponseBodyAsString()); + throw new ServiceException("400-6", "결제 취소 중 오류가 발생했습니다."); + } catch (Exception e) { + log.error("Toss Payments API 연동 중 알 수 없는 오류 발생: {}", e.getMessage()); + throw new ServiceException("500-1", "결제 취소 중 시스템 오류가 발생했습니다."); + } + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java b/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java index f3e2e8d..5a78829 100644 --- a/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java +++ b/backend/src/main/java/com/Catch_Course/global/redis/RedisConfig.java @@ -11,6 +11,8 @@ import org.springframework.data.redis.connection.RedisStandaloneConfiguration; import org.springframework.data.redis.connection.lettuce.LettuceConnectionFactory; import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.listener.PatternTopic; +import org.springframework.data.redis.listener.RedisMessageListenerContainer; import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; import org.springframework.data.redis.serializer.StringRedisSerializer; @@ -54,4 +56,14 @@ public RedisTemplate redisTemplate(RedisConnectionFactory connec return redisTemplate; } + @Bean + public RedisMessageListenerContainer redisMessageListenerContainer( + RedisConnectionFactory connectionFactory, + RedisExpirationListener expirationListener // 리스너 Bean을 주입 + ) { + RedisMessageListenerContainer container = new RedisMessageListenerContainer(); + container.setConnectionFactory(connectionFactory); + container.addMessageListener(expirationListener, new PatternTopic("__keyevent@*__:expired")); + return container; + } } \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java new file mode 100644 index 0000000..6dc4cf7 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/redis/RedisExpirationListener.java @@ -0,0 +1,35 @@ +package com.Catch_Course.global.redis; + +import com.Catch_Course.domain.reservation.service.ReservationService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.connection.Message; +import org.springframework.data.redis.connection.MessageListener; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +@Slf4j +public class RedisExpirationListener implements MessageListener { + + private final ReservationService reservationService; + + @Override + public void onMessage(Message message, byte[] pattern) { + + String expiredKey = message.toString(); + log.info("Redis Key 만료 감지: {}", expiredKey); + + if(expiredKey.startsWith("reservation:expire:")) { + String[] params = expiredKey.split(":"); + if(params.length == 3) { + try { + Long reservationId = Long.parseLong(params[2]); + reservationService.expireReservation(reservationId); + } catch (NumberFormatException e){ + log.error("만료된 Redis 키에서 예약 ID를 파싱하는 데 실패했습니다: {}", expiredKey, e); + } + } + } + } +} diff --git a/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java b/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java new file mode 100644 index 0000000..1a46263 --- /dev/null +++ b/backend/src/main/java/com/Catch_Course/global/restClient/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.Catch_Course.global.restClient; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java b/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java index 9b51630..995cbc5 100644 --- a/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java +++ b/backend/src/main/java/com/Catch_Course/global/security/CustomOauth2UserService.java @@ -61,8 +61,7 @@ private SocialInfo extractUserInfo(String providerType, String oauthId, Map authorizeHttpRequests // swagger,h2 허용 - .requestMatchers("/swagger-ui/**", "/h2-console/**", "/v3/api-docs/**") + .requestMatchers("/swagger-ui/**", "/h2-console/**", "/v3/api-docs/**","/actuator/prometheus") .permitAll() .requestMatchers("/api/courses/statistics") .hasRole("ADMIN") diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index b906264..ebfbab4 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -1,3 +1,4 @@ +# application-dev.yml spring: datasource: url: jdbc:h2:./db_dev;MODE=MySQL @@ -21,3 +22,17 @@ logging: org.hibernate.orm.jdbc.bind: TRACE org.hibernate.orm.jdbc.extract: TRACE org.springframework.transaction.interceptor: TRACE + org.springframework.data.redis: DEBUG + io.lettuce.core: DEBUG + +management: + endpoints: + web: + exposure: + include: health, info, prometheus + endpoint: + prometheus: + enabled: true + metrics: + tags: + application: catch-course-api \ No newline at end of file diff --git a/backend/src/main/resources/application-secret.yml.default b/backend/src/main/resources/application-secret.yml.default index 76ef0ee..85c3321 100644 --- a/backend/src/main/resources/application-secret.yml.default +++ b/backend/src/main/resources/application-secret.yml.default @@ -14,6 +14,7 @@ spring: google: client-id: client-secret: + mail: username: password: @@ -21,3 +22,10 @@ spring: custom: jwt: secret-key: + expire-seconds: + refresh-expire-seconds: + + toss: + payment: + client: + secret: \ No newline at end of file diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index ed099cd..d28e72f 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -1,3 +1,4 @@ +# application.yml server: port: 8080 @@ -67,11 +68,6 @@ spring: spring.json.trusted.packages: "*" auto-offset-reset: earliest -custom: - jwt: - expire-seconds: "#{60 * 60}" - refresh-expire-seconds: "#{60 * 60 * 24 * 7}" - app: registration-time: start: "09:00" diff --git a/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java index 851f9d9..37badf6 100644 --- a/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/course/controller/CourseControllerTest.java @@ -386,8 +386,8 @@ void modifyItem3() throws Exception { .andExpect(jsonPath("$.msg").value("존재하지 않는 강의입니다.")); } - private ResultActions writeRequest(String title, String content, long capacity) throws Exception { - Map requestBody = Map.of("title", title, "content", content, "capacity", capacity); + private ResultActions writeRequest(String title, String content, long capacity, long price) throws Exception { + Map requestBody = Map.of("title", title, "content", content, "capacity", capacity, "price", price); // Map -> Json 변환 ObjectMapper objectMapper = new ObjectMapper(); @@ -408,8 +408,9 @@ void write() throws Exception { String title = "테스트 제목"; String content = "테스트 내용"; long capacity = 100; + long price = 10000; - ResultActions resultActions = writeRequest(title, content, capacity); + ResultActions resultActions = writeRequest(title, content, capacity, price); resultActions .andExpect(status().isOk()) diff --git a/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java new file mode 100644 index 0000000..d525fee --- /dev/null +++ b/backend/src/test/java/com/Catch_Course/domain/payments/controller/PaymentControllerTest.java @@ -0,0 +1,437 @@ +package com.Catch_Course.domain.payments.controller; + +import com.Catch_Course.domain.course.entity.Course; +import com.Catch_Course.domain.course.repository.CourseRepository; +import com.Catch_Course.domain.member.entity.Member; +import com.Catch_Course.domain.member.service.MemberService; +import com.Catch_Course.domain.payments.entity.Payment; +import com.Catch_Course.domain.payments.entity.PaymentStatus; +import com.Catch_Course.domain.payments.repository.PaymentRepository; +import com.Catch_Course.domain.reservation.entity.Reservation; +import com.Catch_Course.domain.reservation.entity.ReservationStatus; +import com.Catch_Course.domain.reservation.repository.ReservationRepository; +import com.Catch_Course.global.exception.ServiceException; +import com.Catch_Course.global.kafka.producer.ReservationCancelProducer; +import com.Catch_Course.global.payment.TossPaymentsService; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.http.MediaType; +import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.context.DynamicPropertyRegistry; +import org.springframework.test.context.DynamicPropertySource; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.ResultActions; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; +import org.testcontainers.containers.GenericContainer; +import org.testcontainers.containers.KafkaContainer; +import org.testcontainers.containers.wait.strategy.Wait; +import org.testcontainers.junit.jupiter.Container; +import org.testcontainers.junit.jupiter.Testcontainers; +import org.testcontainers.shaded.org.awaitility.Awaitility; +import org.testcontainers.utility.DockerImageName; + +import java.nio.charset.StandardCharsets; +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; +import java.util.function.Supplier; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.*; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*; +import static org.springframework.test.web.servlet.result.MockMvcResultHandlers.print; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@EnableAsync +@SpringBootTest +@ActiveProfiles("test") +@AutoConfigureMockMvc +@Testcontainers +@Transactional(propagation = Propagation.NOT_SUPPORTED) +public class PaymentControllerTest { + @Autowired + private MockMvc mvc; + + @Autowired + private MemberService memberService; + + @Autowired + private ReservationRepository reservationRepository; + + @Autowired + private CourseRepository courseRepository; + + @Autowired + private PaymentRepository paymentRepository; + + @MockitoBean + private ReservationCancelProducer reservationCancelProducer; + + @MockitoBean + private TossPaymentsService tossPaymentsService; + + @MockitoBean + private Supplier clockSupplier; + + private String token; + private Member loginedMember; + private Reservation reservation; + + @Container + private static final KafkaContainer KAFKA_CONTAINER = new KafkaContainer(DockerImageName.parse("confluentinc/cp-kafka:7.4.0")); + + // Redis 컨테이너 생성 및 포트 설정 + @Container + private static final GenericContainer REDIS_CONTAINER = + new GenericContainer<>("redis:6-alpine") + .withExposedPorts(6379) + .waitingFor(Wait.forListeningPort()); + + // RedisTemplate이 컨테이너의 동적 포트를 사용하도록 설정 + @DynamicPropertySource + static void setRedisProperties(DynamicPropertyRegistry registry) { + registry.add("spring.data.redis.host", REDIS_CONTAINER::getHost); + registry.add("spring.data.redis.port", () -> REDIS_CONTAINER.getMappedPort(6379)); + registry.add("spring.kafka.bootstrap-servers", KAFKA_CONTAINER::getBootstrapServers); + } + + @BeforeEach + @DisplayName("user1로 로그인 셋업") + void setUp() { + loginedMember = memberService.findByUsername("user1").get(); + token = memberService.getAuthToken(loginedMember); + when(clockSupplier.get()).thenAnswer(invocation -> ZonedDateTime.now(ZoneId.of("Asia/Seoul"))); + + Course course = courseRepository.findById(1L).get(); + + // 결제 대기중인 예약 세팅 + reservation = Reservation.builder() + .student(loginedMember) + .course(course) + .status(ReservationStatus.PENDING) + .price(course.getPrice()) + .build(); + + reservationRepository.save(reservation); + } + + @AfterEach + void tearDown() { + paymentRepository.deleteAllInBatch(); + reservationRepository.deleteAllInBatch(); + } + + private ResultActions confirmRequest(String paymentKey, String orderId, long amount) throws Exception { + Map requestBody = Map.of("paymentKey", paymentKey, "orderId", orderId,"amount", String.valueOf(amount)); + + // Map -> Json 변환 + ObjectMapper objectMapper = new ObjectMapper(); + String json = objectMapper.writeValueAsString(requestBody); + + return mvc + .perform( + post("/api/payment/confirm") + .content(json) + .contentType(new MediaType(MediaType.APPLICATION_JSON, StandardCharsets.UTF_8)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + } + + @Test + @DisplayName("결제 성공 시나리오") + void paymentSuccess() throws Exception { + + ResultActions requestActions = mvc.perform( + post("/api/payment/request") + .param("reservationId", String.valueOf(reservation.getId())) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + ObjectMapper mapper = new ObjectMapper(); + String jsonContent = requestActions.andReturn().getResponse().getContentAsString(); + JsonNode json = mapper.readTree(jsonContent); // 파싱 + + String orderId = json.get("data").get("orderId").asText(); + long amount = json.get("data").get("amount").asLong(); + + doNothing().when(tossPaymentsService).confirm(any(), any(), any()); + ResultActions confirmActions = confirmRequest("fake-payment-key", orderId,amount); + + confirmActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("신청 목록 조회가 완료되었습니다.")); + + Payment confirmedPayment = paymentRepository.findByMerchantUid(orderId).get(); + Reservation completedReservation = reservationRepository.findById(reservation.getId()).get(); + + assertThat(confirmedPayment.getStatus()).isEqualTo(PaymentStatus.PAID); + assertThat(completedReservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + } + + @Test + @DisplayName("결제 취소 성공 시나리오") + void cancelSuccess() throws Exception { + paymentSuccess(); // 결제 성공 셋업 + + ResultActions requestActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + requestActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("결제 취소요청이 접수되었습니다.")); + + Payment canceledPayment = paymentRepository.findByReservation(reservation).get(); + assertThat(canceledPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + } + + @Test + @DisplayName("실패 시나리오: 외부 서비스 환불 실패, Payment 상태는 CANCEL_REQUESTED로 유지된다") + void paymentCancel_Fails_When_PG_Fails() throws Exception { + // GIVEN: '결제 완료' 상태의 예약과 결제 데이터 셋업 + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("fail-test-merchant-uid-" + System.currentTimeMillis()) + .paymentKey("fail-test-payment-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + // GIVEN: ⭐️ TossPaymentsService의 cancel 메서드가 항상 예외를 던지도록 Mocking + doThrow(new ServiceException("400-6", "PG사 연동 오류")) + .when(tossPaymentsService).cancel(any(), any()); + + // WHEN: 사용자가 결제 취소 API를 호출 + mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andExpect(status().isOk()); + + + // 최종적으로 상태가 변경되지 않았음을 검증 + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + Payment failedPayment = paymentRepository.findById(payment.getId()).get(); + Reservation notCancelledReservation = reservationRepository.findById(reservation.getId()).get(); + + // 최종적으로 상태가 변경되지 않았음을 검증 + assertThat(failedPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + assertThat(notCancelledReservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + }); + } + + @Test + @DisplayName("실패 시나리오: 외부 서비스 환불은 성공했으나 내부 DB 작업 실패 시, 상태는 CANCEL_REQUESTED로 유지된다") + void paymentCancel_Rollbacks_When_InternalDB_Fails() throws Exception { + // GIVEN: '결제 완료' 상태의 예약과 결제 데이터 셋업 + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("internal-fail-uid-" + System.currentTimeMillis()) + .paymentKey("internal-fail-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + doNothing().when(tossPaymentsService).cancel(any(), any()); + doThrow(new RuntimeException("내부 DB 저장 실패!")) + .when(reservationCancelProducer).send(any()); + + mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andExpect(status().isOk()); + + + Awaitility.await() + .atMost(30, TimeUnit.SECONDS) + .pollInterval(1, TimeUnit.SECONDS) + .untilAsserted(() -> { + Payment rolledBackPayment = paymentRepository.findById(payment.getId()).get(); + + // 최초 요청 상태인 CANCEL_REQUESTED로 남아있어야 함 + assertThat(rolledBackPayment.getStatus()).isEqualTo(PaymentStatus.CANCEL_REQUESTED); + }); + } + + @Test + @DisplayName("실패 시나리오: 이미 '취소 처리 중'인 결제에 대해 중복 취소 요청 시 409 Conflict 에러가 발생한다") + void deletePayment_Fails_When_StatusIsCancelRequested() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.CANCEL_REQUESTED) + .merchantUid("duplicate-test-uid-" + System.currentTimeMillis()) + .paymentKey("duplicate-test-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + ResultActions resultActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-4")) + .andExpect(jsonPath("$.msg").value("이미 취소 처리중인 결제입니다.")); + } + + @Test + @DisplayName("실패 시나리오: 이미 '취소 완료'된 결제에 대해 중복 취소 요청 시 409 Conflict 에러가 발생한다") + void deletePayment_Fails_When_StatusIsCancelled() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.CANCELLED) + .merchantUid("cancelled-test-uid-" + System.currentTimeMillis()) + .paymentKey("cancelled-test-key") + .amount(reservation.getPrice()) + .build(); + paymentRepository.save(payment); + + ResultActions resultActions = mvc.perform( + delete("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-2")) + .andExpect(jsonPath("$.msg").value("이미 취소된 결제입니다.")); + } + + @Test + @DisplayName("단일 결제 정보 조회 성공") + void getPayment_Success() throws Exception { + reservation.setStatus(ReservationStatus.COMPLETED); + reservationRepository.save(reservation); + Payment payment = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid") + .paymentKey("get-payment-test-key") + .amount(10000L) + .build(); + paymentRepository.save(payment); + + // WHEN: 해당 reservationId로 결제 정보 조회를 요청 + ResultActions resultActions = mvc.perform( + get("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + // THEN: 200 OK 상태와 함께 올바른 결제 정보가 반환됨 + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.data.orderId").value("get-payment-test-uid")) + .andExpect(jsonPath("$.data.amount").value(10000L)); + } + + @Test + @DisplayName("단일 결제 정보 조회 실패 - COMPLETED 상태의 예약이 없음") + void getPayment_Fails_When_ReservationNotCompleted() throws Exception { + ResultActions resultActions = mvc.perform( + get("/api/payment/{reservationId}", reservation.getId()) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("404-3")) + .andExpect(jsonPath("$.msg").value("수강신청 이력이 없습니다.")); + } + + @Test + @DisplayName("결제 목록 조회 성공") + void getPayments_Success() throws Exception { + Course course2 = courseRepository.findById(2L).get(); + Reservation reservation2 = Reservation.builder() + .student(loginedMember) + .course(course2) + .status(ReservationStatus.COMPLETED) + .price(course2.getPrice()) + .build(); + reservationRepository.save(reservation2); + + Payment payment1 = Payment.builder() + .reservation(reservation) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid") + .paymentKey("get-payment-test-key") + .amount(reservation.getPrice()) + .build(); + + Payment payment2 = Payment.builder() + .reservation(reservation2) + .member(loginedMember) + .status(PaymentStatus.PAID) + .merchantUid("get-payment-test-uid2") + .paymentKey("get-payment-test-key2") + .amount(reservation2.getPrice()) + .build(); + + paymentRepository.saveAll(List.of(payment1, payment2)); + + ResultActions resultActions = mvc.perform( + get("/api/payment") + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isOk()) + .andExpect(jsonPath("$.code").value("200-1")) + .andExpect(jsonPath("$.msg").value("신청 목록 조회가 완료되었습니다."));; + } + + @Test + @DisplayName("결제 목록 조회 실패 - 결제 내역이 없음") + void getPayments_Fails_When_NoPayments() throws Exception { + ResultActions resultActions = mvc.perform( + get("/api/payment") + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isNotFound()) + .andExpect(jsonPath("$.code").value("404-5")) + .andExpect(jsonPath("$.msg").value("결제 정보가 없습니다.")); + } +} diff --git a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java index f2834da..3b99b75 100644 --- a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java +++ b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationControllerTest.java @@ -138,12 +138,12 @@ void reserve() throws Exception { Course awaitCourse = courseService.findById(courseId); // DB 조회 Reservation reservation = reservationRepository.findByStudentAndCourse(loginedMember, awaitCourse).get(); - assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + assertThat(reservation.getStatus()).isEqualTo(ReservationStatus.PENDING); // RedisStream 확인 List events = notificationService.getNotifications(loginedMember.getId()); NotificationDto event = events.get(events.size() - 1); // 최신 이벤트 - assertThat(event.getStatus()).isEqualTo(ReservationStatus.COMPLETED); + assertThat(event.getStatus()).isEqualTo(ReservationStatus.PENDING); assertThat(event.getMessage()).isEqualTo("수강 신청이 성공하였습니다."); assertThat(event.getCourseTitle()).isEqualTo(awaitCourse.getTitle()); } @@ -208,7 +208,7 @@ void reserve4() throws Exception { } @Test - @DisplayName("수강 취소") + @DisplayName("수강 취소 - Pending 상태") void cancelReservation() throws Exception { Long courseId = 1L; Course course = courseService.findById(courseId); @@ -222,7 +222,7 @@ void cancelReservation() throws Exception { resultActions .andExpect(status().isOk()) .andExpect(jsonPath("$.code").value("200-1")) - .andExpect(jsonPath("$.msg").value("수강 취소되었습니다.")); + .andExpect(jsonPath("$.msg").value("수강 취소 요청이 접수되었습니다.")); } @Test @@ -230,6 +230,7 @@ void cancelReservation() throws Exception { void cancelReservation2() throws Exception { Long courseId = 1L; cancelReservation(); // 수강 취소 + Thread.sleep(1000); ResultActions resultActions = mvc.perform( delete("/api/reserve?courseId=%d".formatted(courseId)) @@ -237,14 +238,51 @@ void cancelReservation2() throws Exception { ).andDo(print()); resultActions - .andExpect(status().isNotFound()) - .andExpect(jsonPath("$.code").value("404-3")) - .andExpect(jsonPath("$.msg").value("수강 신청 이력이 없습니다.")); + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-5")) + .andExpect(jsonPath("$.msg").value("이미 취소된 신청입니다.")); } @Test - @DisplayName("수강 취소 실패 - 이력이 없음") + @DisplayName("수강 취소 실패 - Reservation: Failed 상태") void cancelReservation3() throws Exception { + Long courseId = 1L; + Course course = courseService.findById(courseId); + reservationTestHelper.reserveSetUpFailed(loginedMember, course); + Thread.sleep(1000); // 잠시 대기 + + ResultActions resultActions = mvc.perform( + delete("/api/reserve?courseId=%d".formatted(courseId)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-6")) + .andExpect(jsonPath("$.msg").value("취소할 수 없는 신청입니다.")); + } + + @Test + @DisplayName("수강 취소 실패 - Reservation: Waiting 상태") + void cancelReservation4() throws Exception { + Long courseId = 1L; + Course course = courseService.findById(courseId); + reservationTestHelper.reserveSetUpWaiting(loginedMember, course); + + ResultActions resultActions = mvc.perform( + delete("/api/reserve?courseId=%d".formatted(courseId)) + .header("Authorization", "Bearer " + token) + ).andDo(print()); + + resultActions + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.code").value("409-6")) + .andExpect(jsonPath("$.msg").value("취소할 수 없는 신청입니다.")); + } + + @Test + @DisplayName("수강 취소 실패 - 이력이 없음") + void cancelReservation5() throws Exception { Long courseId = 3L; ResultActions resultActions = mvc.perform( @@ -260,7 +298,7 @@ void cancelReservation3() throws Exception { @Test @DisplayName("수강 취소 실패 - 존재하지 않는 강의") - void cancelReservation4() throws Exception { + void cancelReservation6() throws Exception { Long courseId = 999L; ResultActions resultActions = mvc.perform( @@ -275,7 +313,7 @@ void cancelReservation4() throws Exception { } @Test - @DisplayName("신청 목록 조회") + @DisplayName("수강 목록 조회(결제 대기)") void getReservation() throws Exception { int page = 1; int pageSize = 5; @@ -285,7 +323,7 @@ void getReservation() throws Exception { reservationTestHelper.reserveSetUp(loginedMember, course); ResultActions resultActions = mvc.perform( - get("/api/reserve/me?page=%d&pageSize=%d".formatted(page, pageSize)) + get("/api/reserve/me/pending?page=%d&pageSize=%d".formatted(page, pageSize)) .header("Authorization", "Bearer " + token) ); diff --git a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java index 051e621..4c539fc 100644 --- a/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java +++ b/backend/src/test/java/com/Catch_Course/domain/reservation/controller/ReservationTestHelper.java @@ -23,7 +23,7 @@ public class ReservationTestHelper { @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 public void reserveSetUp(Member loginedMember, Course course) { - Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING); // 대기열 등록 + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING, course.getPrice()); // 대기열 등록 reservationRepository.save(reservation); reservationService.processReservation(course.getId(), loginedMember.getId()); // 수강 신청 em.flush(); // DB에 적고 @@ -44,4 +44,20 @@ public void addQueue(Member loginedMember,Long courseId) { em.flush(); // DB에 적고 em.clear(); // 영속성 컨텍스트 클리어 } + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 + public void reserveSetUpFailed(Member loginedMember, Course course) { + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.FAILED, course.getPrice()); // 대기열 등록 + reservationRepository.save(reservation); + em.flush(); // DB에 적고 + em.clear(); // 영속성 컨텍스트 클리어 + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) // 반드시 새로운 트랜잭션 생성 + public void reserveSetUpWaiting(Member loginedMember, Course course) { + Reservation reservation = new Reservation(loginedMember,course, ReservationStatus.WAITING, course.getPrice()); // 대기열 등록 + reservationRepository.save(reservation); + em.flush(); // DB에 적고 + em.clear(); // 영속성 컨텍스트 클리어 + } } diff --git a/backend/users_1000.csv b/backend/users_1000.csv new file mode 100644 index 0000000..7a69dbe --- /dev/null +++ b/backend/users_1000.csv @@ -0,0 +1,1001 @@ +username,password +user1,user11234 +user2,user21234 +user3,user31234 +user4,user41234 +user5,user51234 +user6,user61234 +user7,user71234 +user8,user81234 +user9,user91234 +user10,user101234 +user11,user111234 +user12,user121234 +user13,user131234 +user14,user141234 +user15,user151234 +user16,user161234 +user17,user171234 +user18,user181234 +user19,user191234 +user20,user201234 +user21,user211234 +user22,user221234 +user23,user231234 +user24,user241234 +user25,user251234 +user26,user261234 +user27,user271234 +user28,user281234 +user29,user291234 +user30,user301234 +user31,user311234 +user32,user321234 +user33,user331234 +user34,user341234 +user35,user351234 +user36,user361234 +user37,user371234 +user38,user381234 +user39,user391234 +user40,user401234 +user41,user411234 +user42,user421234 +user43,user431234 +user44,user441234 +user45,user451234 +user46,user461234 +user47,user471234 +user48,user481234 +user49,user491234 +user50,user501234 +user51,user511234 +user52,user521234 +user53,user531234 +user54,user541234 +user55,user551234 +user56,user561234 +user57,user571234 +user58,user581234 +user59,user591234 +user60,user601234 +user61,user611234 +user62,user621234 +user63,user631234 +user64,user641234 +user65,user651234 +user66,user661234 +user67,user671234 +user68,user681234 +user69,user691234 +user70,user701234 +user71,user711234 +user72,user721234 +user73,user731234 +user74,user741234 +user75,user751234 +user76,user761234 +user77,user771234 +user78,user781234 +user79,user791234 +user80,user801234 +user81,user811234 +user82,user821234 +user83,user831234 +user84,user841234 +user85,user851234 +user86,user861234 +user87,user871234 +user88,user881234 +user89,user891234 +user90,user901234 +user91,user911234 +user92,user921234 +user93,user931234 +user94,user941234 +user95,user951234 +user96,user961234 +user97,user971234 +user98,user981234 +user99,user991234 +user100,user1001234 +user101,user1011234 +user102,user1021234 +user103,user1031234 +user104,user1041234 +user105,user1051234 +user106,user1061234 +user107,user1071234 +user108,user1081234 +user109,user1091234 +user110,user1101234 +user111,user1111234 +user112,user1121234 +user113,user1131234 +user114,user1141234 +user115,user1151234 +user116,user1161234 +user117,user1171234 +user118,user1181234 +user119,user1191234 +user120,user1201234 +user121,user1211234 +user122,user1221234 +user123,user1231234 +user124,user1241234 +user125,user1251234 +user126,user1261234 +user127,user1271234 +user128,user1281234 +user129,user1291234 +user130,user1301234 +user131,user1311234 +user132,user1321234 +user133,user1331234 +user134,user1341234 +user135,user1351234 +user136,user1361234 +user137,user1371234 +user138,user1381234 +user139,user1391234 +user140,user1401234 +user141,user1411234 +user142,user1421234 +user143,user1431234 +user144,user1441234 +user145,user1451234 +user146,user1461234 +user147,user1471234 +user148,user1481234 +user149,user1491234 +user150,user1501234 +user151,user1511234 +user152,user1521234 +user153,user1531234 +user154,user1541234 +user155,user1551234 +user156,user1561234 +user157,user1571234 +user158,user1581234 +user159,user1591234 +user160,user1601234 +user161,user1611234 +user162,user1621234 +user163,user1631234 +user164,user1641234 +user165,user1651234 +user166,user1661234 +user167,user1671234 +user168,user1681234 +user169,user1691234 +user170,user1701234 +user171,user1711234 +user172,user1721234 +user173,user1731234 +user174,user1741234 +user175,user1751234 +user176,user1761234 +user177,user1771234 +user178,user1781234 +user179,user1791234 +user180,user1801234 +user181,user1811234 +user182,user1821234 +user183,user1831234 +user184,user1841234 +user185,user1851234 +user186,user1861234 +user187,user1871234 +user188,user1881234 +user189,user1891234 +user190,user1901234 +user191,user1911234 +user192,user1921234 +user193,user1931234 +user194,user1941234 +user195,user1951234 +user196,user1961234 +user197,user1971234 +user198,user1981234 +user199,user1991234 +user200,user2001234 +user201,user2011234 +user202,user2021234 +user203,user2031234 +user204,user2041234 +user205,user2051234 +user206,user2061234 +user207,user2071234 +user208,user2081234 +user209,user2091234 +user210,user2101234 +user211,user2111234 +user212,user2121234 +user213,user2131234 +user214,user2141234 +user215,user2151234 +user216,user2161234 +user217,user2171234 +user218,user2181234 +user219,user2191234 +user220,user2201234 +user221,user2211234 +user222,user2221234 +user223,user2231234 +user224,user2241234 +user225,user2251234 +user226,user2261234 +user227,user2271234 +user228,user2281234 +user229,user2291234 +user230,user2301234 +user231,user2311234 +user232,user2321234 +user233,user2331234 +user234,user2341234 +user235,user2351234 +user236,user2361234 +user237,user2371234 +user238,user2381234 +user239,user2391234 +user240,user2401234 +user241,user2411234 +user242,user2421234 +user243,user2431234 +user244,user2441234 +user245,user2451234 +user246,user2461234 +user247,user2471234 +user248,user2481234 +user249,user2491234 +user250,user2501234 +user251,user2511234 +user252,user2521234 +user253,user2531234 +user254,user2541234 +user255,user2551234 +user256,user2561234 +user257,user2571234 +user258,user2581234 +user259,user2591234 +user260,user2601234 +user261,user2611234 +user262,user2621234 +user263,user2631234 +user264,user2641234 +user265,user2651234 +user266,user2661234 +user267,user2671234 +user268,user2681234 +user269,user2691234 +user270,user2701234 +user271,user2711234 +user272,user2721234 +user273,user2731234 +user274,user2741234 +user275,user2751234 +user276,user2761234 +user277,user2771234 +user278,user2781234 +user279,user2791234 +user280,user2801234 +user281,user2811234 +user282,user2821234 +user283,user2831234 +user284,user2841234 +user285,user2851234 +user286,user2861234 +user287,user2871234 +user288,user2881234 +user289,user2891234 +user290,user2901234 +user291,user2911234 +user292,user2921234 +user293,user2931234 +user294,user2941234 +user295,user2951234 +user296,user2961234 +user297,user2971234 +user298,user2981234 +user299,user2991234 +user300,user3001234 +user301,user3011234 +user302,user3021234 +user303,user3031234 +user304,user3041234 +user305,user3051234 +user306,user3061234 +user307,user3071234 +user308,user3081234 +user309,user3091234 +user310,user3101234 +user311,user3111234 +user312,user3121234 +user313,user3131234 +user314,user3141234 +user315,user3151234 +user316,user3161234 +user317,user3171234 +user318,user3181234 +user319,user3191234 +user320,user3201234 +user321,user3211234 +user322,user3221234 +user323,user3231234 +user324,user3241234 +user325,user3251234 +user326,user3261234 +user327,user3271234 +user328,user3281234 +user329,user3291234 +user330,user3301234 +user331,user3311234 +user332,user3321234 +user333,user3331234 +user334,user3341234 +user335,user3351234 +user336,user3361234 +user337,user3371234 +user338,user3381234 +user339,user3391234 +user340,user3401234 +user341,user3411234 +user342,user3421234 +user343,user3431234 +user344,user3441234 +user345,user3451234 +user346,user3461234 +user347,user3471234 +user348,user3481234 +user349,user3491234 +user350,user3501234 +user351,user3511234 +user352,user3521234 +user353,user3531234 +user354,user3541234 +user355,user3551234 +user356,user3561234 +user357,user3571234 +user358,user3581234 +user359,user3591234 +user360,user3601234 +user361,user3611234 +user362,user3621234 +user363,user3631234 +user364,user3641234 +user365,user3651234 +user366,user3661234 +user367,user3671234 +user368,user3681234 +user369,user3691234 +user370,user3701234 +user371,user3711234 +user372,user3721234 +user373,user3731234 +user374,user3741234 +user375,user3751234 +user376,user3761234 +user377,user3771234 +user378,user3781234 +user379,user3791234 +user380,user3801234 +user381,user3811234 +user382,user3821234 +user383,user3831234 +user384,user3841234 +user385,user3851234 +user386,user3861234 +user387,user3871234 +user388,user3881234 +user389,user3891234 +user390,user3901234 +user391,user3911234 +user392,user3921234 +user393,user3931234 +user394,user3941234 +user395,user3951234 +user396,user3961234 +user397,user3971234 +user398,user3981234 +user399,user3991234 +user400,user4001234 +user401,user4011234 +user402,user4021234 +user403,user4031234 +user404,user4041234 +user405,user4051234 +user406,user4061234 +user407,user4071234 +user408,user4081234 +user409,user4091234 +user410,user4101234 +user411,user4111234 +user412,user4121234 +user413,user4131234 +user414,user4141234 +user415,user4151234 +user416,user4161234 +user417,user4171234 +user418,user4181234 +user419,user4191234 +user420,user4201234 +user421,user4211234 +user422,user4221234 +user423,user4231234 +user424,user4241234 +user425,user4251234 +user426,user4261234 +user427,user4271234 +user428,user4281234 +user429,user4291234 +user430,user4301234 +user431,user4311234 +user432,user4321234 +user433,user4331234 +user434,user4341234 +user435,user4351234 +user436,user4361234 +user437,user4371234 +user438,user4381234 +user439,user4391234 +user440,user4401234 +user441,user4411234 +user442,user4421234 +user443,user4431234 +user444,user4441234 +user445,user4451234 +user446,user4461234 +user447,user4471234 +user448,user4481234 +user449,user4491234 +user450,user4501234 +user451,user4511234 +user452,user4521234 +user453,user4531234 +user454,user4541234 +user455,user4551234 +user456,user4561234 +user457,user4571234 +user458,user4581234 +user459,user4591234 +user460,user4601234 +user461,user4611234 +user462,user4621234 +user463,user4631234 +user464,user4641234 +user465,user4651234 +user466,user4661234 +user467,user4671234 +user468,user4681234 +user469,user4691234 +user470,user4701234 +user471,user4711234 +user472,user4721234 +user473,user4731234 +user474,user4741234 +user475,user4751234 +user476,user4761234 +user477,user4771234 +user478,user4781234 +user479,user4791234 +user480,user4801234 +user481,user4811234 +user482,user4821234 +user483,user4831234 +user484,user4841234 +user485,user4851234 +user486,user4861234 +user487,user4871234 +user488,user4881234 +user489,user4891234 +user490,user4901234 +user491,user4911234 +user492,user4921234 +user493,user4931234 +user494,user4941234 +user495,user4951234 +user496,user4961234 +user497,user4971234 +user498,user4981234 +user499,user4991234 +user500,user5001234 +user501,user5011234 +user502,user5021234 +user503,user5031234 +user504,user5041234 +user505,user5051234 +user506,user5061234 +user507,user5071234 +user508,user5081234 +user509,user5091234 +user510,user5101234 +user511,user5111234 +user512,user5121234 +user513,user5131234 +user514,user5141234 +user515,user5151234 +user516,user5161234 +user517,user5171234 +user518,user5181234 +user519,user5191234 +user520,user5201234 +user521,user5211234 +user522,user5221234 +user523,user5231234 +user524,user5241234 +user525,user5251234 +user526,user5261234 +user527,user5271234 +user528,user5281234 +user529,user5291234 +user530,user5301234 +user531,user5311234 +user532,user5321234 +user533,user5331234 +user534,user5341234 +user535,user5351234 +user536,user5361234 +user537,user5371234 +user538,user5381234 +user539,user5391234 +user540,user5401234 +user541,user5411234 +user542,user5421234 +user543,user5431234 +user544,user5441234 +user545,user5451234 +user546,user5461234 +user547,user5471234 +user548,user5481234 +user549,user5491234 +user550,user5501234 +user551,user5511234 +user552,user5521234 +user553,user5531234 +user554,user5541234 +user555,user5551234 +user556,user5561234 +user557,user5571234 +user558,user5581234 +user559,user5591234 +user560,user5601234 +user561,user5611234 +user562,user5621234 +user563,user5631234 +user564,user5641234 +user565,user5651234 +user566,user5661234 +user567,user5671234 +user568,user5681234 +user569,user5691234 +user570,user5701234 +user571,user5711234 +user572,user5721234 +user573,user5731234 +user574,user5741234 +user575,user5751234 +user576,user5761234 +user577,user5771234 +user578,user5781234 +user579,user5791234 +user580,user5801234 +user581,user5811234 +user582,user5821234 +user583,user5831234 +user584,user5841234 +user585,user5851234 +user586,user5861234 +user587,user5871234 +user588,user5881234 +user589,user5891234 +user590,user5901234 +user591,user5911234 +user592,user5921234 +user593,user5931234 +user594,user5941234 +user595,user5951234 +user596,user5961234 +user597,user5971234 +user598,user5981234 +user599,user5991234 +user600,user6001234 +user601,user6011234 +user602,user6021234 +user603,user6031234 +user604,user6041234 +user605,user6051234 +user606,user6061234 +user607,user6071234 +user608,user6081234 +user609,user6091234 +user610,user6101234 +user611,user6111234 +user612,user6121234 +user613,user6131234 +user614,user6141234 +user615,user6151234 +user616,user6161234 +user617,user6171234 +user618,user6181234 +user619,user6191234 +user620,user6201234 +user621,user6211234 +user622,user6221234 +user623,user6231234 +user624,user6241234 +user625,user6251234 +user626,user6261234 +user627,user6271234 +user628,user6281234 +user629,user6291234 +user630,user6301234 +user631,user6311234 +user632,user6321234 +user633,user6331234 +user634,user6341234 +user635,user6351234 +user636,user6361234 +user637,user6371234 +user638,user6381234 +user639,user6391234 +user640,user6401234 +user641,user6411234 +user642,user6421234 +user643,user6431234 +user644,user6441234 +user645,user6451234 +user646,user6461234 +user647,user6471234 +user648,user6481234 +user649,user6491234 +user650,user6501234 +user651,user6511234 +user652,user6521234 +user653,user6531234 +user654,user6541234 +user655,user6551234 +user656,user6561234 +user657,user6571234 +user658,user6581234 +user659,user6591234 +user660,user6601234 +user661,user6611234 +user662,user6621234 +user663,user6631234 +user664,user6641234 +user665,user6651234 +user666,user6661234 +user667,user6671234 +user668,user6681234 +user669,user6691234 +user670,user6701234 +user671,user6711234 +user672,user6721234 +user673,user6731234 +user674,user6741234 +user675,user6751234 +user676,user6761234 +user677,user6771234 +user678,user6781234 +user679,user6791234 +user680,user6801234 +user681,user6811234 +user682,user6821234 +user683,user6831234 +user684,user6841234 +user685,user6851234 +user686,user6861234 +user687,user6871234 +user688,user6881234 +user689,user6891234 +user690,user6901234 +user691,user6911234 +user692,user6921234 +user693,user6931234 +user694,user6941234 +user695,user6951234 +user696,user6961234 +user697,user6971234 +user698,user6981234 +user699,user6991234 +user700,user7001234 +user701,user7011234 +user702,user7021234 +user703,user7031234 +user704,user7041234 +user705,user7051234 +user706,user7061234 +user707,user7071234 +user708,user7081234 +user709,user7091234 +user710,user7101234 +user711,user7111234 +user712,user7121234 +user713,user7131234 +user714,user7141234 +user715,user7151234 +user716,user7161234 +user717,user7171234 +user718,user7181234 +user719,user7191234 +user720,user7201234 +user721,user7211234 +user722,user7221234 +user723,user7231234 +user724,user7241234 +user725,user7251234 +user726,user7261234 +user727,user7271234 +user728,user7281234 +user729,user7291234 +user730,user7301234 +user731,user7311234 +user732,user7321234 +user733,user7331234 +user734,user7341234 +user735,user7351234 +user736,user7361234 +user737,user7371234 +user738,user7381234 +user739,user7391234 +user740,user7401234 +user741,user7411234 +user742,user7421234 +user743,user7431234 +user744,user7441234 +user745,user7451234 +user746,user7461234 +user747,user7471234 +user748,user7481234 +user749,user7491234 +user750,user7501234 +user751,user7511234 +user752,user7521234 +user753,user7531234 +user754,user7541234 +user755,user7551234 +user756,user7561234 +user757,user7571234 +user758,user7581234 +user759,user7591234 +user760,user7601234 +user761,user7611234 +user762,user7621234 +user763,user7631234 +user764,user7641234 +user765,user7651234 +user766,user7661234 +user767,user7671234 +user768,user7681234 +user769,user7691234 +user770,user7701234 +user771,user7711234 +user772,user7721234 +user773,user7731234 +user774,user7741234 +user775,user7751234 +user776,user7761234 +user777,user7771234 +user778,user7781234 +user779,user7791234 +user780,user7801234 +user781,user7811234 +user782,user7821234 +user783,user7831234 +user784,user7841234 +user785,user7851234 +user786,user7861234 +user787,user7871234 +user788,user7881234 +user789,user7891234 +user790,user7901234 +user791,user7911234 +user792,user7921234 +user793,user7931234 +user794,user7941234 +user795,user7951234 +user796,user7961234 +user797,user7971234 +user798,user7981234 +user799,user7991234 +user800,user8001234 +user801,user8011234 +user802,user8021234 +user803,user8031234 +user804,user8041234 +user805,user8051234 +user806,user8061234 +user807,user8071234 +user808,user8081234 +user809,user8091234 +user810,user8101234 +user811,user8111234 +user812,user8121234 +user813,user8131234 +user814,user8141234 +user815,user8151234 +user816,user8161234 +user817,user8171234 +user818,user8181234 +user819,user8191234 +user820,user8201234 +user821,user8211234 +user822,user8221234 +user823,user8231234 +user824,user8241234 +user825,user8251234 +user826,user8261234 +user827,user8271234 +user828,user8281234 +user829,user8291234 +user830,user8301234 +user831,user8311234 +user832,user8321234 +user833,user8331234 +user834,user8341234 +user835,user8351234 +user836,user8361234 +user837,user8371234 +user838,user8381234 +user839,user8391234 +user840,user8401234 +user841,user8411234 +user842,user8421234 +user843,user8431234 +user844,user8441234 +user845,user8451234 +user846,user8461234 +user847,user8471234 +user848,user8481234 +user849,user8491234 +user850,user8501234 +user851,user8511234 +user852,user8521234 +user853,user8531234 +user854,user8541234 +user855,user8551234 +user856,user8561234 +user857,user8571234 +user858,user8581234 +user859,user8591234 +user860,user8601234 +user861,user8611234 +user862,user8621234 +user863,user8631234 +user864,user8641234 +user865,user8651234 +user866,user8661234 +user867,user8671234 +user868,user8681234 +user869,user8691234 +user870,user8701234 +user871,user8711234 +user872,user8721234 +user873,user8731234 +user874,user8741234 +user875,user8751234 +user876,user8761234 +user877,user8771234 +user878,user8781234 +user879,user8791234 +user880,user8801234 +user881,user8811234 +user882,user8821234 +user883,user8831234 +user884,user8841234 +user885,user8851234 +user886,user8861234 +user887,user8871234 +user888,user8881234 +user889,user8891234 +user890,user8901234 +user891,user8911234 +user892,user8921234 +user893,user8931234 +user894,user8941234 +user895,user8951234 +user896,user8961234 +user897,user8971234 +user898,user8981234 +user899,user8991234 +user900,user9001234 +user901,user9011234 +user902,user9021234 +user903,user9031234 +user904,user9041234 +user905,user9051234 +user906,user9061234 +user907,user9071234 +user908,user9081234 +user909,user9091234 +user910,user9101234 +user911,user9111234 +user912,user9121234 +user913,user9131234 +user914,user9141234 +user915,user9151234 +user916,user9161234 +user917,user9171234 +user918,user9181234 +user919,user9191234 +user920,user9201234 +user921,user9211234 +user922,user9221234 +user923,user9231234 +user924,user9241234 +user925,user9251234 +user926,user9261234 +user927,user9271234 +user928,user9281234 +user929,user9291234 +user930,user9301234 +user931,user9311234 +user932,user9321234 +user933,user9331234 +user934,user9341234 +user935,user9351234 +user936,user9361234 +user937,user9371234 +user938,user9381234 +user939,user9391234 +user940,user9401234 +user941,user9411234 +user942,user9421234 +user943,user9431234 +user944,user9441234 +user945,user9451234 +user946,user9461234 +user947,user9471234 +user948,user9481234 +user949,user9491234 +user950,user9501234 +user951,user9511234 +user952,user9521234 +user953,user9531234 +user954,user9541234 +user955,user9551234 +user956,user9561234 +user957,user9571234 +user958,user9581234 +user959,user9591234 +user960,user9601234 +user961,user9611234 +user962,user9621234 +user963,user9631234 +user964,user9641234 +user965,user9651234 +user966,user9661234 +user967,user9671234 +user968,user9681234 +user969,user9691234 +user970,user9701234 +user971,user9711234 +user972,user9721234 +user973,user9731234 +user974,user9741234 +user975,user9751234 +user976,user9761234 +user977,user9771234 +user978,user9781234 +user979,user9791234 +user980,user9801234 +user981,user9811234 +user982,user9821234 +user983,user9831234 +user984,user9841234 +user985,user9851234 +user986,user9861234 +user987,user9871234 +user988,user9881234 +user989,user9891234 +user990,user9901234 +user991,user9911234 +user992,user9921234 +user993,user9931234 +user994,user9941234 +user995,user9951234 +user996,user9961234 +user997,user9971234 +user998,user9981234 +user999,user9991234 +user1000,user10001234 diff --git a/frontend/next.config.ts b/frontend/next.config.ts index 0682c63..7987247 100644 --- a/frontend/next.config.ts +++ b/frontend/next.config.ts @@ -1,6 +1,16 @@ import type { NextConfig } from "next"; const nextConfig: NextConfig = { + // API 요청을 백엔드(localhost:8080)로 전달하기 위한 프록시 설정 + async rewrites() { + return [ + { + source: "/api/:path*", + destination: "http://localhost:8080/api/:path*", + }, + ]; + }, + // 기존 이미지 설정 유지 images: { dangerouslyAllowSVG: true, remotePatterns: [ diff --git a/frontend/src/app/ClientLayout.tsx b/frontend/src/app/ClientLayout.tsx index 5aa4bc7..741e8a9 100644 --- a/frontend/src/app/ClientLayout.tsx +++ b/frontend/src/app/ClientLayout.tsx @@ -37,7 +37,7 @@ export default function ClinetLayout({ }; async function fetchLoginMember() { - const response = await client.GET("/api/members/me", { + const response = await client.GET("/api/profile/me", { credentials: "include", }); diff --git a/frontend/src/app/ClientPage.tsx b/frontend/src/app/ClientPage.tsx index 9705364..460b9db 100644 --- a/frontend/src/app/ClientPage.tsx +++ b/frontend/src/app/ClientPage.tsx @@ -1,6 +1,7 @@ "use client"; import { Button } from "@/components/ui/button"; -import { LoginMemberContext } from "@/stores/auth/loginMemberStore"; +// 경로를 상대 경로로 수정하여 오류를 해결합니다. +import { LoginMemberContext } from "../stores/auth/loginMemberStore"; import { use } from "react"; export default function Page() { @@ -8,16 +9,29 @@ export default function Page() { return ( <> + {/* 로그인이 되어있지 않은 경우, 카카오 로그인 버튼을 보여줍니다. */} {!isLogin && ( )} - {isLogin &&
{loginMember.nickname}님 환영합니다.
} + + {/* 로그인이 되어있는 경우, 환영 메시지와 함께 결제 페이지로 이동하는 버튼을 보여줍니다. */} + {isLogin && ( +
+
{loginMember.nickname}님, 환영합니다.
+

수강 신청을 완료하려면 결제를 진행해주세요.

+ +
+ )} ); } diff --git a/frontend/src/app/toss/ClientPage.tsx b/frontend/src/app/toss/ClientPage.tsx new file mode 100644 index 0000000..d4d46c5 --- /dev/null +++ b/frontend/src/app/toss/ClientPage.tsx @@ -0,0 +1,222 @@ +"use client"; + +import React, { useState, useEffect } from 'react'; + +// --- 타입 정의 --- +interface TossPayments { + requestPayment: (paymentType: string, paymentData: PaymentData) => Promise; +} +interface PaymentData { + amount: number; + orderId: string; + orderName: string; + customerName: string; + successUrl: string; + failUrl: string; +} +interface SuccessData { + paymentKey: string; + orderId: string; + amount: number; +} +interface FailData { + errorCode: string; + errorMessage: string; + orderId: string; +} +// [수정] 백엔드 응답에 맞춰 reservationId 필드 추가 +interface ReservationDto { + reservationId: number; + courseId: number; + courseTitle: string; + studentId: number; + studentName: string; + status: string; + price: number; + createdDate: string; +} +interface PaymentRequestResponseDto { + reservationId: number; + orderId: string; + amount: number; + paymentKey: string | null; + status: string; + createdDate: string; +} +interface LoginMember { + nickname: string | null; +} + +declare global { + interface Window { + TossPayments: (clientKey: string) => TossPayments; + } +} + +export default function ClientPage() { + const [pendingReservations, setPendingReservations] = useState([]); + const [tossPayments, setTossPayments] = useState(null); + const [isLoading, setIsLoading] = useState(true); + const [statusMessage, setStatusMessage] = useState('결제 대기 목록을 불러오는 중...'); + const [currentUser, setCurrentUser] = useState(null); + const [payingItemId, setPayingItemId] = useState(null); + + const TOSS_CLIENT_KEY = 'test_ck_LlDJaYngroz40d27Z0bXVezGdRpX'; + + const fetchPendingReservations = async () => { + setIsLoading(true); + setStatusMessage('결제 대기 목록을 불러오는 중...'); + try { + const fetchOptions = { + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' } + }; + + const pendingRes = await fetch(`/api/reserve/me/pending?page=1&pageSize=10`, fetchOptions); + if (pendingRes.status === 401) { + setStatusMessage('로그인이 필요합니다. 다시 로그인해주세요.'); + return; + } + if (!pendingRes.ok) { + throw new Error(`결제 대기 목록을 불러오는 데 실패했습니다. (상태: ${pendingRes.status})`); + } + + const pendingData = await pendingRes.json(); + + if (!pendingData.data || !pendingData.data.items || pendingData.data.items.length === 0) { + setStatusMessage('결제할 항목이 없습니다.'); + setPendingReservations([]); + return; + } + + const reservations: ReservationDto[] = pendingData.data.items.map((item: any) => ({ + ...item, + createdDate: item.createdDatetime + })); + + setPendingReservations(reservations); + if (reservations.length > 0) { + setCurrentUser({ nickname: reservations[0].studentName }); + } + setStatusMessage(''); + + } catch (error) { + console.error('결제 대기 목록 조회 실패:', error); + setStatusMessage(error instanceof Error ? error.message : String(error)); + } finally { + setIsLoading(false); + } + }; + + const handlePaymentRequest = async (reservation: ReservationDto) => { + if (!tossPayments) { + alert("결제 모듈이 아직 준비되지 않았습니다. 잠시 후 다시 시도해주세요."); + return; + } + setPayingItemId(reservation.reservationId); + try { + const fetchOptions = { + method: 'POST', + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' } + }; + + // [수정] reservationId 파라미터에 reservation.reservationId를 전달 + const requestRes = await fetch(`/api/payment/request?reservationId=${reservation.reservationId}`, fetchOptions); + if (!requestRes.ok) { + throw new Error(`결제 요청에 실패했습니다. (상태: ${requestRes.status})`); + } + const paymentData: PaymentRequestResponseDto = (await requestRes.json()).data; + + await tossPayments.requestPayment('카드', { + amount: paymentData.amount, + orderId: paymentData.orderId, + orderName: reservation.courseTitle, + customerName: reservation.studentName, + successUrl: `${window.location.origin}/toss/success`, + failUrl: `${window.location.origin}/toss/fail`, + }); + + } catch (error) { + console.error(`[${reservation.courseTitle}] 결제 처리 실패:`, error); + alert(`'${reservation.courseTitle}' 결제에 실패했습니다: ${error instanceof Error ? error.message : String(error)}`); + } finally { + setPayingItemId(null); + } + }; + + useEffect(() => { + const script = document.createElement('script'); + script.src = 'https://js.tosspayments.com/v1'; + script.async = true; + document.head.appendChild(script); + + script.onload = () => { + if (window.TossPayments) { + setTossPayments(window.TossPayments(TOSS_CLIENT_KEY)); + } + }; + + fetchPendingReservations(); + + return () => { + if (document.head.contains(script)) { + document.head.removeChild(script); + } + }; + }, []); + + return ( +
+
+
+

결제 대기 목록

+ {currentUser &&

{currentUser.nickname}님의 결제를 기다리는 수강 목록입니다.

} +
+ +
+ {isLoading ? ( +
+
+

{statusMessage}

+
+ ) : pendingReservations.length > 0 ? ( +
    + {pendingReservations.map((reservation) => ( + // [수정] React key를 reservation.reservationId로 변경 +
  • +
    +
    +
    +

    {reservation.courseTitle}

    +

    {reservation.price.toLocaleString()}원

    +
    +

    신청일: {new Date(reservation.createdDate).toLocaleDateString()}

    +
    +
    +

    상태: {reservation.status}

    +
    + +
    +
    +
    +
  • + ))} +
+ ) : ( +
+

{statusMessage || '결제할 항목이 없습니다.'}

+
+ )} +
+
+
+ ); +} diff --git a/frontend/src/app/toss/fail/page.tsx b/frontend/src/app/toss/fail/page.tsx new file mode 100644 index 0000000..cc51bfa --- /dev/null +++ b/frontend/src/app/toss/fail/page.tsx @@ -0,0 +1,40 @@ +"use client"; + +import { useSearchParams, useRouter } from 'next/navigation'; +import React, { Suspense } from 'react'; + +function FailComponent() { + const router = useRouter(); + const searchParams = useSearchParams(); + const errorCode = searchParams.get('code'); + const errorMessage = searchParams.get('message'); + + return ( +
+
+ + + +

결제 실패

+
+

오류 코드: {errorCode}

+

오류 메시지: {errorMessage}

+
+ +
+
+ ); +} + +export default function FailPage() { + return ( + Loading...}> + + + ) +} diff --git a/frontend/src/app/toss/page.tsx b/frontend/src/app/toss/page.tsx new file mode 100644 index 0000000..4b5ea3f --- /dev/null +++ b/frontend/src/app/toss/page.tsx @@ -0,0 +1,9 @@ +import React from 'react'; +// import 경로에 파일 확장자를 명시하여 모듈을 찾을 수 있도록 수정합니다. +import ClientPage from './ClientPage'; + +export default function TossPage() { + // /toss 경로로 접속하면 이 페이지가 보입니다. + // 실제 화면 내용은 ClientPage 컴포넌트가 담당합니다. + return ; +} diff --git a/frontend/src/app/toss/success/page.tsx b/frontend/src/app/toss/success/page.tsx new file mode 100644 index 0000000..dd19c9a --- /dev/null +++ b/frontend/src/app/toss/success/page.tsx @@ -0,0 +1,86 @@ +"use client"; + +import React, { useEffect, useState } from 'react'; + +// Next.js 라우터 훅 대신 표준 웹 API를 사용하도록 수정했습니다. +export default function SuccessPage() { + const [statusMessage, setStatusMessage] = useState("결제를 승인하는 중입니다..."); + const [isSuccess, setIsSuccess] = useState(null); + + useEffect(() => { + const confirmPayment = async () => { + // useSearchParams 훅 대신 URLSearchParams를 사용하여 쿼리 파라미터를 직접 파싱합니다. + const params = new URLSearchParams(window.location.search); + const paymentKey = params.get("paymentKey"); + const orderId = params.get("orderId"); + const amount = params.get("amount"); + + if (!paymentKey || !orderId || !amount) { + setStatusMessage("결제 승인에 필요한 정보가 URL에 없습니다."); + setIsSuccess(false); + return; + } + + try { + const fetchOptions = { + method: 'POST', + credentials: 'include' as RequestCredentials, + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ paymentKey, orderId, amount: Number(amount) }), + }; + + // 백엔드에 최종 결제 승인 요청 + const response = await fetch("/api/payment/confirm", fetchOptions); + const responseData = await response.json(); + + if (response.ok) { + setStatusMessage("결제가 성공적으로 완료되었습니다!"); + setIsSuccess(true); + } else { + // 백엔드에서 보낸 에러 메시지를 표시 + setStatusMessage(`결제 승인 실패: ${responseData.msg || '알 수 없는 오류가 발생했습니다.'}`); + setIsSuccess(false); + } + } catch (error) { + console.error("결제 승인 네트워크 오류:", error); + setStatusMessage(`결제 승인 중 오류가 발생했습니다: ${error instanceof Error ? error.message : String(error)}`); + setIsSuccess(false); + } + }; + + confirmPayment(); + }, []); // 컴포넌트가 마운트될 때 한 번만 실행 + + const handleGoHome = () => { + // useRouter 훅 대신 window.location을 사용하여 페이지를 이동합니다. + window.location.href = '/'; + }; + + return ( +
+
+ {isSuccess === null ? ( +
+ ) : isSuccess ? ( + + + + ) : ( + + + + )} +

+ {isSuccess ? "결제 완료" : "결제 실패"} +

+

{statusMessage}

+ +
+
+ ); +} diff --git a/frontend/src/components/business/ProfileMenu.tsx b/frontend/src/components/business/ProfileMenu.tsx index 6548eaf..bc5a7cc 100644 --- a/frontend/src/components/business/ProfileMenu.tsx +++ b/frontend/src/components/business/ProfileMenu.tsx @@ -20,29 +20,42 @@ export default function HomeMenu() { async function handleLogout(e: React.MouseEvent) { e.preventDefault(); - const response = await client.DELETE("/api/members/logout", { - credentials: "include", - }); + try { + // openapi-fetch 라이브러리의 표준 응답 형식에 맞춰 error 객체를 직접 구조분해합니다. + const { error } = await client.DELETE("/api/members/logout", { + credentials: "include", + }); - if (response.error) { - alert(response.error.msg); - return; - } + // API 요청 실패 시 error 객체에 정보가 담겨옵니다. + if (error) { + // error 객체에서 메시지를 추출하여 사용자에게 알립니다. + const errorMessage = (error as any).msg || '로그아웃에 실패했습니다.'; + alert(errorMessage); + return; + } + + // 에러가 없으면 성공으로 간주하고 로그아웃 처리를 진행합니다. + removeLoginMember(); + router.replace("/"); - removeLoginMember(); - router.replace("/"); + } catch (err) { + // 네트워크 문제 등 예기치 않은 오류 발생 시 처리합니다. + console.error("Logout failed:", err); + alert("로그아웃 중 예기치 않은 오류가 발생했습니다."); + } } return ( - {isLogin && ( + {/* 로그인 상태이고, loginMember 객체와 profileImageUrl이 모두 유효한 값일 때만 Image를 렌더링합니다. */} + {isLogin && loginMember?.profileImageUrl && (