diff --git a/.DS_Store b/.DS_Store new file mode 100644 index 0000000..4a8e492 Binary files /dev/null and b/.DS_Store differ diff --git a/.github/workflows/deploy-aws-channel.yml b/.github/workflows/deploy-aws-channel.yml index 985e69a..711bbbf 100644 --- a/.github/workflows/deploy-aws-channel.yml +++ b/.github/workflows/deploy-aws-channel.yml @@ -18,51 +18,59 @@ jobs: build-and-deploy: runs-on: ubuntu-latest steps: - - name: Checkout - uses: actions/checkout@v4 + - name: Checkout + uses: actions/checkout@v4 - - name: Setup JDK 17 - uses: actions/setup-java@v4 - with: - java-version: "17" - distribution: "temurin" - cache: gradle + - name: Setup JDK 17 + uses: actions/setup-java@v4 + with: + java-version: "17" + distribution: "temurin" + cache: gradle - - name: Grant execute permission for gradlew - working-directory: cloud-services/msa-channel-user-service - run: chmod +x ./gradlew + - name: Grant execute permission for gradlew + working-directory: cloud-services/msa-channel-user-service + run: chmod +x ./gradlew - - name: Build Channel User Service - working-directory: cloud-services/msa-channel-user-service - run: ./gradlew clean build -x test + - name: Build Channel User Service + working-directory: cloud-services/msa-channel-user-service + run: ./gradlew clean build -x test - - name: Log in to Docker Hub - uses: docker/login-action@v3 - with: - username: ${{ secrets.DOCKER_USERNAME }} - password: ${{ secrets.DOCKER_PASSWORD }} + - name: Log in to Docker Hub + uses: docker/login-action@v3 + with: + username: ${{ secrets.DOCKER_USERNAME }} + password: ${{ secrets.DOCKER_PASSWORD }} - - name: Build and push Docker image - run: | - docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.VERSION }} ./cloud-services/msa-coupon-service + - name: Build and push Docker image + run: | + docker build -t ${{ env.DOCKER_IMAGE }}:${{ env.VERSION }} ./cloud-services/msa-channel-user-service docker push ${{ env.DOCKER_IMAGE }}:${{ env.VERSION }} - # ----------------------------- - # Kubernetes rolling update - # ----------------------------- - - name: Deploy to Kubernetes - uses: appleboy/ssh-action@v1.0.3 - with: - host: ${{ secrets.K8S_MASTER_IP }} - username: ubuntu - key: ${{ secrets.K8S_MASTER_KEY }} - timeout: 60s - command_timeout: 30m - script: | - set -e + # ----------------------------- + # Kubernetes rolling update + # ----------------------------- + - name: Deploy to Kubernetes + uses: appleboy/ssh-action@v1.0.3 + with: + host: ${{ secrets.K8S_MASTER_IP }} + username: ubuntu + key: ${{ secrets.K8S_MASTER_KEY }} + timeout: 60s + command_timeout: 30m + script: | + set -e - kubectl set image deployment/$DEPLOYMENT_NAME \ - $CONTAINER_NAME=$DOCKER_IMAGE:$VERSION \ - -n $NAMESPACE + DOCKER_IMAGE="${{ env.DOCKER_IMAGE }}" + VERSION="${{ env.VERSION }}" + NAMESPACE="${{ env.NAMESPACE }}" + DEPLOYMENT_NAME="${{ env.DEPLOYMENT_NAME }}" + CONTAINER_NAME="${{ env.CONTAINER_NAME }}" + + echo "NS=$NAMESPACE DEPLOY=$DEPLOYMENT_NAME CONTAINER=$CONTAINER_NAME IMAGE=$DOCKER_IMAGE:$VERSION" + + kubectl -n "$NAMESPACE" set image "deployment/$DEPLOYMENT_NAME" \ + "$CONTAINER_NAME=$DOCKER_IMAGE:$VERSION" + + kubectl -n "$NAMESPACE" rollout status "deployment/$DEPLOYMENT_NAME" - kubectl rollout status deployment/$DEPLOYMENT_NAME -n $NAMESPACE diff --git a/cloud-services/msa-channel-user-service/Dockerfile b/cloud-services/msa-channel-user-service/Dockerfile new file mode 100644 index 0000000..0c28e5c --- /dev/null +++ b/cloud-services/msa-channel-user-service/Dockerfile @@ -0,0 +1,44 @@ +# Multi-stage build for optimal image size +FROM gradle:8.14.3-jdk17 AS builder + +WORKDIR /app + +# Copy gradle files first (for caching) +COPY build.gradle settings.gradle ./ +COPY gradle ./gradle + +# Copy source code +COPY src ./src + +# Build the application (skip tests for faster build) +RUN gradle clean build -x test --no-daemon + +# Runtime stage +FROM eclipse-temurin:17-jre-alpine + +WORKDIR /app + +# Install curl for health checks +RUN apk add --no-cache curl + +# Copy the built JAR from builder stage +COPY --from=builder /app/build/libs/*.jar app.jar + +# Create non-root user for security (Alpine syntax) +RUN addgroup -S spring && adduser -S spring -G spring +USER spring:spring + +# Expose port (channel-user-service = 8081) +EXPOSE 8081 + +# Health check (actuator가 켜져있을 때만 유효) +HEALTHCHECK --interval=30s --timeout=3s --start-period=40s --retries=3 \ + CMD curl -f http://localhost:8081/actuator/health || exit 1 + +# Run the application +ENTRYPOINT ["java", \ + "-XX:+UseContainerSupport", \ + "-XX:MaxRAMPercentage=75.0", \ + "-Djava.security.egd=file:/dev/./urandom", \ + "-jar", \ + "app.jar"] diff --git a/cloud-services/msa-channel-user-service/build.gradle b/cloud-services/msa-channel-user-service/build.gradle index fa7242c..a36ad9f 100644 --- a/cloud-services/msa-channel-user-service/build.gradle +++ b/cloud-services/msa-channel-user-service/build.gradle @@ -42,11 +42,12 @@ dependencies { testRuntimeOnly 'org.junit.platform:junit-platform-launcher' implementation "io.netty:netty-resolver-dns-native-macos:4.2.9.Final:osx-aarch_64" implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310' + implementation 'org.springframework.boot:spring-boot-starter-actuator' // jwt - implementation 'io.jsonwebtoken:jjwt-api:0.13.0' - runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.13.0' - runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.13.0' + implementation 'io.jsonwebtoken:jjwt-api:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.5' + runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.5' // kafka implementation 'org.springframework.kafka:spring-kafka' diff --git a/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/service/BankingService.java b/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/service/BankingService.java index 78317f6..8f4e27f 100644 --- a/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/service/BankingService.java +++ b/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/service/BankingService.java @@ -20,7 +20,7 @@ public void createAccount(String userUuid, String accountNo) { } // 입금 - public void deposit(String userUuid, String accountNo, BigDecimal amount) { + public BigDecimal deposit(String userUuid, String accountNo, BigDecimal amount) { if (amount == null || amount.compareTo(BigDecimal.ZERO) <= 0) { throw new IllegalArgumentException("입금 금액은 0보다 커야 합니다."); } @@ -28,6 +28,7 @@ public void deposit(String userUuid, String accountNo, BigDecimal amount) { DepositMessage message = new DepositMessage(userUuid, accountNo, amount); try { kafkaProducerService.sendDepositRequest(message); + return amount; } catch (Exception e) { throw new RuntimeException("입금 요청 전송 실패", e); } diff --git a/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/util/JwtUtil.java b/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/util/JwtUtil.java index 46ac1d4..4da5a6e 100644 --- a/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/util/JwtUtil.java +++ b/cloud-services/msa-channel-user-service/src/main/java/com/fisa/channel_service/util/JwtUtil.java @@ -1,5 +1,6 @@ package com.fisa.channel_service.util; + import io.jsonwebtoken.Claims; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; diff --git a/cloud-services/msa-point-service/build.gradle b/cloud-services/msa-point-service/build.gradle index bfbbb22..f41685c 100644 --- a/cloud-services/msa-point-service/build.gradle +++ b/cloud-services/msa-point-service/build.gradle @@ -34,7 +34,7 @@ ext { } dependencies { - implementation 'org.springframework.boot:spring-boot-starter-webmvc' + implementation 'org.springframework.boot:spring-boot-starter-web' implementation 'org.springframework.cloud:spring-cloud-starter-openfeign' implementation 'org.springframework.kafka:spring-kafka' implementation 'org.springframework.boot:spring-boot-starter-data-jpa' diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/AppConfig.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/AppConfig.java new file mode 100644 index 0000000..141483d --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/AppConfig.java @@ -0,0 +1,17 @@ +package com.techsemina.msa.pointservice.config; + +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class AppConfig { + + @Bean + public ObjectMapper objectMapper() { + ObjectMapper mapper = new ObjectMapper(); + mapper.registerModule(new JavaTimeModule()); // 날짜 처리 기능 추가 + return mapper; + } +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/KafkaProducerConfig.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/KafkaProducerConfig.java index c8fb1a5..428a4b8 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/KafkaProducerConfig.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/config/KafkaProducerConfig.java @@ -17,14 +17,14 @@ @Configuration public class KafkaProducerConfig { - @Value("${spring.kafka.bootstrap-servers}") // properties 값 가져오기 + @Value("${spring.kafka.bootstrap-servers}") // application.properties 값 가져오기 private String bootstrapServers; @Bean public ProducerFactory producerFactory() { Map configProps = new HashMap<>(); - // 카프카 브로커 주소 (application.yml에 있어도 여기서 명시하면 더 확실함) + // 카프카 브로커 주소 (application.yml에 있어도 여기서 명시하면 더 확실) configProps.put(ProducerConfig.BOOTSTRAP_SERVERS_CONFIG, bootstrapServers); // Key는 String, Value는 JSON(Object)으로 직렬화하겠다 설정 @@ -34,7 +34,7 @@ public ProducerFactory producerFactory() { // 메시지 전송 신뢰성 설정 configProps.put(ProducerConfig.ACKS_CONFIG, "all"); // 모든 replica 확인 configProps.put(ProducerConfig.RETRIES_CONFIG, 3); // 재시도 횟수 - configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // 중복 전송 방지 + configProps.put(ProducerConfig.ENABLE_IDEMPOTENCE_CONFIG, true); // 중복 전송 방지 return new DefaultKafkaProducerFactory<>(configProps); } diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/PaymentController.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/PaymentController.java index 6789dd3..59944ff 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/PaymentController.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/PaymentController.java @@ -1,28 +1,46 @@ package com.techsemina.msa.pointservice.controller; import com.techsemina.msa.pointservice.dto.PaymentRequest; +import com.techsemina.msa.pointservice.dto.PaymentResponse; import com.techsemina.msa.pointservice.service.PaymentService; +import com.techsemina.msa.pointservice.util.OrderIdGenerator; import lombok.RequiredArgsConstructor; -import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.http.HttpStatus; +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; @RestController -@RequestMapping("/api/payment") +@RequestMapping("/api") @RequiredArgsConstructor public class PaymentController { - private final KafkaTemplate kafkaTemplate; - // 스프링이 미리 만들어둔 객체를 주입받음 (DI) private final PaymentService paymentService; @PostMapping("/payment") - public String pay(@RequestBody PaymentRequest dto) { + public ResponseEntity pay(@RequestBody PaymentRequest dto) { + // 1. 주문 번호 생성 및 주입 + // 프론트에서 안 보냈으면(null이면) 유틸리티로 생성 + if (dto.getOrderId() == null) { + String newId = OrderIdGenerator.generateOrderId(); // "PAY-2026..." 생성 + dto.setOrderId(newId); // DTO에 쏙 넣기 + } + + // 2. ID가 채워진 dto를 서비스로 넘김 (Kafka로 메시지 던지고 바로 리턴) paymentService.processCompositePayment(dto); - return "결제 요청 처리 완료"; + + // 3. 응답 객체 생성 + PaymentResponse response = PaymentResponse.builder() + .message("결제 요청이 정상적으로 접수되었습니다. (결과 알림 예정)") + .orderId(dto.getOrderId()) + .status("PENDING") // 아직 Kafka 타고 가는 중 - PENDING + .build(); + + // 4. 202 Accepted 리턴 + return ResponseEntity.status(HttpStatus.ACCEPTED).body(response); } } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/TestController.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/TestController.java new file mode 100644 index 0000000..8bfb6d2 --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/controller/TestController.java @@ -0,0 +1,40 @@ +package com.techsemina.msa.pointservice.controller; +import org.springframework.context.annotation.Profile; + + +import com.techsemina.msa.pointservice.dto.CashResponseDTO; +import lombok.RequiredArgsConstructor; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("/test") +@RequiredArgsConstructor +@Profile({"dev", "test"}) // 운영 환경에서는 비활성화 +public class TestController { + + private final KafkaTemplate kafkaTemplate; + + // 🕵️‍♂️ 가짜 현금 서비스: "성공했다"고 뻥치기 + // 호출 주소: POST /test/fake-success?orderId=PAY-1234 + @PostMapping("/fake-success") + public String fakeSuccess(@RequestParam String orderId) { + + // 현금 서비스가 보내줄 법한 메시지를 우리가 직접 만듭니다. + CashResponseDTO fakeResponse = new CashResponseDTO(orderId, "SUCCESS", "정상 처리됨"); + + // 'core-withdraw-result' 토픽으로 쏩니다. + // 그러면 아까 만든 PaymentConsumer가 이걸 낚아채서 'completePayment'를 실행하겠죠? + kafkaTemplate.send("core-withdraw-result", fakeResponse); + + return "가짜 성공 메시지 전송 완료! (OrderID: " + orderId + ")"; + } + + // 🕵️‍♂️ 가짜 현금 서비스: "실패했다"고 뻥치기 (롤백 테스트) + @PostMapping("/fake-fail") + public String fakeFail(@RequestParam String orderId) { + CashResponseDTO fakeResponse = new CashResponseDTO(orderId, "FAILED", "잔액 부족"); + kafkaTemplate.send("core-withdraw-result", fakeResponse); + return "가짜 실패 메시지 전송 완료 -> 환불될 것임"; + } +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/Payment.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/Payment.java new file mode 100644 index 0000000..ace1d56 --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/Payment.java @@ -0,0 +1,37 @@ +package com.techsemina.msa.pointservice.domain; + +import jakarta.persistence.*; +import lombok.*; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +@Entity +@Getter +@Setter +@NoArgsConstructor +@AllArgsConstructor +@Builder +@EntityListeners(AuditingEntityListener.class) // 날짜 자동 기록용 +@Table(name = "payment_history") // 테이블 이름 +public class Payment { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + // 생성된 주문번호 (OrderIdGenerator) + @Column(unique = true, nullable = false) + private String orderId; + + private String userId; // 누가 + private Long pointAmount; // 포인트 얼마 + private Long cashAmount; // 현금 얼마 + + // 상태 관리 (PENDING -> COMPLETED / FAILED) + private String status; + + @CreatedDate + private LocalDateTime createdAt; +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/PointMaster.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/PointMaster.java index 8350a24..389f877 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/PointMaster.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/domain/PointMaster.java @@ -36,7 +36,7 @@ public PointMaster(String userUuid, long currentAmt) { // [로직 1] 포인트 충전 (합산) public void charge(long amount) { if (amount <= 0) { - throw new IllegalArgumentException("충전 금액은 0보다 커야 합니다."); + throw new IllegalArgumentException("충전 금액은 0원보다 커야 합니다."); } this.currentAmt += amount; this.lastUpdatedAt = LocalDateTime.now(); @@ -46,7 +46,7 @@ public void charge(long amount) { // [로직 2] 포인트 차감 public void use(long amount) { if (amount <= 0) { - throw new IllegalArgumentException("차감 금액은 0보다 커야 합니다."); + throw new IllegalArgumentException("차감 금액은 0원보다 커야 합니다."); } if (this.currentAmt < amount) { throw new IllegalStateException("포인트 잔액이 부족합니다."); @@ -58,7 +58,7 @@ public void use(long amount) { // [로직 3] 포인트 롤백(환불) public void refund(long amount) { if (amount <= 0) { - throw new IllegalArgumentException("환불 금액은 0보다 커야 합니다."); + throw new IllegalArgumentException("환불 금액은 0원보다 커야 합니다."); } this.currentAmt += amount; this.lastUpdatedAt = LocalDateTime.now(); diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashRequestDTO.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashRequestDTO.java index aba7a31..844fe4b 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashRequestDTO.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashRequestDTO.java @@ -8,6 +8,7 @@ @NoArgsConstructor @AllArgsConstructor public class CashRequestDTO { + private String orderId; private String loginId; private Long amount; // 출금 액수 } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashResponseDTO.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashResponseDTO.java new file mode 100644 index 0000000..6b7b2ee --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/CashResponseDTO.java @@ -0,0 +1,14 @@ +package com.techsemina.msa.pointservice.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +public class CashResponseDTO { + private String orderId; // 주문 번호 + private String status; // "SUCCESS" or "FAIL" + private String message; // 실패 사유 (예: "잔액 부족") +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentRequest.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentRequest.java index ad7edab..2c26cba 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentRequest.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentRequest.java @@ -1,13 +1,15 @@ package com.techsemina.msa.pointservice.dto; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; +import lombok.*; @Getter +@Setter @NoArgsConstructor @AllArgsConstructor +@Builder public class PaymentRequest { + //주문/결제 고유 번호 (UUID 등을 사용) + private String orderId; private String loginId; // 사용자 ID private Long pointAmount; // 포인트 사용액 private Long cashAmount; // 현금 결제액 diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentResponse.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentResponse.java new file mode 100644 index 0000000..108e894 --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PaymentResponse.java @@ -0,0 +1,13 @@ +package com.techsemina.msa.pointservice.dto; + + +import lombok.Builder; +import lombok.Getter; + +@Getter +@Builder +public class PaymentResponse { + private String message; // "결제 요청이 접수되었습니다." + private String orderId; // 추적용 ID (UserUUID + Time 등) + private String status; // "PENDING" (처리중) +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointRequestDTO.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointRequestDTO.java index 7652656..6739a8e 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointRequestDTO.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointRequestDTO.java @@ -9,5 +9,5 @@ @AllArgsConstructor public class PointRequestDTO { private String loginId; - private Long pointAmount; // 포인트 차감/환불 액수 + private Long pointAmount; // 포인트 충전/차감/환불 액수 } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointResultEvent.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointResultEvent.java deleted file mode 100644 index 5052bd9..0000000 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/dto/PointResultEvent.java +++ /dev/null @@ -1,13 +0,0 @@ -package com.techsemina.msa.pointservice.dto; - -import lombok.AllArgsConstructor; -import lombok.Data; -import lombok.NoArgsConstructor; - -@Data -@NoArgsConstructor -@AllArgsConstructor -public class PointResultEvent { - private String userId; - private String status; -} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/MockCoreBanking.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/MockCoreBanking.java deleted file mode 100644 index 7046697..0000000 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/MockCoreBanking.java +++ /dev/null @@ -1,41 +0,0 @@ -package com.techsemina.msa.pointservice.kafka; - -import com.techsemina.msa.pointservice.dto.CashRequestDTO; -import com.techsemina.msa.pointservice.dto.CoreResultEvent; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.kafka.annotation.KafkaListener; -import org.springframework.kafka.core.KafkaTemplate; -import org.springframework.stereotype.Component; - -import java.util.Random; - -@Component -@Slf4j -@RequiredArgsConstructor -public class MockCoreBanking { - - private final KafkaTemplate kafkaTemplate; - private final Random random = new Random(); - - // 👂 결제 서비스가 보낸 "출금 요청"을 가로챕니다. - @KafkaListener(topics = "core-withdraw-request", groupId = "mock-core-group") - public void handleWithdrawRequest(CashRequestDTO request) throws InterruptedException { - log.info("============== [On-Premise 시뮬레이터] =============="); - log.info("🤑 코어뱅킹: 출금 요청 받음! 금액={}원", request.getAmount()); - - // 1. 실제 은행처럼 약간의 딜레이(2초)를 줍니다. - Thread.sleep(2000); - - // 2. 랜덤하게 성공/실패 결정 (50% 확률) - boolean isSuccess = random.nextBoolean(); - // 테스트하고 싶은 시나리오에 따라 강제로 true/false로 바꿔보세요! - - String status = isSuccess ? "SUCCESS" : "FAIL"; - log.info("🏦 코어뱅킹 처리 결과: {}", status); - - // 3. 결과 메시지 발송 (-> PaymentKafkaConsumer가 받음) - kafkaTemplate.send("core-result", new CoreResultEvent(request.getLoginId(), status)); - log.info("==================================================="); - } -} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/PaymentKafkaConsumer.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/PaymentKafkaConsumer.java index 0249182..7e6470a 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/PaymentKafkaConsumer.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/kafka/PaymentKafkaConsumer.java @@ -25,7 +25,7 @@ public void handleCoreResult(CoreResultEvent event) { // --- Step 3: 포인트 롤백 (보상 트랜잭션) --- // 🔥 핵심: Kafka 안 쓰고 직접 서비스 호출해서 롤백! try { - pointService.refund(event.getUserId(), 5000L); // 금액은 예시 + pointService.refundPoint(event.getUserId(), 5000L); // 금액은 예시 log.info("✅ 포인트 환불(롤백) 완료. 결제가 취소되었습니다."); } catch (Exception e) { log.error("💀 큰일 났다... 환불마저 실패함. (관리자 호출 필요)"); diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PaymentRepository.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PaymentRepository.java new file mode 100644 index 0000000..d987507 --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PaymentRepository.java @@ -0,0 +1,10 @@ +package com.techsemina.msa.pointservice.repository; + +import com.techsemina.msa.pointservice.domain.Payment; +import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; + +public interface PaymentRepository extends JpaRepository { + // 주문 번호로 결제 내역 찾기 (SELECT * FROM payment WHERE order_id = ?) + Optional findByOrderId(String orderId); +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PointMasterRepository.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PointMasterRepository.java index dedcd2c..22bc426 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PointMasterRepository.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/repository/PointMasterRepository.java @@ -19,14 +19,12 @@ public interface PointMasterRepository extends JpaRepository Optional findByUserUuid(String userUuid); /** - * 🔥 [핵심 수정] 결제/차감용 (비관적 락 적용) - * - "내가 수정하는 동안 아무도 건드리지 마!" (SELECT ... FOR UPDATE) - * - 동시성 문제 해결의 핵심입니다. + * 결제/차감용 (비관적 락 적용) + * - 수정하는 동안 건드릴 수 없음 (SELECT ... FOR UPDATE) + * - 동시성 문제 해결 */ @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({@QueryHint(name = "jakarta.persistence.lock.timeout", value = "3000")}) // 3초 대기 후 에러 - @Query("select p from PointMaster p where p.userUuid = :userUuid") // 👈 직접 쿼리 명시 + @Query("select p from PointMaster p where p.userUuid = :userUuid") // 직접 쿼리 명시 Optional findByUserUuidWithLock(String userUuid); - // (JPA가 메서드 이름을 분석할 때 'AndLock'은 무시하므로 기능은 똑같이 동작하고 락만 걸립니다) - // 혹은 @Query("select p from PointMaster p where p.userUuid = :uuid") 로 직접 짜도 됨 } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java new file mode 100644 index 0000000..cb69f3d --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentConsumer.java @@ -0,0 +1,37 @@ +package com.techsemina.msa.pointservice.service; + +import com.techsemina.msa.pointservice.dto.CashResponseDTO; +import com.techsemina.msa.pointservice.service.PaymentService; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.annotation.KafkaListener; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +public class PaymentConsumer { + + private final PaymentService paymentService; + private final ObjectMapper objectMapper; + + @KafkaListener(topics = "core-withdraw-result", groupId = "point-service-group") + public void consumeWithdrawResult(String message) { + try { + log.info("📨 [Kafka] 결과 수신: {}", message); + CashResponseDTO result = objectMapper.readValue(message, CashResponseDTO.class); + + if ("SUCCESS".equals(result.getStatus())) { + // 성공 처리 + paymentService.completePayment(result.getOrderId()); + } else { + // 실패 -> 롤백 + paymentService.compensatePayment(result.getOrderId()); + } + + } catch (Exception e) { + log.error("❌ 메시지 처리 중 에러", e); + } + } +} \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java index 989ec25..1e87afc 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PaymentService.java @@ -1,41 +1,98 @@ package com.techsemina.msa.pointservice.service; +import com.techsemina.msa.pointservice.domain.Payment; import com.techsemina.msa.pointservice.dto.CashRequestDTO; import com.techsemina.msa.pointservice.dto.PaymentRequest; +import com.techsemina.msa.pointservice.repository.PaymentRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.concurrent.TimeUnit; + @Service @RequiredArgsConstructor @Slf4j public class PaymentService { - // 1. 이제 API Client 대신, 옆자리 동료(Service)와 우체부(Kafka)를 사용 + // 1. Service + kafka 사용 private final PointService pointService; private final KafkaTemplate kafkaTemplate; + private final PaymentRepository paymentRepository; @Transactional // 포인트 차감 중 에러나면 자동 롤백 보장 public void processCompositePayment(PaymentRequest request) { log.info("=== 1. 복합 결제 시작 (Hybrid): User={} ===", request.getLoginId()); - // [Step 1] 포인트 차감 (Local Logic) - // -> 같은 프로젝트라 네트워크를 안 타므로 try-catch가 굳이 필요 없음 - // -> 실패하면 RuntimeException이 터지면서 트랜잭션이 전체 롤백됨 - log.info("-> [Local] 포인트 서비스 직접 호출: {}점 차감", request.getPointAmount()); + // [Step 0] 장부에 먼저 "결제 대기중(PENDING)"으로 적어놓기 + Payment newPayment = Payment.builder() + .orderId(request.getOrderId()) + .userId(request.getLoginId()) + .pointAmount(request.getPointAmount()) + .cashAmount(request.getCashAmount()) + .status("PENDING") // 대기중 + .build(); + + paymentRepository.save(newPayment); // DB 저장 (INSERT) + log.info("-> 결제 내역 저장 완료 (PENDING) ✅"); + + // [Step 1] 포인트 차감 pointService.usePoint(request.getLoginId(), request.getPointAmount()); - log.info("-> 포인트 차감 완료 (DB 반영됨) ✅"); + log.info("-> 포인트 차감 완료 ✅"); + - // [Step 2] 현금 출금 요청 (Async Kafka) - // -> 핵심 변경점: 결과를 기다리지(Block) 않고 쪽지만 보냄 - // -> 따라서 여기서 '실패 시 롤백' 코드를 짤 필요가 없음 (Consumer가 할 일) - log.info("-> [Remote] 코어뱅킹 출금 요청 전송 (Kafka)"); - kafkaTemplate.send("core-withdraw-request", - new CashRequestDTO(request.getLoginId(), request.getCashAmount())); + // [Step 2] 현금 출금 요청 (Kafka) + // 1. 변수에 먼저 담습니다. + CashRequestDTO cashMessage = new CashRequestDTO( + request.getOrderId(), + request.getLoginId(), + request.getCashAmount() + ); + // 2. 보내기 전에 로그 확인 + log.info("-> [Kafka 전송] 토픽: core-withdraw-request, 데이터: {}", cashMessage); + // 3. 전송 + try { + kafkaTemplate.send("core-withdraw-request", cashMessage).get(5, TimeUnit.SECONDS); + } catch (Exception e) { + log.error("Kafka 전송 실패: {}", e.getMessage()); + throw new RuntimeException("출금 요청 전송 실패", e); + } - // 사용자는 여기서 즉시 응답을 받습니다. (대기 시간 0초) log.info("=== 2. 결제 요청 접수 완료 (결과는 비동기 처리) ⏳ ==="); } + + + // ✅ 결제 성공 확정 (Commit) + @Transactional + public void completePayment(String orderId) { + Payment payment = paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new RuntimeException("주문 없음")); + + if (!"PENDING".equals(payment.getStatus())) { + log.warn("⚠️ 이미 처리된 주문입니다: orderId={}, status={}", orderId, payment.getStatus()); + return; + } + + payment.setStatus("COMPLETED"); + log.info("🎉 최종 결제 완료 처리됨: {}", orderId); + } + + // ✅ [추가 2] 결제 실패 보상 (Rollback/Refund) + @Transactional + public void compensatePayment(String orderId) { + Payment payment = paymentRepository.findByOrderId(orderId) + .orElseThrow(() -> new RuntimeException("주문 없음")); + + // 이미 취소된 건지 체크하는 로직 등이 여기 들어가면 안전함 + if ("FAILED".equals(payment.getStatus())) return; + + // 포인트 환불 로직 + pointService.refundPoint(payment.getUserId(), payment.getPointAmount()); + + payment.setStatus("FAILED"); + log.info("🚨 보상 트랜잭션(환불) 완료: {}", orderId); + } + } \ No newline at end of file diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PointService.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PointService.java index cea9568..4273816 100644 --- a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PointService.java +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/service/PointService.java @@ -16,29 +16,30 @@ @Transactional public class PointService { - private final PointMasterRepository pointRepository; + private final PointMasterRepository pointMasterRepository; private final PointHistoryRepository pointHistoryRepository; /** - * [기능 1] 포인트 적립 (Upsert) - * - 이미 있는 유저면? -> 기존 금액 + 충전 금액 (오류 안 남!) + * [기능 1] 포인트 적립 (Charge) + * - 이미 있는 유저면? -> 기존 금액 + 충전 금액 * - 없는 유저면? -> 새로 생성 - * - 그리고 히스토리에 기록! + * - 히스토리에 기록 */ public PointMaster chargePoint(String userUuid, long amount) { // 1. 유저 조회 (없으면 새로 생성 - 0원으로 초기화) - PointMaster pointMaster = pointRepository.findByUserUuidWithLock(userUuid) - .orElseGet(() -> pointRepository.save(new PointMaster(userUuid, 0))); + PointMaster pointMaster = pointMasterRepository.findByUserUuidWithLock(userUuid) + .orElseGet(() -> pointMasterRepository.save(new PointMaster(userUuid, 0))); - // 2. 금액 합산 (Entity 메서드 사용) + // 2. 포인트 합산 (Entity 메서드 사용) pointMaster.charge(amount); // 3. 마스터 테이블 저장 (Insert or Update) - PointMaster savedMaster = pointRepository.save(pointMaster); + PointMaster savedMaster = pointMasterRepository.save(pointMaster); - // 4. 히스토리 저장 (기록 남기기) + // 4. 히스토리 저장 saveHistory(savedMaster, amount, "CHARGE"); + // 5. 로그 출력 log.info("💰 포인트 충전 완료: 사용자={}, 충전액={}, 잔액={}", userUuid, amount, savedMaster.getCurrentAmt()); return savedMaster; @@ -48,49 +49,48 @@ public PointMaster chargePoint(String userUuid, long amount) { * [기능 2] 포인트 사용 (결제) * - 비관적 락(Lock)을 걸어서 동시성 이슈 방지 * - 잔액 체크 후 차감 - * - 히스토리 저장 추가 + * - 히스토리에 기록 */ public void usePoint(String userId, Long amount) { // 1. 내 지갑 찾기 (Lock 사용) - PointMaster wallet = pointRepository.findByUserUuidWithLock(userId) + PointMaster wallet = pointMasterRepository.findByUserUuidWithLock(userId) .orElseThrow(() -> new RuntimeException("사용자의 포인트 지갑을 찾을 수 없습니다.")); // 2. 잔액 확인 (비즈니스 로직) if (wallet.getCurrentAmt() < amount) { - throw new RuntimeException("포인트 잔액이 부족합니다!"); // -> 결제 전체 취소됨 + throw new RuntimeException("포인트 잔액이 부족합니다."); // -> 결제 전체 취소 } - // 3. 돈 깎기 + // 3. 포인트 차감 wallet.use(amount); - // 4. 히스토리 저장 (사용 기록) + // 4. 히스토리 저장 saveHistory(wallet, amount, "USE"); - // 5. 기존 로그 유지 - log.info("💰 포인트 차감 완료: 사용자={}, 차감액={}, 잔액={}", userId, amount, wallet.getCurrentAmt()); + // 5. 로그 출력 + log.info("⛔ 포인트 차감 완료: 사용자={}, 차감액={}, 잔액={}", userId, amount, wallet.getCurrentAmt()); } /** * [기능 3] 보상 트랜잭션 (포인트 환불/롤백) - * - 온프레미스(은행) 쪽에서 에러났을 때 호출됨 - * - 히스토리 저장 추가 + * - 온프레미스(은행) 쪽에서 에러났을 때 호출 + * - 히스토리에 기록 */ - public void refund(String userId, Long amount) { - PointMaster wallet = pointRepository.findByUserUuidWithLock(userId) + public void refundPoint(String userId, Long amount) { + PointMaster wallet = pointMasterRepository.findByUserUuidWithLock(userId) .orElseThrow(() -> new RuntimeException("사용자의 포인트 지갑을 찾을 수 없습니다.")); - // 1. 다시 돈 채워주기 (refund 메서드가 없다면 charge 사용 가능) - // Entity에 refund 메서드가 없다면 charge(amount)와 로직이 같습니다. - wallet.charge(amount); + // 1. 다시 포인트 충전(환불) + wallet.refund(amount); - // 2. 히스토리 저장 (환불 기록) + // 2. 히스토리에 기록 saveHistory(wallet, amount, "REFUND"); // 3. 기존 로그 유지 log.info("↩️ 포인트 환불(롤백) 완료: 사용자={}, 환불액={}", userId, amount); } - // [내부 메서드] 히스토리 저장 로직 공통화 (중복 제거) + // [내부 메서드] 히스토리 저장 로직 private void saveHistory(PointMaster master, Long amount, String type) { PointHistory history = PointHistory.builder() .pointId(master.getPointId()) diff --git a/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/util/OrderIdGenerator.java b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/util/OrderIdGenerator.java new file mode 100644 index 0000000..35ec7b4 --- /dev/null +++ b/cloud-services/msa-point-service/src/main/java/com/techsemina/msa/pointservice/util/OrderIdGenerator.java @@ -0,0 +1,17 @@ +package com.techsemina.msa.pointservice.util; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.UUID; +import java.util.concurrent.ThreadLocalRandom; + +public class OrderIdGenerator { + + // 결과 예시: PAY-20260203-163055-19283 + public static String generateOrderId() { + + String timestamp = LocalDateTime.now().format(DateTimeFormatter.ofPattern("yyyyMMdd-HHmmssSSS")); + String randomNo = UUID.randomUUID().toString().replace("-", "").substring(0, 12); + return "PAY-" + timestamp + "-" + randomNo; + } +} \ No newline at end of file diff --git a/on-premise/.DS_Store b/on-premise/.DS_Store new file mode 100644 index 0000000..db0804a Binary files /dev/null and b/on-premise/.DS_Store differ diff --git a/on-premise/core-payment-service/gradlew b/on-premise/core-payment-service/gradlew old mode 100644 new mode 100755 diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/CorePaymentServiceApplication.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/CorePaymentServiceApplication.java index 592b2c4..a847918 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/CorePaymentServiceApplication.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/CorePaymentServiceApplication.java @@ -3,8 +3,10 @@ import org.springframework.boot.SpringApplication; import org.springframework.boot.autoconfigure.SpringBootApplication; import org.springframework.kafka.annotation.EnableKafka; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; @EnableKafka +@EnableJpaAuditing @SpringBootApplication public class CorePaymentServiceApplication { diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/config/KafkaConsumerConfig.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/config/KafkaConsumerConfig.java index d0ff5a7..f500057 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/config/KafkaConsumerConfig.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/config/KafkaConsumerConfig.java @@ -4,13 +4,13 @@ import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; import org.apache.kafka.clients.consumer.ConsumerConfig; import org.apache.kafka.common.serialization.StringDeserializer; +import org.springframework.beans.factory.annotation.Value; 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.DefaultKafkaConsumerFactory; -import org.springframework.beans.factory.annotation.Value; import java.util.HashMap; import java.util.Map; @@ -19,6 +19,7 @@ @Configuration public class KafkaConsumerConfig { + // application.yml에서 값을 가져오되, 없으면 기본값 사용 @Value("${spring.kafka.bootstrap-servers:127.0.0.1:9092}") private String bootstrapServers; diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/controller/PaymentController.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/controller/PaymentController.java index dde0525..fd1b09a 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/controller/PaymentController.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/controller/PaymentController.java @@ -2,6 +2,7 @@ import com.fisa.core_payment_service.dto.AmountRequestDto; import com.fisa.core_payment_service.service.PaymentService; +import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.web.bind.annotation.*; @@ -29,7 +30,7 @@ public BigDecimal getBalance(@PathVariable String accountNo) { // 입금 @PostMapping("/accounts/{accountNo}/deposit") - public BigDecimal deposit(@PathVariable String accountNo, @RequestBody AmountRequestDto request) { + public BigDecimal deposit(@PathVariable String accountNo, @Valid @RequestBody AmountRequestDto request) { return paymentService.deposit(accountNo, request.userUuid(), request.amount()); } diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/AmountRequestDto.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/AmountRequestDto.java index 4993031..0767269 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/AmountRequestDto.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/AmountRequestDto.java @@ -1,6 +1,18 @@ package com.fisa.core_payment_service.dto; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Positive; + import java.math.BigDecimal; -public record AmountRequestDto(String userUuid, BigDecimal amount) { +public record AmountRequestDto( + + @NotBlank(message = "사용자 UUID는 필수입니다.") + String userUuid, + + @NotNull(message = "금액은 필수입니다.") + @Positive(message = "금액은 0보다 커야 합니다.") + BigDecimal amount +) { } diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashRequestDTO.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashRequestDTO.java new file mode 100644 index 0000000..5d56249 --- /dev/null +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashRequestDTO.java @@ -0,0 +1,15 @@ +package com.fisa.core_payment_service.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class CashRequestDTO { + private String loginId; // 사용자 식별자 + private Long amount; // 출금액 +} \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashResponseDTO.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashResponseDTO.java new file mode 100644 index 0000000..a0645c3 --- /dev/null +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/dto/CashResponseDTO.java @@ -0,0 +1,16 @@ +package com.fisa.core_payment_service.dto; + +import lombok.AllArgsConstructor; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.ToString; + +@Getter +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class CashResponseDTO { + private String loginId; // 사용자 식별자 + private String status; // "SUCCESS" or "FAIL" + private String message; +} \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/repository/AccountRepository.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/repository/AccountRepository.java index b260def..c05066b 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/repository/AccountRepository.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/repository/AccountRepository.java @@ -2,6 +2,10 @@ import com.fisa.core_payment_service.domain.Account; import org.springframework.data.jpa.repository.JpaRepository; +import java.util.Optional; public interface AccountRepository extends JpaRepository { + + // uuid로 계좌 조회 + Optional findByUserUuid(String userUuid); } \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/CouponManageService.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/CouponManageService.java deleted file mode 100644 index 2f2d4ab..0000000 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/CouponManageService.java +++ /dev/null @@ -1,33 +0,0 @@ -package com.fisa.core_payment_service.service; - -import com.fisa.core_payment_service.domain.IssuedCoupon; -import com.fisa.core_payment_service.dto.CouponIssueMessage; -import com.fisa.core_payment_service.repository.CouponRepository; -import lombok.RequiredArgsConstructor; -import lombok.extern.slf4j.Slf4j; -import org.springframework.stereotype.Service; -import org.springframework.transaction.annotation.Transactional; - -@Slf4j -@Service -@RequiredArgsConstructor -public class CouponManageService { - - private final CouponRepository couponRepository; - - @Transactional - public void issueCoupon(CouponIssueMessage dto) { - - // Entity 생성 - IssuedCoupon coupon = IssuedCoupon.builder() - .userUuid(dto.getUserUuid()) - .couponCode(dto.getCouponCode()) - .build(); - - - couponRepository.save(coupon); - - log.info("💾 [Core Service] 쿠폰 발급 완료! User: {}, Code: {}", - dto.getUserUuid(), dto.getCouponCode()); - } -} \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaConsumerService.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaConsumerService.java index 34ecfc1..8de929a 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaConsumerService.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaConsumerService.java @@ -2,21 +2,28 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; +import com.fisa.core_payment_service.domain.Account; +import com.fisa.core_payment_service.dto.CashRequestDTO; +import com.fisa.core_payment_service.dto.CashResponseDTO; import com.fisa.core_payment_service.dto.CouponIssueMessage; import com.fisa.core_payment_service.dto.DepositMessage; +import com.fisa.core_payment_service.repository.AccountRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Service; +import java.math.BigDecimal; + @Slf4j @Service @RequiredArgsConstructor public class KafkaConsumerService { private final PaymentService paymentService; - private final CouponManageService couponService; private final ObjectMapper objectMapper; + private final AccountRepository accountRepository; + private final KafkaProducerService kafkaProducerService; // 1. 입금 처리 @KafkaListener(topics = "bank_deposit", groupId = "core-group") @@ -25,7 +32,7 @@ public void consumeDeposit(String message) { DepositMessage depositDto = objectMapper.readValue(message, DepositMessage.class); paymentService.deposit( - depositDto.getAccountNo(), + depositDto.getAccountNo(), // ★ 수정됨 (getAccountNumber() -> getAccountNo()) depositDto.getUserUuid(), depositDto.getAmount() ); @@ -39,14 +46,56 @@ public void consumeDeposit(String message) { } } - // 2. 쿠폰 처리 - @KafkaListener(topics = "coupon_issue", groupId = "core-group") - public void consumeCouponIssue(String message) { + + // 2. 현금 출금 처리 (PointService와 연결) + @KafkaListener(topics = "core-withdraw-request", groupId = "core-group") + public void consumeWithdraw(String message) { + + CashRequestDTO requestDto = null; + try { - CouponIssueMessage couponDto = objectMapper.readValue(message, CouponIssueMessage.class); - couponService.issueCoupon(couponDto); + // (1) 메시지 파싱 + requestDto = objectMapper.readValue(message, CashRequestDTO.class); + log.info("📉 [Core] 출금 요청 수신: loginId={}", requestDto.getLoginId()); + + // (2) 계좌 조회 + Account account = accountRepository.findByUserUuid(requestDto.getLoginId()) + .orElseThrow(() -> new IllegalArgumentException("계좌를 찾을 수 없습니다.")); + + // (3) 출금 비즈니스 로직 + paymentService.withdraw( + account.getAccountNo(), + requestDto.getLoginId(), + BigDecimal.valueOf(requestDto.getAmount()) + ); + + // (4) 성공 이벤트 발행 -> PointService의 토픽 이름인 "core-result"로 변경 + CashResponseDTO successResponse = new CashResponseDTO( + requestDto.getLoginId(), + "SUCCESS", + "정상 출금 완료" + ); + + // ★ 토픽 이름 변경: core-withdraw-result -> core-result + kafkaProducerService.send("core-result", successResponse); + log.info("✅ [Core] 출금 성공 -> Point Service로 전송: {}", successResponse); + } catch (JsonProcessingException e) { - log.error("❌ JSON 파싱 에러: {}", message, e); + log.error("❌ JSON 파싱 에러 (Withdraw): {}", message, e); + } catch (Exception e) { + log.error("❌ 출금 처리 실패: {}", e.getMessage()); + + if (requestDto != null) { + // (5) 실패 이벤트 발행 + CashResponseDTO failResponse = new CashResponseDTO( + requestDto.getLoginId(), + "FAIL", + e.getMessage() + ); + // ★ 토픽 이름 변경 + kafkaProducerService.send("core-result", failResponse); + log.info("⚠️ [Core] 출금 실패 -> Point Service로 전송: {}", failResponse); + } } } } \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaProducerService.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaProducerService.java new file mode 100644 index 0000000..90d35b4 --- /dev/null +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/KafkaProducerService.java @@ -0,0 +1,28 @@ +package com.fisa.core_payment_service.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaProducerService { + + private final KafkaTemplate kafkaTemplate; + private final ObjectMapper objectMapper; + + public void send(String topic, Object payload) { + try { + String jsonMessage = objectMapper.writeValueAsString(payload); + kafkaTemplate.send(topic, jsonMessage); + log.info("🚀 [Core-Producer] Sent to {}: {}", topic, jsonMessage); + } catch (JsonProcessingException e) { + log.error("❌ [Core-Producer] Serialization failed: {}", payload, e); + throw new RuntimeException("Kafka message serialization failed", e); + } + } +} \ No newline at end of file diff --git a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/PaymentService.java b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/PaymentService.java index 2868201..917622b 100644 --- a/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/PaymentService.java +++ b/on-premise/core-payment-service/src/main/java/com/fisa/core_payment_service/service/PaymentService.java @@ -1,19 +1,23 @@ package com.fisa.core_payment_service.service; import com.fisa.core_payment_service.domain.Account; +import com.fisa.core_payment_service.dto.CouponIssueMessage; import com.fisa.core_payment_service.repository.AccountRepository; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import java.math.BigDecimal; +@Slf4j @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class PaymentService { private final AccountRepository accountRepository; + private final KafkaProducerService kafkaProducerService; // 계좌 개설 @Transactional @@ -21,7 +25,24 @@ public void createAccount(String accountNo, String userUuid) { if (accountRepository.existsById(accountNo)) { throw new IllegalArgumentException("이미 존재하는 계좌입니다."); } + + // 1. 계좌 저장 accountRepository.save(Account.create(accountNo, userUuid)); + log.info("✅ 계좌 개설 완료: accountNo={}, user={}", accountNo, userUuid); + + // 2. 쿠폰 발급 요청 보내기 (Producer) + try { + CouponIssueMessage couponEvent = new CouponIssueMessage( + userUuid, + "WELCOME_COUPON", + "계좌 개설 축하 쿠폰" + ); + // coupon-service가 듣고 있는 "coupon_issue" 토픽으로 쏜다 + kafkaProducerService.send("coupon_issue", couponEvent); + + } catch (Exception e) { + log.error("⚠️ 계좌는 생성되었으나 쿠폰 발급 요청 실패: {}", e.getMessage()); + } } // 입금 @@ -31,8 +52,8 @@ public BigDecimal deposit(String accountNo, String userUuid, BigDecimal amount) .orElseThrow(() -> new IllegalArgumentException("계좌가 없습니다.")); account.validateOwner(userUuid); - account.deposit(amount); + return account.getBalance(); } @@ -43,11 +64,23 @@ public BigDecimal withdraw(String accountNo, String userUuid, BigDecimal amount) .orElseThrow(() -> new IllegalArgumentException("계좌가 없습니다.")); account.validateOwner(userUuid); - account.withdraw(amount); + return account.getBalance(); } + // [Saga용] 계좌번호 없이 ID로 출금 + @Transactional + public void withdrawByLoginId(String loginId, Long amount) { + Account account = accountRepository.findByUserUuid(loginId) + .orElseThrow(() -> new IllegalArgumentException("해당 유저의 계좌가 존재하지 않습니다.")); + + // Long -> BigDecimal 변환 후 출금 처리 + account.withdraw(BigDecimal.valueOf(amount)); + + log.info("📉 [Saga] 출금 처리 완료: user={}, amount={}", loginId, amount); + } + // 잔액 조회 public BigDecimal getBalance(String accountNo) { return accountRepository.findById(accountNo)