diff --git a/.gitignore b/.gitignore index 9a1b16f9..3fcdae40 100644 --- a/.gitignore +++ b/.gitignore @@ -333,4 +333,4 @@ src/test/resources/application-test-secrets.yml src/main/resources/data.sql /scripts/ init.sql -k6-scripts/ +k6-scripts/test.js diff --git a/build.gradle b/build.gradle index cfffb868..550ad900 100644 --- a/build.gradle +++ b/build.gradle @@ -25,17 +25,17 @@ repositories { dependencies { // Spring Boot 핵심 기능 라이브러리 - implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // JPA 및 Hibernate를 통한 데이터 접근 지원 - implementation 'org.springframework.boot:spring-boot-starter-validation' // 데이터 검증을 위한 라이브러리 (Bean Validation) - implementation 'org.springframework.boot:spring-boot-starter-web' // 웹 애플리케이션 개발을 위한 라이브러리 (Spring MVC 포함) - implementation 'org.springframework.boot:spring-boot-starter-websocket' // WebSocket 기능을 제공하는 라이브러리 - implementation 'org.springframework.boot:spring-boot-starter-webflux' // WebFlux 라이브러리 - implementation 'org.springframework.boot:spring-boot-starter-batch' // Spring Batch 의존성 - implementation 'org.springframework.boot:spring-boot-starter-actuator' // Spring 모니터링 - implementation 'org.springframework.boot:spring-boot-starter-aop' // Spring AOP + implementation 'org.springframework.boot:spring-boot-starter-data-jpa' // JPA 및 Hibernate를 통한 데이터 접근 지원 + implementation 'org.springframework.boot:spring-boot-starter-validation' // 데이터 검증을 위한 라이브러리 (Bean Validation) + implementation 'org.springframework.boot:spring-boot-starter-web' // 웹 애플리케이션 개발을 위한 라이브러리 (Spring MVC 포함) + implementation 'org.springframework.boot:spring-boot-starter-websocket' // WebSocket 기능을 제공하는 라이브러리 + implementation 'org.springframework.boot:spring-boot-starter-webflux' // WebFlux 라이브러리 + implementation 'org.springframework.boot:spring-boot-starter-batch' // Spring Batch 의존성 + implementation 'org.springframework.boot:spring-boot-starter-actuator' // Spring 모니터링 + implementation 'org.springframework.boot:spring-boot-starter-aop' // Spring AOP // Spring Security (인증 및 인가 관련 보안 기능) - implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 기본 설정 및 기능 제공 + implementation 'org.springframework.boot:spring-boot-starter-security' // Spring Security 기본 설정 및 기능 제공 testImplementation 'org.springframework.security:spring-security-test' // JWT (JSON Web Token) 관련 라이브러리 @@ -44,29 +44,29 @@ dependencies { implementation 'io.jsonwebtoken:jjwt-jackson:0.11.5' // 테스트 - testImplementation 'org.springframework.boot:spring-boot-starter-test' // Spring Boot의 테스트 도구 모음 (JUnit, Mockito 포함) - testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JUnit 플랫폼 런처 (테스트 실행 환경 제공) - testImplementation "org.testcontainers:testcontainers:1.19.2" // Docker 기반 통합 테스트 지원 (Testcontainers 기본 라이브러리) - testImplementation "org.testcontainers:junit-jupiter:1.19.2" // JUnit 5(Testcontainers 연동) 지원 + testImplementation 'org.springframework.boot:spring-boot-starter-test' // Spring Boot의 테스트 도구 모음 (JUnit, Mockito 포함) + testRuntimeOnly 'org.junit.platform:junit-platform-launcher' // JUnit 플랫폼 런처 (테스트 실행 환경 제공) + testImplementation "org.testcontainers:testcontainers:1.19.2" // Docker 기반 통합 테스트 지원 (Testcontainers 기본 라이브러리) + testImplementation "org.testcontainers:junit-jupiter:1.19.2" // JUnit 5(Testcontainers 연동) 지원 testImplementation 'org.testcontainers:postgresql:1.19.2' // 데이터베이스 - runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스a H2 (테스트 및 개발 환경용) - runtimeOnly 'org.postgresql:postgresql:42.5.2' // PostgreSQL 데이터베이스 드라이버 (운영 환경에서 사용) + runtimeOnly 'com.h2database:h2' // 인메모리 데이터베이스a H2 (테스트 및 개발 환경용) + runtimeOnly 'org.postgresql:postgresql:42.5.2' // PostgreSQL 데이터베이스 드라이버 (운영 환경에서 사용) // Redis - implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Spring Data Redis 라이브러리 - testImplementation 'com.redis:testcontainers-redis:2.2.2' // Redis용 Testcontainers (테스트 환경에서 Redis 컨테이너 실행) - implementation 'org.redisson:redisson-spring-boot-starter:3.23.4' // Redisson 락 적용 + implementation 'org.springframework.boot:spring-boot-starter-data-redis' // Spring Data Redis 라이브러리 + testImplementation 'com.redis:testcontainers-redis:2.2.2' // Redis용 Testcontainers (테스트 환경에서 Redis 컨테이너 실행) + implementation 'org.redisson:redisson-spring-boot-starter:3.23.4' // Redisson 락 적용 // 쿼리DSL - implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' // QueryDSL의 JPA 모듈 - annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" // QueryDSL 코드 자동 생성 도구 - annotationProcessor "jakarta.annotation:jakarta.annotation-api" // Jakarta Annotation API (자바 표준 어노테이션 지원) - annotationProcessor "jakarta.persistence:jakarta.persistence-api" // Jakarta Persistence API (JPA 관련 표준 API 지원) + implementation 'com.querydsl:querydsl-jpa:5.0.0:jakarta' // QueryDSL의 JPA 모듈 + annotationProcessor "com.querydsl:querydsl-apt:5.0.0:jakarta" // QueryDSL 코드 자동 생성 도구 + annotationProcessor "jakarta.annotation:jakarta.annotation-api" // Jakarta Annotation API (자바 표준 어노테이션 지원) + annotationProcessor "jakarta.persistence:jakarta.persistence-api" // Jakarta Persistence API (JPA 관련 표준 API 지원) // AWS - implementation 'software.amazon.awssdk:kms:2.20.0' // AWS KMS 의존성 + implementation 'software.amazon.awssdk:kms:2.20.0' // AWS KMS 의존성 // RabbitMQ implementation 'org.springframework.boot:spring-boot-starter-amqp' @@ -74,12 +74,12 @@ dependencies { testImplementation 'org.testcontainers:rabbitmq' // 기타 - compileOnly 'org.projectlombok:lombok' // Lombok (Getter, Setter, Constructor 자동 생성) - annotationProcessor 'org.projectlombok:lombok' // Lombok 사용을 위한 어노테이션 프로세서 - implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' // Swagger Ui - implementation 'io.projectreactor.netty:reactor-netty:1.1.22' // Reactor Netty 의존성 - implementation 'com.fasterxml.jackson.core:jackson-databind' // JSON 파싱을 위한 의존성 - implementation 'com.google.code.gson:gson:2.10.1' // JSON 데이터를 다룰 수 있는 클래스 + compileOnly 'org.projectlombok:lombok' // Lombok (Getter, Setter, Constructor 자동 생성) + annotationProcessor 'org.projectlombok:lombok' // Lombok 사용을 위한 어노테이션 프로세서 + implementation 'org.springdoc:springdoc-openapi-starter-webmvc-ui:2.8.4' // Swagger Ui + implementation 'io.projectreactor.netty:reactor-netty:1.1.22' // Reactor Netty 의존성 + implementation 'com.fasterxml.jackson.core:jackson-databind' // JSON 파싱을 위한 의존성 + implementation 'com.google.code.gson:gson:2.10.1' // JSON 데이터를 다룰 수 있는 클래스 } tasks.named('test') { diff --git a/docker-compose.yml b/docker-compose.yml index 9d2033e5..06284c2a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -98,7 +98,7 @@ services: - ./k6-scripts:/k6-scripts # K6 스크립트가 있는 디렉토리 environment: - K6_OUT=influxdb=http://influxdb:8086/k6 - command: ["run", "/k6-scripts/.."] + command: ["run", "/k6-scripts/test.js"] # 🔹 Spring Boot 애플리케이션 app: diff --git a/k6-scripts/cardFunctions.js b/k6-scripts/cardFunctions.js new file mode 100644 index 00000000..a8fee581 --- /dev/null +++ b/k6-scripts/cardFunctions.js @@ -0,0 +1,112 @@ +import http from 'k6/http'; +import { check, sleep } from 'k6'; + +// 실물 카드 발급 함수 +export function generatePhysicalCard(BASE_URL, authParams) { + let generateCardRes = http.post(`${BASE_URL}/api/v1/card/physical`, null, authParams, { + tags: { method: 'POST', endpoint: 'generatePhysicalCard' }, + }); + + check(generateCardRes, { + 'Card Generation Success': (r) => r.status === 201, + }); + + if (generateCardRes.status !== 201) { + return null; + } + + return generateCardRes; +} + +// 카드 상태 변경 함수 +export function changeCardStatus(BASE_URL, authParams, cardType, status) { + let changeCardStatusPayload = JSON.stringify({ + cardType: cardType, + status: status + }); + + let changeCardStatusRes = http.put(`${BASE_URL}/api/v1/card/status`, changeCardStatusPayload, authParams, { + tags: { method: 'PUT', endpoint: 'changeCardStatus' }, + }); + + check(changeCardStatusRes, { + 'Card Status Change Success': (r) => r.status === 204, + }); + + if (changeCardStatusRes.status !== 204) { + return null; + } + + return changeCardStatusRes; +} + +// 카드 목록 조회 함수 +export function getCardList(BASE_URL, authParams) { + let cardListRes = http.get(`${BASE_URL}/api/v1/card`, authParams, { + tags: {method: 'GET', endpoint: 'getCardList'}, + }); + + check(cardListRes, { + 'Card List Fetch Success': (r) => r.status === 200, + }); + + if (cardListRes.status !== 200) { + return null; + } + + let cards = JSON.parse(cardListRes.body); + + if (cards.length === 0) { + return null; + } + + // 첫 번째 카드의 cardId 추출 + let firstCard = cards[0]; + let cardId = firstCard.cardId; + + if (!cardId) { + return null; + } + + return { + cardId: cardId + }; +} + +// 카드 상세 정보 조회 함수 +export function getCardDetail(BASE_URL, authParams, cardId) { + let cardDetailRes = http.get(`${BASE_URL}/api/v1/card/${cardId}`, authParams, { + tags: { method: 'GET', endpoint: 'getCardDetail' }, + }); + + check(cardDetailRes, { + 'Card Detail Fetch Success': (r) => r.status === 200, + }); + + if (cardDetailRes.status !== 200) { + return null; + } + + return cardDetailRes; +} + +// 카드 상세 정보 조회 100번 반복 함수 +export function getCardDetailRepeated(BASE_URL, authParams, cardId, repeatCount) { + for (let i = 0; i < repeatCount; i++) { + let cardDetailRes = http.get(`${BASE_URL}/api/v1/card/${cardId}`, authParams, { + tags: { method: 'GET', endpoint: 'getCardDetail' }, + }); + + check(cardDetailRes, { + 'Card Detail Fetch Success': (r) => r.status === 200, + }); + + if (cardDetailRes.status === 200) { + console.error(`[${i + 1}/${repeatCount}] 카드 상세 정보 조회 성공`); + } else { + console.error(`[${i + 1}/${repeatCount}] 카드 상세 정보 조회 실패`); + } + + sleep(0.1); + } +} \ No newline at end of file diff --git a/k6-scripts/userFunctions.js b/k6-scripts/userFunctions.js new file mode 100644 index 00000000..5afb0705 --- /dev/null +++ b/k6-scripts/userFunctions.js @@ -0,0 +1,146 @@ +import http from 'k6/http'; +import { randomString, randomIntBetween } from 'https://jslib.k6.io/k6-utils/1.2.0/index.js'; +import { check } from 'k6'; + +// 회원가입 함수 +export function signupUser(BASE_URL) { + let userEmail = `user_${randomString(10)}@test.com`; + let userPwd = 'Test1234!'; + let userName = `홍길동`; + let userPhoneNumber = `010-${randomIntBetween(1000, 9999)}-${randomIntBetween(1000, 9999)}`; + let userSex = 'MALE'; + let walletPassword = '1234'; + + let signupPayload = JSON.stringify({ + userEmail: userEmail, + userPwd: userPwd, + userName: userName, + userPhoneNumber: userPhoneNumber, + userSex: userSex, + walletPassword: walletPassword + }); + + let params = { headers: { 'Content-Type': 'application/json' } }; + + let signupRes = http.post(`${BASE_URL}/api/v1/user/signup`, signupPayload, params, { + tags: { method: 'POST', endpoint: 'signup' }, + }); + + check(signupRes, { + 'Signup success': (r) => r.status === 201, + }); + + if (signupRes.status !== 201) { + return null; // 회원가입 실패 시 null 반환 + } + + return { userEmail, userPwd }; // 로그인에 필요한 userEmail, userPwd 반환 +} + +// 로그인 함수 +export function loginUser(BASE_URL, userEmail, userPwd) { + let loginPayload = JSON.stringify({ + userEmail: userEmail, + password: userPwd + }); + + let params = { headers: { 'Content-Type': 'application/json' } }; + + let loginRes = http.post(`${BASE_URL}/login`, loginPayload, params, { + tags: { method: 'POST', endpoint: 'login' }, + }); + + check(loginRes, { + 'Login success': (r) => r.status === 200, + }); + + if (loginRes.status !== 200) { + return null; // 로그인 실패 시 null 반환 + } + + let authToken = JSON.parse(loginRes.body).accessToken; + + if (!authToken) { + return null; // JWT 토큰 없을 경우 null 반환 + } + + return authToken; // 로그인 후 JWT 토큰 반환 +} + +// 내 정보 조회 함수 +export function getUserInfo(BASE_URL, authToken) { + let authParams = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + } + }; + + let userRes = http.get(`${BASE_URL}/api/v1/user`, authParams, { + tags: { method: 'GET', endpoint: 'user' }, + }); + + check(userRes, { + 'Get User Info success': (r) => r.status === 200, + }); + + if (userRes.status !== 200) { + return null; // 정보 조회 실패 시 null 반환 + } + + return userRes.body; // 응답 본문 반환 +} + +// 내 정보 수정 함수 +export function updateUserInfo(BASE_URL, authToken) { + let updatePayload = JSON.stringify({ + userNickname: `Test_${randomString(8)}`, + userAge: 24, + userSex: "FEMALE" + }); + + let authParams = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + } + }; + + let updateRes = http.put(`${BASE_URL}/api/v1/user`, updatePayload, authParams, { + tags: { method: 'PUT', endpoint: 'user' }, + }); + + check(updateRes, { + 'Update User Info success': (r) => r.status === 204, // No Content 응답 예상 + }); + + if (updateRes.status !== 204) { + return null; // 수정 실패 시 null 반환 + } + + return updateRes.status; +} + +// 회원 탈퇴 함수 +export function deleteUser(BASE_URL, authToken) { + let authParams = { + headers: { + 'Content-Type': 'application/json', + 'Authorization': `Bearer ${authToken}` + } + }; + + let deleteRes = http.delete(`${BASE_URL}/api/v1/user`, null, authParams, { + tags: { method: 'DELETE', endpoint: 'user' }, + }); + + check(deleteRes, { + 'Delete User success': (r) => r.status === 204, // No Content 응답 예상 + }); + + if (deleteRes.status !== 204) { + return null; // 탈퇴 실패 시 null 반환 + } + + return deleteRes.status; +} diff --git a/src/main/java/bumblebee/xchangepass/domain/card/controller/CardPaymentController.java b/src/main/java/bumblebee/xchangepass/domain/card/controller/CardPaymentController.java new file mode 100644 index 00000000..253be2b3 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/card/controller/CardPaymentController.java @@ -0,0 +1,42 @@ +package bumblebee.xchangepass.domain.card.controller; + +import bumblebee.xchangepass.domain.card.dto.request.PaymentRequest; +import bumblebee.xchangepass.domain.card.dto.response.PaymentResponse; +import bumblebee.xchangepass.domain.card.service.CardPaymentService; +import bumblebee.xchangepass.global.error.ErrorCode; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/card/payment") +@Tag(name = "Card Payment", description = "카드 결제 API") +public class CardPaymentController { + + private final CardPaymentService cardPaymentService; + + @Operation(summary = "카드 결제 요청", description = "카드 정보를 검증하고 결제를 처리합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "결제 성공"), + @ApiResponse(responseCode = "400", description = "결제 실패", + content = @Content(mediaType = "application/json", + schema = @Schema(implementation = ErrorCode.class), + examples = @ExampleObject(value = "{\n \"code\": \"B001\"," + + "\n \"message\": \"잔액이 부족합니다.\"\n}")) + ) + }) + @ResponseStatus(HttpStatus.OK) + @PostMapping + public PaymentResponse processPayment(@RequestBody @Valid PaymentRequest request) { + return cardPaymentService.processPayment(request); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/card/dto/request/PaymentRequest.java b/src/main/java/bumblebee/xchangepass/domain/card/dto/request/PaymentRequest.java new file mode 100644 index 00000000..753941aa --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/card/dto/request/PaymentRequest.java @@ -0,0 +1,58 @@ +package bumblebee.xchangepass.domain.card.dto.request; + +import bumblebee.xchangepass.domain.card.entity.CardType; +import bumblebee.xchangepass.domain.cardTransaction.entity.TransactionType; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.*; + +import java.math.BigDecimal; +import java.util.Currency; + +@Schema(description = "결제 요청 객체") +public record PaymentRequest( + + @Schema(description = "사용자 이름", example = "홍길동") + @NotBlank(message = "사용자 이름은 필수 입력 값입니다.") + String userName, + + @Schema(description = "사용자 전화번호", example = "010-0000-0001") + @NotBlank(message = "전화번호는 필수 입력 값입니다.") + @Pattern(regexp = "^010-\\d{4}-\\d{4}$", message = "전화번호는 010-0000-0000 형식이어야 합니다.") + String phoneNumber, + + @Schema(description = "카드 번호", example = "8734-7469-4121-3667") + @NotBlank(message = "카드 번호는 필수 입력 값입니다.") + @Pattern(regexp = "^\\d{4}-\\d{4}-\\d{4}-\\d{4}$", message = "카드 번호는 XXXX-XXXX-XXXX-XXXX 형식이어야 합니다.") + String cardNumber, + + @Schema(description = "CVC 코드", example = "123") + @NotBlank(message = "CVC는 필수 입력 값입니다.") + String cvc, + + @Schema(description = "카드 타입", example = "PHYSICAL") + @NotNull(message = "카드 타입은 필수 입력 값입니다.") + CardType cardType, + + @Schema(description = "결제 금액", example = "100.00") + @DecimalMin(value = "0.01", message = "결제 금액은 0.01 이상이어야 합니다.") + @Digits(integer = 10, fraction = 2, message = "소수점 둘째 자리까지 입력 가능합니다.") + @NotNull(message = "결제 금액은 필수 입력 값입니다.") + BigDecimal amount, + + @Schema(description = "결제 통화", example = "USD") + @NotNull(message = "결제 통화는 필수 입력 값입니다.") + Currency currency, + + @Schema(description = "가맹점 이름", example = "XChangeMart") + @NotBlank(message = "가맹점 이름은 필수 입력 값입니다.") + String merchantName, + + @Schema(description = "지갑 비밀번호", example = "1234") + @NotBlank(message = "지갑 비밀번호는 필수 입력 값입니다.") + String walletPassword, + + @Schema(description = "거래 유형", example = "PAYMENT") + @NotNull(message = "거래 유형은 필수 입력 값입니다.") + TransactionType transactionType + +) {} diff --git a/src/main/java/bumblebee/xchangepass/domain/card/dto/response/BasicCardInfoResponse.java b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/BasicCardInfoResponse.java index 5eb119cb..b91da7c3 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/dto/response/BasicCardInfoResponse.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/BasicCardInfoResponse.java @@ -9,6 +9,9 @@ @Schema(description = "기본 카드 정보 응답 DTO") @Builder public record BasicCardInfoResponse( + @Schema(description = "카드 ID", example = "1") + Long cardId, + @Schema(description = "카드 타입", example = "PHYSICAL") CardType cardType, @@ -34,6 +37,7 @@ public static String maskCardNumber(String cardNumber) { */ public static BasicCardInfoResponse from(DetailedCardInfoResponse detailedInfo) { return BasicCardInfoResponse.builder() + .cardId(detailedInfo.cardId()) .cardType(detailedInfo.cardType()) .cardStatus(detailedInfo.cardStatus()) .maskedCardNumber(maskCardNumber(detailedInfo.cardNumber())) diff --git a/src/main/java/bumblebee/xchangepass/domain/card/dto/response/DetailedCardInfoResponse.java b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/DetailedCardInfoResponse.java index a605cece..860c783e 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/dto/response/DetailedCardInfoResponse.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/DetailedCardInfoResponse.java @@ -12,6 +12,9 @@ @Schema(description = "세부 카드 정보 응답 DTO") @Builder public record DetailedCardInfoResponse( + @Schema(description = "카드 ID", example = "1") + Long cardId, + @Schema(description = "카드 타입", example = "PHYSICAL") CardType cardType, diff --git a/src/main/java/bumblebee/xchangepass/domain/card/dto/response/PaymentResponse.java b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/PaymentResponse.java new file mode 100644 index 00000000..6a4c56f5 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/card/dto/response/PaymentResponse.java @@ -0,0 +1,58 @@ +package bumblebee.xchangepass.domain.card.dto.response; + +import bumblebee.xchangepass.domain.cardTransaction.dto.request.PaymentApprovedEvent; +import bumblebee.xchangepass.domain.cardTransaction.entity.TransactionType; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "결제 응답 객체") +public record PaymentResponse( + + @Schema(description = "사용자 이름", example = "홍길동") + String userName, + + @Schema(description = "가맹점 이름", example = "XChangeMart") + String merchantName, + + @Schema(description = "승인된 금액", example = "100.00") + BigDecimal approvedAmount, + + @Schema(description = "통화", example = "USD") + Currency approvedCurrency, + + @Schema(description = "KRW 환산 금액", example = "134500") + BigDecimal krwAmount, + + @Schema(description = "결제 승인 시각", example = "2025-04-04T14:30:00") + LocalDateTime transactionTime, + + @Schema(description = "승인 번호", example = "A1B2C3D4E5F6") + String approvalNumber, + + @Schema(description = "잔액", example = "865500") + BigDecimal balanceAfter, + + @Schema(description = "거래 유형", example = "PAYMENT") + TransactionType transactionType + +) { + + public static PaymentResponse fromEvent(PaymentApprovedEvent event) { + return new PaymentResponse( + event.user().getUserName().getValue(), + event.merchantName(), + event.approvedAmount(), + event.approvedCurrency(), + event.krwAmount(), + event.transactionTime(), + event.approvalNumber(), + event.balanceAfter(), + event.transactionType() + ); + } + +} + diff --git a/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java b/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java new file mode 100644 index 00000000..99347039 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/card/service/CardPaymentService.java @@ -0,0 +1,117 @@ +package bumblebee.xchangepass.domain.card.service; + +import bumblebee.xchangepass.domain.ExchangeRate.dto.response.ExchangeRateResponse; +import bumblebee.xchangepass.domain.ExchangeRate.service.ExchangeService; +import bumblebee.xchangepass.domain.card.dto.request.PaymentRequest; +import bumblebee.xchangepass.domain.card.dto.response.PaymentResponse; +import bumblebee.xchangepass.domain.card.entity.CardStatus; +import bumblebee.xchangepass.domain.cardTransaction.dto.request.PaymentApprovedEvent; +import bumblebee.xchangepass.domain.user.entity.User; +import bumblebee.xchangepass.domain.user.repository.UserRepository; +import bumblebee.xchangepass.domain.wallet.entity.Wallet; +import bumblebee.xchangepass.domain.walletBalance.entity.WalletBalance; +import bumblebee.xchangepass.domain.walletBalance.service.WalletBalanceService; +import bumblebee.xchangepass.global.error.ErrorCode; +import bumblebee.xchangepass.global.security.crypto.AESEncryption; +import bumblebee.xchangepass.global.security.crypto.RSAEncryption; +import bumblebee.xchangepass.global.util.EventPublisher; +import lombok.RequiredArgsConstructor; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import javax.crypto.SecretKey; +import java.math.BigDecimal; +import java.math.RoundingMode; +import java.util.Currency; +import java.util.UUID; + +@Service +@RequiredArgsConstructor +public class CardPaymentService { + + private final UserRepository userRepository; + private final WalletBalanceService walletBalanceService; + private final ExchangeService exchangeService; + private final RSAEncryption rsaEncryption; + private final PasswordEncoder passwordEncoder; + private final EventPublisher eventPublisher; + + /** + * ✅ 카드 결제 요청 처리 + */ + @Transactional + public PaymentResponse processPayment(PaymentRequest request) { + User user = userRepository.findByNameAndPhoneNumber(request.userName(), request.phoneNumber()) + .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); + + if (!isCardValid(user, request)) { + throw ErrorCode.CARD_NOT_FOUND.commonException(); + } + + Wallet wallet = user.getWallet(); + + if (!passwordEncoder.matches(request.walletPassword(), wallet.getWalletPassword())) { + throw ErrorCode.INVALID_WALLET_PASSWORD.commonException(); + } + + WalletBalance balance = walletBalanceService.findBalanceWithLock(wallet.getWalletId(), request.currency()); + + if (balance.getBalance().compareTo(request.amount()) < 0) { + throw ErrorCode.BALANCE_NOT_AVAILABLE.commonException(); + } + + walletBalanceService.withdrawBalance(balance, request.amount()); + BigDecimal krwAmount = calculateKrw(request.amount(), request.currency()); + + PaymentApprovedEvent event = PaymentApprovedEvent.of( + user, + request, + krwAmount, + balance.getBalance(), + generateApprovalNumber() + ); + + eventPublisher.publishEvent(event); + + return PaymentResponse.fromEvent(event); + } + + /** + * ✅ 환율 계산 (기준 통화 → KRW) + */ + private BigDecimal calculateKrw(BigDecimal amount, Currency currency) { + ExchangeRateResponse response = exchangeService.getExchangeRateForCountry(currency.getCurrencyCode(), "KRW"); + + Double rate = response.conversionRates().get("KRW"); + + if (rate == null) { + throw ErrorCode.EXCHANGE_RATE_NOT_FOUND.commonException(); + } + + BigDecimal rateDecimal = BigDecimal.valueOf(rate); + return amount.multiply(rateDecimal).setScale(2, RoundingMode.HALF_UP); + } + + /** + * ✅ 승인 번호 생성 + */ + private String generateApprovalNumber() { + return UUID.randomUUID().toString().replace("-", "").substring(0, 12).toUpperCase(); + } + + /** + * ✅ 카드 유효성 검사 + */ + public boolean isCardValid(User user, PaymentRequest request) { + return user.getWallet().getCards().stream() + .filter(c -> c.getCardType() == request.cardType() && c.getCardStatus() == CardStatus.ACTIVE) + .anyMatch(c -> { + SecretKey key = rsaEncryption.decryptAESKeyWithKMS(c.getEncryptionData().getEncryptedAesKey()); + String number = AESEncryption.decryptData(c.getCardNumber(), key, c.getEncryptionData().getIv()); + String cvc = AESEncryption.decryptData(c.getCvc(), key, c.getEncryptionData().getIv()); + return number.equals(request.cardNumber()) && cvc.equals(request.cvc()); + }); + } + +} diff --git a/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java b/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java index 9fc3cd8c..8f28f58d 100644 --- a/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java +++ b/src/main/java/bumblebee/xchangepass/domain/card/service/CardService.java @@ -70,11 +70,16 @@ public void generateMobileCard(Wallet wallet) { /** * ✅ 실물 카드 발급 */ + @Transactional public void generatePhysicalCard(Long userId) { User existUser = userRepository.findByUserId(userId) .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); + if(existUser.getWallet().getCards().stream().anyMatch(c -> c.getCardType().equals(CardType.PHYSICAL))) { + throw ErrorCode.ALREADY_ISSUED_PHYSICAL_CARD.commonException(); + } + String cardNumber = cardFactory.generateCardNumber(); String cvc = cardFactory.generateCvc(); @@ -141,6 +146,7 @@ public void changeCardStatus(Long userId,ChangeCardStatusRequest request) { /** * ✅ 카드 관리 - 보유 카드 목록 조회 */ + @Transactional(readOnly = true) public List getBasicCardInfo(Long userId) { User existUser = userRepository.findByUserId(userId) .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); @@ -189,6 +195,7 @@ private DetailedCardInfoResponse cacheCardInfo(Card card) { String decryptedCvc = AESEncryption.decryptData(card.getCvc(), decryptedAESKey, card.getEncryptionData().getIv()); DetailedCardInfoResponse cardInfoDTO = DetailedCardInfoResponse.builder() + .cardId(card.getCardId()) .cardType(card.getCardType()) .cardStatus(card.getCardStatus()) .cardNumber(decryptedCardNumber) diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/controller/CardTransactionController.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/controller/CardTransactionController.java new file mode 100644 index 00000000..5a43bca8 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/controller/CardTransactionController.java @@ -0,0 +1,65 @@ +package bumblebee.xchangepass.domain.cardTransaction.controller; + +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionDetailResponse; +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionSummaryResponse; +import bumblebee.xchangepass.domain.cardTransaction.service.CardTransactionService; +import bumblebee.xchangepass.global.common.CursorResponse; +import bumblebee.xchangepass.global.security.jwt.CustomUserDetails; +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.springframework.http.HttpStatus; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/v1/card/transactions") +@Tag(name = "CardTransaction", description = "카드 거래내역 관련 API") +public class CardTransactionController { + + private final CardTransactionService cardTransactionService; + + @Operation(summary = "거래내역 무한 스크롤 조회", description = "사용자의 카드 거래내역을 최신순으로 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "400", description = "조회 실패", content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n \"code\": \"T001\",\n \"message\": \"거래내역을 찾을 수 없습니다.\"\n}"))) + }) + @ResponseStatus(HttpStatus.OK) + @GetMapping + public CursorResponse getTransactions( + @AuthenticationPrincipal CustomUserDetails user, + @RequestParam(required = false) Long lastTransactionId, + @RequestParam(defaultValue = "10") int size) { + + List transactions = + cardTransactionService.getUserTransactions(user.getUserId(), lastTransactionId, size); + + Long nextCursor = transactions.isEmpty() ? null : + transactions.get(transactions.size() - 1).transactionId(); + + return CursorResponse.of(transactions, nextCursor); + } + + @Operation(summary = "거래내역 상세 조회", description = "사용자의 특정 거래내역을 상세 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "조회 성공", content = @Content(mediaType = "application/json")), + @ApiResponse(responseCode = "404", description = "거래내역 없음", content = @Content(mediaType = "application/json", + examples = @ExampleObject(value = "{\n \"code\": \"T002\",\n \"message\": \"해당 거래내역을 찾을 수 없습니다.\"\n}"))) + }) + @ResponseStatus(HttpStatus.OK) + @GetMapping("/{transactionId}") + public CardTransactionDetailResponse getTransactionDetail( + @AuthenticationPrincipal CustomUserDetails user, + @PathVariable Long transactionId) { + + return cardTransactionService.getTransactionDetail(user.getUserId(), transactionId); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/request/PaymentApprovedEvent.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/request/PaymentApprovedEvent.java new file mode 100644 index 00000000..72b69da4 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/request/PaymentApprovedEvent.java @@ -0,0 +1,69 @@ +package bumblebee.xchangepass.domain.cardTransaction.dto.request; + +import bumblebee.xchangepass.domain.card.dto.request.PaymentRequest; +import bumblebee.xchangepass.domain.cardTransaction.entity.TransactionType; +import bumblebee.xchangepass.domain.user.entity.User; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import lombok.Builder; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "결제 승인 이벤트 데이터") +@Builder +public record PaymentApprovedEvent( + + @Schema(description = "유저 객체", implementation = User.class) + @NotNull(message = "유저는 null일 수 없습니다.") + User user, + + @Schema(description = "가맹점 이름", example = "XChangeMart") + @NotBlank(message = "가맹점 이름은 필수입니다.") + String merchantName, + + @Schema(description = "승인된 금액", example = "100.00") + @NotNull(message = "승인 금액은 필수입니다.") + BigDecimal approvedAmount, + + @Schema(description = "승인된 통화", example = "USD") + @NotNull(message = "승인 통화는 필수입니다.") + Currency approvedCurrency, + + @Schema(description = "KRW 환산 금액", example = "130000") + @NotNull(message = "KRW 금액은 필수입니다.") + BigDecimal krwAmount, + + @Schema(description = "결제 승인 시각", example = "2025-04-04T12:34:56") + @NotNull(message = "결제 시간은 필수입니다.") + LocalDateTime transactionTime, + + @Schema(description = "승인 번호", example = "APRV-20250404123456") + @NotBlank(message = "승인 번호는 필수입니다.") + String approvalNumber, + + @Schema(description = "잔액", example = "500000") + @NotNull(message = "잔액은 필수입니다.") + BigDecimal balanceAfter, + + @Schema(description = "거래 유형", example = "PAYMENT") + @NotNull(message = "거래 유형은 필수입니다.") + TransactionType transactionType + +) { + public static PaymentApprovedEvent of(User user, PaymentRequest request, BigDecimal krwAmount, BigDecimal afterBalance, String approvalNumber) { + return PaymentApprovedEvent.builder() + .user(user) + .merchantName(request.merchantName()) + .approvedAmount(request.amount()) + .approvedCurrency(request.currency()) + .krwAmount(krwAmount) + .transactionTime(LocalDateTime.now()) + .approvalNumber(approvalNumber) + .balanceAfter(afterBalance) + .transactionType(request.transactionType()) + .build(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionDetailResponse.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionDetailResponse.java new file mode 100644 index 00000000..239fcce1 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionDetailResponse.java @@ -0,0 +1,53 @@ +package bumblebee.xchangepass.domain.cardTransaction.dto.response; + +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import bumblebee.xchangepass.domain.cardTransaction.entity.TransactionType; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "거래내역 상세 응답 DTO") +public record CardTransactionDetailResponse( + + @Schema(description = "가맹점 이름", example = "LAWSON") + String merchantName, + + @Schema(description = "결제 금액", example = "4385.00") + BigDecimal approvedAmount, + + @Schema(description = "결제 통화", example = "JPY") + Currency approvedCurrency, + + @Schema(description = "KRW 환산 금액", example = "3769.50") + BigDecimal krwAmount, + + @Schema(description = "승인 번호", example = "APRV-20250404123456") + String approvalNumber, + + @Schema(description = "거래 일시", example = "2025-01-03T16:10:00") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime transactionTime, + + @Schema(description = "잔액", example = "500000") + BigDecimal balanceAfter, + + @Schema(description = "거래 유형", example = "PAYMENT") + TransactionType transactionType + +) { + public static CardTransactionDetailResponse from(CardTransaction tx) { + return new CardTransactionDetailResponse( + tx.getMerchantName(), + tx.getApprovedAmount(), + tx.getApprovedCurrency(), + tx.getKrwAmount(), + tx.getApprovalNumber(), + tx.getTransactionTime(), + tx.getBalanceAfter(), + tx.getTransactionType() + ); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionSummaryResponse.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionSummaryResponse.java new file mode 100644 index 00000000..672db544 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/dto/response/CardTransactionSummaryResponse.java @@ -0,0 +1,45 @@ +package bumblebee.xchangepass.domain.cardTransaction.dto.response; + +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import bumblebee.xchangepass.domain.cardTransaction.entity.TransactionType; +import com.fasterxml.jackson.annotation.JsonFormat; +import io.swagger.v3.oas.annotations.media.Schema; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +@Schema(description = "거래내역 요약 응답 DTO") +public record CardTransactionSummaryResponse( + + @Schema(description = "거래내역 ID", example = "42") + Long transactionId, + + @Schema(description = "가맹점 이름", example = "FAMILYMART") + String merchantName, + + @Schema(description = "결제 금액", example = "2980.00") + BigDecimal approvedAmount, + + @Schema(description = "결제 통화", example = "JPY") + Currency approvedCurrency, + + @Schema(description = "결제 일시", example = "2025-01-03T15:45:00") + @JsonFormat(shape = JsonFormat.Shape.STRING, pattern = "yyyy-MM-dd'T'HH:mm:ss") + LocalDateTime transactionTime, + + @Schema(description = "거래 유형", example = "PAYMENT") + TransactionType transactionType + +) { + public static CardTransactionSummaryResponse from(CardTransaction tx) { + return new CardTransactionSummaryResponse( + tx.getTransactionId(), + tx.getMerchantName(), + tx.getApprovedAmount(), + tx.getApprovedCurrency(), + tx.getTransactionTime(), + tx.getTransactionType() + ); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/CardTransaction.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/CardTransaction.java new file mode 100644 index 00000000..8e64fb10 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/CardTransaction.java @@ -0,0 +1,83 @@ +package bumblebee.xchangepass.domain.cardTransaction.entity; + +import bumblebee.xchangepass.domain.card.entity.Card; +import bumblebee.xchangepass.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.math.BigDecimal; +import java.time.LocalDateTime; +import java.util.Currency; + +import static lombok.AccessLevel.PROTECTED; + +@Entity +@Getter +@NoArgsConstructor(access = PROTECTED) +@Table(name = "card_trainsaction") +@EntityListeners(AuditingEntityListener.class) +public class CardTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "transaction_id") + private Long transactionId; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "merchant_name", nullable = false, length = 100) + private String merchantName; + + @Column(name = "approved_amount", nullable = false) + private BigDecimal approvedAmount; + + @Column(name = "approved_currency", nullable = false, length = 3) + private Currency approvedCurrency; + + @Column(name = "krw_amount", nullable = false) + private BigDecimal krwAmount; + + @Column(name = "transaction_time", nullable = false) + private LocalDateTime transactionTime; + + @Column(name = "approval_number", nullable = false, length = 20) + private String approvalNumber; + + @Column(name = "balance_after", nullable = false) + private BigDecimal balanceAfter; + + @Enumerated(EnumType.STRING) + @Column(name = "transaction_type", nullable = false, length = 20) + private TransactionType transactionType; + + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + @Builder + public CardTransaction(User user, + String merchantName, + BigDecimal approvedAmount, + Currency approvedCurrency, + BigDecimal krwAmount, + LocalDateTime transactionTime, + String approvalNumber, + BigDecimal balanceAfter, + TransactionType transactionType) { + this.user = user; + this.merchantName = merchantName; + this.approvedAmount = approvedAmount; + this.approvedCurrency = approvedCurrency; + this.krwAmount = krwAmount; + this.transactionTime = transactionTime; + this.approvalNumber = approvalNumber; + this.balanceAfter = balanceAfter; + this.transactionType = transactionType; + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/TransactionType.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/TransactionType.java new file mode 100644 index 00000000..c7eb10b7 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/entity/TransactionType.java @@ -0,0 +1,8 @@ +package bumblebee.xchangepass.domain.cardTransaction.entity; + +public enum TransactionType { + PAYMENT, + DEPOSIT, + REFUND, + TRANSFER; +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepository.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepository.java new file mode 100644 index 00000000..252d80c2 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepository.java @@ -0,0 +1,8 @@ +package bumblebee.xchangepass.domain.cardTransaction.repository; + +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface CardTransactionRepository extends JpaRepository, CardTransactionRepositoryCustom { + +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryCustom.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryCustom.java new file mode 100644 index 00000000..c0301b2a --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryCustom.java @@ -0,0 +1,13 @@ +package bumblebee.xchangepass.domain.cardTransaction.repository; + +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionSummaryResponse; +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface CardTransactionRepositoryCustom { + + //무한 스크롤 거래내역 + List getUserTransactions(Long userId, Long lastTransactionId, int size); +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryImpl.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryImpl.java new file mode 100644 index 00000000..001cadbe --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/repository/CardTransactionRepositoryImpl.java @@ -0,0 +1,48 @@ +package bumblebee.xchangepass.domain.cardTransaction.repository; + +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionSummaryResponse; +import bumblebee.xchangepass.domain.cardTransaction.entity.QCardTransaction; +import com.querydsl.core.BooleanBuilder; +import com.querydsl.jpa.impl.JPAQueryFactory; +import lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Repository; + +import java.util.List; + +@Repository +@RequiredArgsConstructor +public class CardTransactionRepositoryImpl implements CardTransactionRepositoryCustom { + + private final JPAQueryFactory queryFactory; + + private static final QCardTransaction cardTransaction = QCardTransaction.cardTransaction; + + /** + * ✅ 사용자 거래내역 무한 스크롤 조회 (최신순) + */ + @Override + public List getUserTransactions(Long userId, Long lastTransactionId, int size) { + BooleanBuilder builder = new BooleanBuilder(); + builder.and(cardTransaction.user.userId.eq(userId)); + + if (lastTransactionId != null) { + builder.and(cardTransaction.transactionId.lt(lastTransactionId)); + } + + return queryFactory + .select(com.querydsl.core.types.Projections.constructor( + CardTransactionSummaryResponse.class, + cardTransaction.transactionId, + cardTransaction.merchantName, + cardTransaction.approvedAmount, + cardTransaction.approvedCurrency, + cardTransaction.transactionTime, + cardTransaction.transactionType + )) + .from(cardTransaction) + .where(builder) + .orderBy(cardTransaction.transactionId.desc()) + .limit(size) + .fetch(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/domain/cardTransaction/service/CardTransactionService.java b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/service/CardTransactionService.java new file mode 100644 index 00000000..ac2b3c34 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/domain/cardTransaction/service/CardTransactionService.java @@ -0,0 +1,84 @@ +package bumblebee.xchangepass.domain.cardTransaction.service; + +import bumblebee.xchangepass.domain.cardTransaction.dto.request.PaymentApprovedEvent; +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionDetailResponse; +import bumblebee.xchangepass.domain.cardTransaction.dto.response.CardTransactionSummaryResponse; +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; +import bumblebee.xchangepass.domain.cardTransaction.repository.CardTransactionRepository; +import bumblebee.xchangepass.global.error.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.event.EventListener; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@RequiredArgsConstructor +@Slf4j +public class CardTransactionService { + + private final CardTransactionRepository transactionRepository; + + /** + * ✅ 결제 승인 이벤트 수신 시 거래내역 생성 + * {@link PaymentApprovedEvent} 이벤트를 비동기로 수신 + */ + @Async("asyncExecutor") + @Transactional + @EventListener(PaymentApprovedEvent.class) + public void handlePaymentApprovedEvent(PaymentApprovedEvent event) { + CardTransaction transaction = CardTransaction.builder() + .user(event.user()) + .merchantName(event.merchantName()) + .approvedAmount(event.approvedAmount()) + .approvedCurrency(event.approvedCurrency()) + .krwAmount(event.krwAmount()) + .transactionTime(event.transactionTime()) + .approvalNumber(event.approvalNumber()) + .balanceAfter(event.balanceAfter()) + .transactionType(event.transactionType()) + .build(); + + transactionRepository.save(transaction); + + log.info("💾 거래내역 저장 완료 - 승인번호: {}", event.approvalNumber()); + } + + /** + * ✅ 거래 내역 무한 스크롤 조회 (커서 기반 최신순) + */ + public List getUserTransactions(Long userId, Long lastTransactionId, int size) { + + List transactions = + transactionRepository.getUserTransactions(userId, lastTransactionId, size); + + if (transactions.isEmpty()) { + throw ErrorCode.CARD_TRANSACTION_NOT_FOUND.commonException(); + } + + return transactions; + } + + + /** + * ✅ 개별 거래 내역 상세 조회 + */ + @Transactional(readOnly = true) + public CardTransactionDetailResponse getTransactionDetail(Long loginUserId, Long transactionId) { + CardTransaction transaction = transactionRepository.findById(transactionId) + .orElseThrow(ErrorCode.CARD_TRANSACTION_NOT_FOUND::commonException); + + if (!transaction.getUser().getUserId().equals(loginUserId)) { + throw ErrorCode.USER_FORBIDDEN.commonException(); + } + + return CardTransactionDetailResponse.from(transaction); + } + +} diff --git a/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java b/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java index d9cd4a59..3b3feec0 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/entity/User.java @@ -1,6 +1,7 @@ package bumblebee.xchangepass.domain.user.entity; -import bumblebee.xchangepass.domain.exchangeTransaction.entitiy.ExchangeTransaction; +import bumblebee.xchangepass.domain.ExchangeTransaction.entitiy.ExchangeTransaction; +import bumblebee.xchangepass.domain.cardTransaction.entity.CardTransaction; import bumblebee.xchangepass.domain.user.entity.value.*; import bumblebee.xchangepass.domain.wallet.wallet.entity.Wallet; import jakarta.persistence.*; @@ -64,12 +65,15 @@ public class User { @Column(name = "is_deleted") private Boolean isDeleted; - @OneToOne(mappedBy ="user", orphanRemoval = true, cascade = CascadeType.ALL) + @OneToOne(mappedBy ="user", orphanRemoval = true, cascade = CascadeType.ALL, fetch = FetchType.EAGER) private Wallet wallet; @OneToMany(mappedBy = "user", orphanRemoval = true, cascade = CascadeType.ALL) private List exchangeTransactions; + @OneToMany(mappedBy = "user", orphanRemoval = true, cascade = CascadeType.ALL) + private List cardTransactions; + @CreatedDate @Column(name = "user_join_date") private LocalDateTime userJoinDate; diff --git a/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java b/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java index e00be48e..f661f5db 100644 --- a/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java +++ b/src/main/java/bumblebee/xchangepass/domain/user/repository/UserRepository.java @@ -29,13 +29,14 @@ public interface UserRepository extends JpaRepository, UserRepositor FROM User u WHERE u.userId = :userId """) - Optional findByUserId(@Param("userId") Long userId); - - @Query(value = """ - SELECT u - FROM User u - WHERE u.userName.value = :userName and u.userPhoneNumber.value=:userPhoneNumber - """) - Optional findByUserId(@Param("userName") String userName, @Param("userPhoneNumber") String userPhoneNumber); - + Optional findByUserId(Long userId); + + @Query(""" + SELECT u + FROM User u + WHERE u.userName.value = :name + AND u.userPhoneNumber.value = :phoneNumber +""") + Optional findByNameAndPhoneNumber(@Param("name") String name, + @Param("phoneNumber") String phoneNumber); } diff --git a/src/main/java/bumblebee/xchangepass/global/common/CursorResponse.java b/src/main/java/bumblebee/xchangepass/global/common/CursorResponse.java new file mode 100644 index 00000000..527a9c25 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/common/CursorResponse.java @@ -0,0 +1,25 @@ +package bumblebee.xchangepass.global.common; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Builder; + +import java.util.List; + +@Schema(description = "커서 기반 페이징 응답 객체") +@Builder +public record CursorResponse( + + @Schema(description = "데이터 목록") + List data, + + @Schema(description = "다음 페이지 커서 (null이면 다음 페이지 없음)", example = "45") + Long nextCursor + +) { + public static CursorResponse of(List data, Long nextCursor) { + return CursorResponse.builder() + .data(data) + .nextCursor(nextCursor) + .build(); + } +} diff --git a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java index a87c9da7..fb3524ff 100644 --- a/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java +++ b/src/main/java/bumblebee/xchangepass/global/error/ErrorCode.java @@ -21,6 +21,7 @@ public enum ErrorCode { /*Wallet*/ WALLET_NOT_FOUND(HttpStatus.BAD_REQUEST, "W001", "지갑을 찾을 수 없습니다."), WALLET_ALREADY_EXIST(HttpStatus.BAD_REQUEST,"W002","이미 지갑이 존재합니다."), + INVALID_WALLET_PASSWORD(HttpStatus.UNAUTHORIZED, "WALLET001", "지갑 비밀번호가 일치하지 않습니다."), /*Balance*/ BALANCE_NOT_FOUND(HttpStatus.BAD_REQUEST, "B001", "해당 화폐 잔액이 존재하지 않습니다."), @@ -53,7 +54,10 @@ public enum ErrorCode { PHYSICAL_CARD_GENERATION_FAILED(HttpStatus.BAD_REQUEST,"C002","실물 카드 발급에 실패했습니다."), CARD_NOT_FOUND(HttpStatus.BAD_REQUEST,"C003","찾는 카드가 존재하지 않습니다."), INVALID_CARD_NUMBER(HttpStatus.BAD_REQUEST, "C004", "잘못된 카드 번호입니다."), + ALREADY_ISSUED_PHYSICAL_CARD(HttpStatus.CONFLICT, "C005", "이미 발급된 실물 카드가 존재합니다."), + /* Card Transaction */ + CARD_TRANSACTION_NOT_FOUND(HttpStatus.NOT_FOUND, "CT001", "해당 카드 거래내역을 찾을 수 없습니다."), /*Encryption*/ AES_KEY_GENERATION_FAILED(HttpStatus.INTERNAL_SERVER_ERROR, "ENC001", "AES 키 생성에 실패했습니다."), @@ -66,8 +70,6 @@ public enum ErrorCode { INVALID_AES_KEY(HttpStatus.BAD_REQUEST, "ENC008", "잘못된 AES 키입니다."), INVALID_IV(HttpStatus.BAD_REQUEST, "ENC009", "잘못된 IV 값입니다."), - - //Security USER_FORBIDDEN(HttpStatus.FORBIDDEN, "S0001", "권한이 없습니다."), LOGIN_NOT_CORRECT(HttpStatus.UNAUTHORIZED, "S002", "아이디 혹은 비밀번호가 일치하지 않습니다."), diff --git a/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java b/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java index 226aefb7..79d950fc 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java +++ b/src/main/java/bumblebee/xchangepass/global/security/SecurityConfig.java @@ -74,6 +74,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .requestMatchers("/css/**", "/js/**", "/images/**", "/static/**", "/index.html").permitAll() .requestMatchers("/login", "/api/v1/user/signup", "/token-refresh", "/favicon.ico", "/error").permitAll() .requestMatchers("/swagger-ui/**", "/swagger-ui", "/v3/api-docs/**", "/v3/api-docs").permitAll() //swagger-ui + .requestMatchers("/api/v1/card/payment").permitAll() // 🔓 환율 조회는 인증 없이 허용 .requestMatchers("/api/exchange-rate/**").permitAll() diff --git a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java index fddcc8f8..006c75ac 100644 --- a/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java +++ b/src/main/java/bumblebee/xchangepass/global/security/jwt/JwtAuthFilter.java @@ -90,7 +90,8 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce "/images/**", "/login", "/api/v1/signup", - "/api/exchange-rate/**" + "/api/exchange-rate/**", + "/api/v1/card/payment" ); AntPathMatcher pathMatcher = new AntPathMatcher(); diff --git a/src/main/java/bumblebee/xchangepass/global/util/EventPublisher.java b/src/main/java/bumblebee/xchangepass/global/util/EventPublisher.java new file mode 100644 index 00000000..7397b2a6 --- /dev/null +++ b/src/main/java/bumblebee/xchangepass/global/util/EventPublisher.java @@ -0,0 +1,16 @@ +package bumblebee.xchangepass.global.util; + +import lombok.RequiredArgsConstructor; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Component; + +@Component +@RequiredArgsConstructor +public class EventPublisher { + + private final ApplicationEventPublisher applicationEventPublisher; + + public void publishEvent(Object event) { + applicationEventPublisher.publishEvent(event); + } +} \ No newline at end of file diff --git a/src/test/java/bumblebee/xchangepass/domain/card/controller/CardControllerTest.java b/src/test/java/bumblebee/xchangepass/domain/card/controller/CardControllerTest.java new file mode 100644 index 00000000..b3ff9d13 --- /dev/null +++ b/src/test/java/bumblebee/xchangepass/domain/card/controller/CardControllerTest.java @@ -0,0 +1,134 @@ +package bumblebee.xchangepass.domain.card.controller; + +import bumblebee.xchangepass.config.TestUserInitializer; +import bumblebee.xchangepass.domain.card.dto.request.ChangeCardStatusRequest; +import bumblebee.xchangepass.domain.card.dto.response.BasicCardInfoResponse; +import bumblebee.xchangepass.domain.card.dto.response.DetailedCardInfoResponse; +import bumblebee.xchangepass.domain.card.entity.CardStatus; +import bumblebee.xchangepass.domain.card.entity.CardType; +import bumblebee.xchangepass.domain.card.service.CardService; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; +import org.mockito.Mockito; +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.boot.test.context.TestConfiguration; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import java.time.LocalDateTime; +import java.util.Collections; + +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.status; + +/** + * 컨트롤러 단위 테스트 + */ +@SpringBootTest +@AutoConfigureMockMvc +@ActiveProfiles("test") +@Import(TestUserInitializer.class) +class CardControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private CardService cardService; + + @Autowired + private ObjectMapper objectMapper; + + @Test + @WithMockUser(username = "1") + void 실물카드발급_성공() throws Exception { + doNothing().when(cardService).generatePhysicalCard(1L); + + mockMvc.perform(post("/api/v1/card/physical") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isCreated()); + + verify(cardService).generatePhysicalCard(1L); + } + + @Test + @WithMockUser(username = "1") + void 카드상태변경_성공() throws Exception { + ChangeCardStatusRequest request = ChangeCardStatusRequest.builder() + .cardType(CardType.PHYSICAL) + .status(CardStatus.INACTIVE) + .build(); + + String requestJson = objectMapper.writeValueAsString(request); + + doNothing().when(cardService).changeCardStatus(1L, request); + + mockMvc.perform(put("/api/v1/card/status") + .contentType(MediaType.APPLICATION_JSON) + .content(requestJson)) + .andDo(print()) + .andExpect(status().isNoContent()); + + verify(cardService).changeCardStatus(1L, request); + } + + @Test + @WithMockUser(username = "1") + void 보유카드목록조회_성공() throws Exception { + BasicCardInfoResponse cardInfoResponse = BasicCardInfoResponse.builder() + .cardId(1L) + .cardStatus(CardStatus.ACTIVE) + .cardType(CardType.PHYSICAL) + .maskedCardNumber("1111-****-****-1234") + .build(); + + when(cardService.getBasicCardInfo(1L)).thenReturn(Collections.singletonList(cardInfoResponse)); + + mockMvc.perform(get("/api/v1/card") + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + + verify(cardService).getBasicCardInfo(1L); + } + + @Test + @WithMockUser(username = "1") + void 카드상세정보조회_성공() throws Exception { + Long cardId = 1L; + DetailedCardInfoResponse cardInfoResponse = DetailedCardInfoResponse.builder() + .cardId(cardId) + .cardType(CardType.PHYSICAL) + .cardStatus(CardStatus.ACTIVE) + .cardNumber("1111-1111-1111-1234") + .cvc("123") + .expirationDate(LocalDateTime.of(2023, 1, 30, 0, 0)) + .cardCreateDate(LocalDateTime.of(2023, 5, 1, 0, 0)) + .build(); + when(cardService.getDetailedCardInfo(cardId)).thenReturn(cardInfoResponse); + + mockMvc.perform(get("/api/v1/card/{cardId}", cardId) + .contentType(MediaType.APPLICATION_JSON)) + .andDo(print()) + .andExpect(status().isOk()); + + verify(cardService).getDetailedCardInfo(cardId); + } + + @TestConfiguration + static class MockServiceConfig { + @Bean + public CardService cardService() { + return Mockito.mock(CardService.class); + } + } +} diff --git a/src/test/java/bumblebee/xchangepass/domain/card/service/CardServiceTest.java b/src/test/java/bumblebee/xchangepass/domain/card/service/CardServiceTest.java index 78c152bb..7e059414 100644 --- a/src/test/java/bumblebee/xchangepass/domain/card/service/CardServiceTest.java +++ b/src/test/java/bumblebee/xchangepass/domain/card/service/CardServiceTest.java @@ -3,9 +3,13 @@ import bumblebee.xchangepass.config.RedisTestBase; import bumblebee.xchangepass.config.TestUserInitializer; +import bumblebee.xchangepass.domain.card.dto.request.ChangeCardStatusRequest; +import bumblebee.xchangepass.domain.card.dto.response.BasicCardInfoResponse; import bumblebee.xchangepass.domain.card.dto.response.DetailedCardInfoResponse; import bumblebee.xchangepass.domain.card.entity.Card; +import bumblebee.xchangepass.domain.card.entity.CardStatus; import bumblebee.xchangepass.domain.card.entity.CardType; +import bumblebee.xchangepass.domain.card.repository.CardRepository; import bumblebee.xchangepass.domain.user.entity.User; import bumblebee.xchangepass.domain.user.repository.UserRepository; import bumblebee.xchangepass.global.error.ErrorCode; @@ -21,6 +25,9 @@ import javax.crypto.SecretKey; +import java.util.List; + +import static org.assertj.core.api.AssertionsForClassTypes.assertThat; import static org.junit.jupiter.api.Assertions.*; @SpringBootTest @@ -40,6 +47,9 @@ public class CardServiceTest extends RedisTestBase { @Autowired private UserRepository userRepository; + @Autowired + private CardRepository cardRepository; + @Test @Transactional @DisplayName("실물 카드 발급 여부") @@ -59,14 +69,14 @@ void verifyPhysicalCardIssuance(){ @Test @Transactional - @DisplayName("카드 정보 조회 - 키 복호화 및 Redis 저장 상태 확인") + @DisplayName("카드 정보 조회 시 키 복호화 및 Redis 캐시 저장 확인") void verifyKeyDecryptionAndRedisStorage() { Long userId = 2L; cardService.generatePhysicalCard(userId); User user = userRepository.findByUserId(userId) - .orElseThrow(() -> new RuntimeException("테스트 유저가 존재하지 않습니다.")); + .orElseThrow(ErrorCode.USER_NOT_FOUND::commonException); Card card = user.getWallet().getCards().stream() .findFirst() @@ -75,10 +85,10 @@ void verifyKeyDecryptionAndRedisStorage() { Long cardId = card.getCardId(); DetailedCardInfoResponse cachedCardBefore = cardCacheService.getCardInfo(cardId); - assertNull(cachedCardBefore, "Redis에 카드 정보가 미리 저장되지 않아야 합니다."); + assertNull(cachedCardBefore, "Redis에 카드 정보 조회 X"); DetailedCardInfoResponse detailedCardInfo = cardService.getDetailedCardInfo(cardId); - assertNotNull(detailedCardInfo, "복호화된 카드 정보를 가져올 수 있어야 합니다."); + assertNotNull(detailedCardInfo, "복호화된 카드 정보 조회 가능"); SecretKey decryptedAESKey = rsaEncryption.decryptAESKeyWithKMS(card.getEncryptionData().getEncryptedAesKey()); @@ -88,12 +98,52 @@ void verifyKeyDecryptionAndRedisStorage() { String decryptedCvc = AESEncryption.decryptData( card.getCvc(), decryptedAESKey, card.getEncryptionData().getIv()); - assertEquals(decryptedCardNumber, detailedCardInfo.cardNumber(), "복호화된 카드 번호가 일치해야 합니다."); - assertEquals(decryptedCvc, detailedCardInfo.cvc(), "복호화된 CVC가 일치해야 합니다."); + assertEquals(decryptedCardNumber, detailedCardInfo.cardNumber(), "복호화된 카드 번호 일치"); + assertEquals(decryptedCvc, detailedCardInfo.cvc(), "복호화된 CVC 일치"); DetailedCardInfoResponse cachedCardAfter = cardCacheService.getCardInfo(cardId); - assertNotNull(cachedCardAfter, "Redis에 카드 정보가 저장되어야 합니다."); - assertEquals(detailedCardInfo, cachedCardAfter, "Redis에 저장된 카드 정보가 복호화된 정보와 일치해야 합니다."); + assertNotNull(cachedCardAfter, "Redis에 카드 정보 저장"); + assertEquals(detailedCardInfo, cachedCardAfter, "Redis에 저장된 카드 정보가 복호화된 정보와 일치"); + } + + @Test + @DisplayName("카드 상태 변경 시 DB와 Redis 동시 반영") + void changeCardStatus_shouldUpdateBothDatabaseAndRedisCache() { + Long userId = 2L; + cardService.generatePhysicalCard(userId); + + List cardInfo = cardService.getBasicCardInfo(userId); + + Long physicalCardIds = cardInfo.stream() + .filter(c -> c.cardType().equals(CardType.PHYSICAL)) + .map(BasicCardInfoResponse::cardId) + .findFirst() + .orElseThrow(ErrorCode.CARD_NOT_FOUND::commonException); + + + + var request = ChangeCardStatusRequest.builder() + .cardType(CardType.PHYSICAL) + .status(CardStatus.INACTIVE) + .build(); + + cardService.changeCardStatus(userId, request); + + Card updatedCard = cardRepository.findById(physicalCardIds) + .stream() + .filter(c -> c.getCardId().equals(physicalCardIds)) + .findFirst() + .orElseThrow(); + + assertThat(updatedCard.getCardStatus()) + .as("DB에 저장된 카드 상태 INACTIVE로 변경") + .isEqualTo(CardStatus.INACTIVE); + + DetailedCardInfoResponse cachedCard = cardCacheService.getCardInfo(physicalCardIds); + assertThat(cachedCard).isNotNull(); + assertThat(cachedCard.cardStatus()) + .as("Redis 캐시에 저장된 카드 상태 INACTIVE") + .isEqualTo(CardStatus.INACTIVE); } }