Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ dependencies {

//메일 인증 사용
implementation 'org.springframework.boot:spring-boot-starter-mail'

//결제 포트원
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'com.fasterxml.jackson.core:jackson-databind'

}

tasks.named('test') {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package com.itda.moamoa.domain.payment.controller;

import com.itda.moamoa.domain.payment.dto.PaymentRefundRequest;
import com.itda.moamoa.domain.payment.dto.PaymentVerifyRequest;
import com.itda.moamoa.domain.payment.service.PaymentService;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.web.bind.annotation.*;

@RestController
@RequestMapping("/api/payments")
@RequiredArgsConstructor
public class PaymentController {

private final PaymentService paymentService;

@PostMapping("/verify")
public ResponseEntity<?> verifyPayment(
@AuthenticationPrincipal UserDetails user,
@RequestBody PaymentVerifyRequest request
) {
paymentService.verifyPayment(request, user.getUsername());
return ResponseEntity.ok("결제 검증 완료");
}

@PostMapping("/refund")
public ResponseEntity<?> refundPayment(
@AuthenticationPrincipal UserDetails user,
@RequestBody PaymentRefundRequest request
) {
paymentService.refundPayment(request, user.getUsername());
return ResponseEntity.ok("환불 처리 완료");
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package com.itda.moamoa.domain.payment.dto;

public record PaymentRefundRequest(
String impUid,
int amount
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package com.itda.moamoa.domain.payment.dto;

public record PaymentVerifyRequest(
String impUid,
String merchantUid,
Long somoimId,
Long sessionId
) {}
61 changes: 61 additions & 0 deletions src/main/java/com/itda/moamoa/domain/payment/entity/Payment.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package com.itda.moamoa.domain.payment.entity;

import com.itda.moamoa.domain.session.entity.Session;
import com.itda.moamoa.domain.somoim.entity.Somoim;
import com.itda.moamoa.domain.user.entity.User;
import jakarta.persistence.*;
import lombok.*;

import java.time.LocalDateTime;

@Entity
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Payment {

@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

private String impUid; // 아임포트 결제 고유 ID
private String merchantUid; // 상점 거래 고유 ID

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "user_id")
private User user; // 결제한 사용자

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "somoim_id")
private Somoim somoim; // 소모임 정보

@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "session_id")
private Session session; // 소모임 회차 정보

private int amount; // 결제 금액

@Enumerated(EnumType.STRING)
private PaymentStatus status; // 결제 상태 (PAID, CANCELLED, FAILED 등)

private LocalDateTime paidAt;
private LocalDateTime cancelledAt;

// 쉽게 접근하기 위한 유저명 필드 (조회 편의성)
private String username;

public void markPaid() {
this.status = PaymentStatus.PAID;
this.paidAt = LocalDateTime.now();
}

public void markCancelled() {
this.status = PaymentStatus.CANCELLED;
this.cancelledAt = LocalDateTime.now();
}

public enum PaymentStatus {
READY, PAID, CANCELLED, FAILED
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
package com.itda.moamoa.domain.payment.repository;

import com.itda.moamoa.domain.payment.entity.Payment;
import com.itda.moamoa.domain.session.entity.Session;
import com.itda.moamoa.domain.somoim.entity.Somoim;
import com.itda.moamoa.domain.user.entity.User;
import org.springframework.data.jpa.repository.JpaRepository;

import java.util.List;
import java.util.Optional;

public interface PaymentRepository extends JpaRepository<Payment, Long> {

Optional<Payment> findByImpUid(String impUid);
Optional<Payment> findByMerchantUid(String merchantUid);

// 사용자별 결제 내역 조회
List<Payment> findByUserOrderByPaidAtDesc(User user);

// 사용자와 소모임별 결제 내역 조회
List<Payment> findByUserAndSomoimOrderByPaidAtDesc(User user, Somoim somoim);

// 회차에 대한 결제 내역 조회
List<Payment> findBySession(Session session);

// 사용자가 특정 회차에 결제했는지 확인
Optional<Payment> findByUserAndSessionAndStatus(User user, Session session, Payment.PaymentStatus status);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,140 @@
package com.itda.moamoa.domain.payment.service;

import com.itda.moamoa.domain.payment.dto.PaymentRefundRequest;
import com.itda.moamoa.domain.payment.dto.PaymentVerifyRequest;
import com.itda.moamoa.domain.payment.entity.Payment;
import com.itda.moamoa.domain.payment.repository.PaymentRepository;
import com.itda.moamoa.domain.session.entity.Session;
import com.itda.moamoa.domain.session.repository.SessionRepository;
import com.itda.moamoa.domain.somoim.entity.Somoim;
import com.itda.moamoa.domain.somoim.repository.SomoimRepository;
import com.itda.moamoa.domain.user.entity.User;
import com.itda.moamoa.domain.user.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.util.Map;
import java.util.Optional;

@Service
@RequiredArgsConstructor
public class PaymentService {

private final PaymentRepository paymentRepository;
private final PortOneApiClient portOneApiClient;
private final UserRepository userRepository;
private final SomoimRepository somoimRepository;
private final SessionRepository sessionRepository;

@Transactional
public void verifyPayment(PaymentVerifyRequest request, String userId) {
// 아임포트에서 결제 내역 조회
Map<String, Object> paymentInfo = portOneApiClient.getPaymentInfo(request.impUid());

int amount = (int) paymentInfo.get("amount");
String merchantUid = (String) paymentInfo.get("merchant_uid");
String impUid = (String) paymentInfo.get("imp_uid");

// DB 결제 정보 검증
Optional<Payment> optionalPayment = paymentRepository.findByMerchantUid(merchantUid);

// 사용자 조회 - 이메일 형식인 경우 username으로 조회
User user;
if (userId.contains("@")) {
// 이메일 형식의 username으로 조회
user = userRepository.findByUsername(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
} else {
try {
// 숫자 형식의 ID로 조회 시도
Long userIdLong = Long.parseLong(userId);
user = userRepository.findById(userIdLong)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
} catch (NumberFormatException e) {
// ID가 숫자 형식이 아닌 경우 username으로 조회
user = userRepository.findByUsername(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
}
}

if (optionalPayment.isPresent()) {
// 기존 결제 정보가 있는 경우
Payment payment = optionalPayment.get();

if (!payment.getUser().getId().toString().equals(user.getId().toString())) {
throw new SecurityException("본인 결제가 아님");
}

if (payment.getAmount() != amount) {
throw new IllegalArgumentException("결제 금액 불일치");
}

// 결제 상태 갱신
payment.markPaid();
paymentRepository.save(payment);
} else {
// 결제 정보가 없는 경우, 새로 생성
Somoim somoim = null;
Session session = null;

if (request.somoimId() != null) {
somoim = somoimRepository.findById(request.somoimId())
.orElse(null);
}

if (request.sessionId() != null) {
session = sessionRepository.findById(request.sessionId())
.orElse(null);
}

// 새 결제 정보 생성
Payment newPayment = Payment.builder()
.merchantUid(merchantUid)
.impUid(impUid)
.user(user)
.somoim(somoim)
.session(session)
.amount(amount)
.status(Payment.PaymentStatus.PAID)
.username(user.getUsername())
.build();

newPayment.markPaid();
paymentRepository.save(newPayment);
}
}

@Transactional
public void refundPayment(PaymentRefundRequest request, String userId) {
Payment payment = paymentRepository.findByImpUid(request.impUid())
.orElseThrow(() -> new IllegalArgumentException("결제 내역 없음"));

User user;
if (userId.contains("@")) {
// 이메일 형식의 username으로 조회
user = userRepository.findByUsername(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
} else {
try {
// 숫자 형식의 ID로 조회 시도
Long userIdLong = Long.parseLong(userId);
user = userRepository.findById(userIdLong)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
} catch (NumberFormatException e) {
// ID가 숫자 형식이 아닌 경우 username으로 조회
user = userRepository.findByUsername(userId)
.orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다"));
}
}

if (!payment.getUser().getId().toString().equals(user.getId().toString())) {
throw new SecurityException("본인 결제만 환불 가능");
}

portOneApiClient.requestRefund(request.impUid(), request.amount());

payment.markCancelled();
paymentRepository.save(payment);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
package com.itda.moamoa.domain.payment.service;

import com.itda.moamoa.global.config.PortOneProperties;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.RequiredArgsConstructor;
import org.springframework.http.*;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestTemplate;

import java.util.HashMap;
import java.util.Map;

@Component
@RequiredArgsConstructor
public class PortOneApiClient {

private final PortOneProperties portOneProperties;
private final ObjectMapper objectMapper;
private final RestTemplate restTemplate;

private String getAccessToken() {
String url = "https://api.iamport.kr/users/getToken";

String apiKey = portOneProperties.getApiKey();
String apiSecret = portOneProperties.getApiSecret();

if (apiKey == null || apiKey.isEmpty() || apiSecret == null || apiSecret.isEmpty()) {
throw new IllegalStateException("포트원 API 키 또는 시크릿 키가 설정되지 않았습니다.");
}

// 직접 JSON 문자열 생성
String jsonBody = String.format("{\"imp_key\":\"%s\",\"imp_secret\":\"%s\"}", apiKey, apiSecret);

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);

HttpEntity<String> request = new HttpEntity<>(jsonBody, headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);

// 응답 본문 출력
System.out.println("Response status: " + response.getStatusCode());
System.out.println("Response body: " + response.getBody());

if (response.getStatusCode() != HttpStatus.OK) {
throw new IllegalStateException("아임포트 토큰 요청 실패: " + response.getBody());
}

try {
JsonNode json = objectMapper.readTree(response.getBody());
return json.get("response").get("access_token").asText();
} catch (Exception e) {
throw new RuntimeException("토큰 파싱 실패", e);
}
}

public Map<String, Object> getPaymentInfo(String impUid) {
String accessToken = getAccessToken();
String url = "https://api.iamport.kr/payments/" + impUid;

HttpHeaders headers = new HttpHeaders();
headers.setBearerAuth(accessToken);

HttpEntity<Void> request = new HttpEntity<>(headers);
ResponseEntity<String> response = restTemplate.exchange(url, HttpMethod.GET, request, String.class);

try {
JsonNode json = objectMapper.readTree(response.getBody());
JsonNode responseNode = json.get("response");

Map<String, Object> result = new HashMap<>();
result.put("imp_uid", responseNode.get("imp_uid").asText());
result.put("merchant_uid", responseNode.get("merchant_uid").asText());
result.put("amount", responseNode.get("amount").asInt());

return result;
} catch (Exception e) {
throw new RuntimeException("결제 정보 파싱 실패", e);
}
}

public void requestRefund(String impUid, int amount) {
String accessToken = getAccessToken();
String url = "https://api.iamport.kr/payments/cancel";

Map<String, Object> body = new HashMap<>();
body.put("imp_uid", impUid);
body.put("amount", amount); // 부분 환불 가능

HttpHeaders headers = new HttpHeaders();
headers.setContentType(MediaType.APPLICATION_JSON);
headers.setBearerAuth(accessToken);

HttpEntity<Map<String, Object>> request = new HttpEntity<>(body, headers);
ResponseEntity<String> response = restTemplate.postForEntity(url, request, String.class);

if (!response.getStatusCode().is2xxSuccessful()) {
throw new RuntimeException("환불 요청 실패: " + response.getBody());
}
}
}
Loading
Loading