Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
ceb9710
단일 결제 정보 조회
wonseokyoon Aug 14, 2025
5f6b147
결제 목록 조회
wonseokyoon Aug 14, 2025
c081651
feat: 결제 요청과 확인
wonseokyoon Aug 14, 2025
e215d1c
refactor: 수강신청 결제 상태 반영
wonseokyoon Aug 14, 2025
991e7cb
refactor: reservation과 payment 상태 관리;
wonseokyoon Aug 14, 2025
8e35005
fix: 사소한 오류
wonseokyoon Aug 14, 2025
bec344e
FE: 결제 테슽 UI
wonseokyoon Aug 14, 2025
57ce461
FE: 결제 테스트 UI 구성
wonseokyoon Aug 14, 2025
ae8aa09
fix: 결제 오류 해결
wonseokyoon Aug 14, 2025
f4d299f
fix: 테스트 안되는거 수정
wonseokyoon Aug 14, 2025
6facbcb
rollback:workflows
wonseokyoon Aug 14, 2025
f2c8a0e
feat: 결제 취소
wonseokyoon Aug 15, 2025
9ecbb8e
feat: 결제 취소
wonseokyoon Aug 15, 2025
7bd98b1
fix: 수강신청 취소 -> 결제 취소 사용자 경험 향상
wonseokyoon Aug 15, 2025
34cff7a
test: Reservation
wonseokyoon Aug 15, 2025
eb0eb17
test: 결제 신청과 취소 e2e 테스트 + 데이터 정합성
wonseokyoon Aug 15, 2025
b713b75
test: 결제 내역 조회와 예외
wonseokyoon Aug 15, 2025
b945876
fix: test
wonseokyoon Aug 15, 2025
d97f341
feat: 웹훅으로 데이터 원자성 유지
wonseokyoon Aug 15, 2025
27a069a
feat: 스케줄러를 이용하여 결제 만료 체크
wonseokyoon Aug 16, 2025
dcfe80b
refactor: 스케줄러 반복 시간 1분으로 설정
wonseokyoon Aug 16, 2025
9eed83c
chore: grafana/promethus 설정
wonseokyoon Aug 16, 2025
a46ef1d
refactor: 메세지 큐를 이용하여 만료상태 reservation 처리 + 결제와 예약 상태 변경 동시성 제어
wonseokyoon Aug 16, 2025
85bd2af
refactor: 동시성 제어
wonseokyoon Aug 16, 2025
03b3236
rollback: init 데이터 롤백
wonseokyoon Aug 16, 2025
1903981
fix: 이벤트 처리
wonseokyoon Aug 17, 2025
747271e
rollback: init 데이터 롤백
wonseokyoon Aug 17, 2025
a222990
rollback
wonseokyoon Aug 17, 2025
553451d
fix:CI
wonseokyoon Aug 17, 2025
5123f09
fix:CI
wonseokyoon Aug 17, 2025
3dd622d
fix:CI
wonseokyoon Aug 17, 2025
8417350
fix:CI
wonseokyoon Aug 17, 2025
b55d777
fix:CI
wonseokyoon Aug 17, 2025
fb89989
fix:CI
wonseokyoon Aug 17, 2025
107ba2a
fix:CI
wonseokyoon Aug 17, 2025
48cab20
fix:CI
wonseokyoon Aug 17, 2025
fcfd091
fix:CI
wonseokyoon Aug 17, 2025
d920a92
Merge pull request #94 from wonseokyoon/feat/결제/#93
wonseokyoon Aug 17, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
16 changes: 11 additions & 5 deletions .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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()
Expand All @@ -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:
Expand Down Expand Up @@ -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:
Expand Down Expand Up @@ -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
ghcr.io/${{ env.OWNER_LC }}/catch-course:latest
4 changes: 4 additions & 0 deletions backend/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -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') {
Expand Down
1 change: 1 addition & 0 deletions backend/docker-compose-prod.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
43 changes: 42 additions & 1 deletion backend/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -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"
Expand Down Expand Up @@ -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
12 changes: 12 additions & 0 deletions backend/prometheus.yml
Original file line number Diff line number Diff line change
@@ -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']
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@ public RsData<CourseDto> getItem(@PathVariable long id) {
public RsData<Void> 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);
Expand All @@ -100,7 +100,7 @@ public RsData<Void> 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())) {
Expand All @@ -119,7 +119,8 @@ public RsData<Void> 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
) {
}

Expand All @@ -130,7 +131,7 @@ record WriteReqBody(
public RsData<CourseDto> 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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -32,6 +32,7 @@ public Course write(Member member, String title, String content, long capacity)
.content(content)
.capacity(capacity)
.currentRegistration(0)
.price(price)
.build()
);
}
Expand All @@ -58,6 +59,10 @@ public Optional<Course> getItem(long courseId) {
return courseRepository.findById(courseId);
}

public Optional<Course> getItemWithPessimisticLock(long courseId) {
return courseRepository.findByIdWithPessimisticLock(courseId);
}

public long count() {
return courseRepository.count();
}
Expand All @@ -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", "존재하지 않는 강의입니다."));
}
}
Original file line number Diff line number Diff line change
@@ -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<PaymentDto> 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<List<PaymentDto>> getPayments() {

Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체

List<PaymentDto> paymentDtos = paymentService.getPayments(member);

return new RsData<>(
"200-1",
"신청 목록 조회가 완료되었습니다.",
paymentDtos
);
}


@Operation(summary = "결제 생성 및 요청")
@PostMapping("/request")
public RsData<PaymentDto> 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<PaymentDto> 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<PaymentDto> getReservations(@PathVariable Long reservationId) {
Member member = rq.getMember(rq.getDummyMember()); // 실제 멤버 객체

PaymentDto paymentDto = paymentService.deletePaymentRequest(member, reservationId);

return new RsData<>(
"200-1",
"결제 취소요청이 접수되었습니다.",
paymentDto
);
}

}
Original file line number Diff line number Diff line change
@@ -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<Void> handleTossWebhook(@RequestBody Map<String, Object> payload) {
log.info("Toss Payments 웹훅 수신: {}", payload);

// Webhook 페이로드에서 이벤트 타입과 주문 ID 추출
String eventType = (String) payload.get("eventType");
Map<String, Object> data = (Map<String, Object>) 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();
}
}
Loading
Loading