From 4a37fe8e67701284f285b3288e3f431d9a34968d Mon Sep 17 00:00:00 2001 From: skdudnayoung Date: Thu, 15 May 2025 15:18:15 +0900 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20PortOneApiClient=20-=20=EA=B2=B0?= =?UTF-8?q?=EC=A0=9C=20=EA=B8=B0=EB=8A=A5=20=ED=8F=AC=ED=8A=B8=EC=9B=90=20?= =?UTF-8?q?=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- build.gradle | 5 + .../payment/service/PortOneApiClient.java | 91 +++++++++++++++++++ .../global/config/PortOneProperties.java | 21 +++++ src/main/resources/application.yml | 6 +- 4 files changed, 122 insertions(+), 1 deletion(-) create mode 100644 src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java create mode 100644 src/main/java/com/itda/moamoa/global/config/PortOneProperties.java diff --git a/build.gradle b/build.gradle index 115dc5c..94d5bee 100644 --- a/build.gradle +++ b/build.gradle @@ -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') { diff --git a/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java new file mode 100644 index 0000000..5562079 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java @@ -0,0 +1,91 @@ +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 = new ObjectMapper(); + private final RestTemplate restTemplate = new RestTemplate(); + + private String getAccessToken() { + String url = "https://api.iamport.kr/users/getToken"; + + Map body = new HashMap<>(); + body.put("imp_key", portOneProperties.getApiKey()); + body.put("imp_secret", portOneProperties.getApiSecret()); + + HttpHeaders headers = new HttpHeaders(); + headers.setContentType(MediaType.APPLICATION_JSON); + + HttpEntity> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + if (response.getStatusCode() != HttpStatus.OK) { + throw new IllegalStateException("아임포트 토큰 요청 실패"); + } + + try { + JsonNode json = objectMapper.readTree(response.getBody()); + return json.get("response").get("access_token").asText(); + } catch (Exception e) { + throw new RuntimeException("토큰 파싱 실패", e); + } + } + + public Map getPaymentInfo(String impUid) { + String accessToken = getAccessToken(); + String url = "https://api.iamport.kr/payments/" + impUid; + + HttpHeaders headers = new HttpHeaders(); + headers.setBearerAuth(accessToken); + + HttpEntity request = new HttpEntity<>(headers); + ResponseEntity response = restTemplate.exchange(url, HttpMethod.GET, request, String.class); + + try { + JsonNode json = objectMapper.readTree(response.getBody()); + JsonNode responseNode = json.get("response"); + + Map 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 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> request = new HttpEntity<>(body, headers); + ResponseEntity response = restTemplate.postForEntity(url, request, String.class); + + if (!response.getStatusCode().is2xxSuccessful()) { + throw new RuntimeException("환불 요청 실패: " + response.getBody()); + } + } +} diff --git a/src/main/java/com/itda/moamoa/global/config/PortOneProperties.java b/src/main/java/com/itda/moamoa/global/config/PortOneProperties.java new file mode 100644 index 0000000..65437da --- /dev/null +++ b/src/main/java/com/itda/moamoa/global/config/PortOneProperties.java @@ -0,0 +1,21 @@ +package com.itda.moamoa.global.config; + +import lombok.Getter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Configuration; + +@Getter +@Configuration +@ConfigurationProperties(prefix = "portone") +public class PortOneProperties { + private String apiKey; + private String apiSecret; + + public void setApiKey(String apiKey) { + this.apiKey = apiKey; + } + + public void setApiSecret(String apiSecret) { + this.apiSecret = apiSecret; + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 0675c34..2c02a0d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -56,4 +56,8 @@ cloud: logging.level: org.hibernate.SQL: debug - org.hibernate.orm.jdbc.bind: trace \ No newline at end of file + org.hibernate.orm.jdbc.bind: trace + +portone: + api-key: ${PORTONE_REST_API_KEY} + api-secret: ${PORTONE_REST_API_SECRET} \ No newline at end of file From cc0e0cac6588177e124294ccc2182249b1dab7a5 Mon Sep 17 00:00:00 2001 From: skdudnayoung Date: Thu, 15 May 2025 15:18:33 +0900 Subject: [PATCH 2/5] =?UTF-8?q?feat:=20=EA=B2=B0=EC=A0=9C=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=B4=88=EA=B8=B0=20=EC=84=A4=EA=B3=84?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/controller/PaymentController.java | 36 +++++++++++ .../payment/dto/PaymentRefundRequest.java | 6 ++ .../payment/dto/PaymentVerifyRequest.java | 8 +++ .../moamoa/domain/payment/entity/Payment.java | 61 +++++++++++++++++++ .../payment/repository/PaymentRepository.java | 28 +++++++++ .../payment/service/PaymentService.java | 59 ++++++++++++++++++ 6 files changed, 198 insertions(+) create mode 100644 src/main/java/com/itda/moamoa/domain/payment/controller/PaymentController.java create mode 100644 src/main/java/com/itda/moamoa/domain/payment/dto/PaymentRefundRequest.java create mode 100644 src/main/java/com/itda/moamoa/domain/payment/dto/PaymentVerifyRequest.java create mode 100644 src/main/java/com/itda/moamoa/domain/payment/entity/Payment.java create mode 100644 src/main/java/com/itda/moamoa/domain/payment/repository/PaymentRepository.java create mode 100644 src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java diff --git a/src/main/java/com/itda/moamoa/domain/payment/controller/PaymentController.java b/src/main/java/com/itda/moamoa/domain/payment/controller/PaymentController.java new file mode 100644 index 0000000..0cbd442 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/controller/PaymentController.java @@ -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("환불 처리 완료"); + } +} diff --git a/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentRefundRequest.java b/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentRefundRequest.java new file mode 100644 index 0000000..225212e --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentRefundRequest.java @@ -0,0 +1,6 @@ +package com.itda.moamoa.domain.payment.dto; + +public record PaymentRefundRequest( + String impUid, + int amount +) {} diff --git a/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentVerifyRequest.java b/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentVerifyRequest.java new file mode 100644 index 0000000..6a5e7e1 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/dto/PaymentVerifyRequest.java @@ -0,0 +1,8 @@ +package com.itda.moamoa.domain.payment.dto; + +public record PaymentVerifyRequest( + String impUid, + String merchantUid, + Long somoimId, + Long sessionId +) {} diff --git a/src/main/java/com/itda/moamoa/domain/payment/entity/Payment.java b/src/main/java/com/itda/moamoa/domain/payment/entity/Payment.java new file mode 100644 index 0000000..6e998be --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/entity/Payment.java @@ -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 + } +} diff --git a/src/main/java/com/itda/moamoa/domain/payment/repository/PaymentRepository.java b/src/main/java/com/itda/moamoa/domain/payment/repository/PaymentRepository.java new file mode 100644 index 0000000..60cfe1f --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/repository/PaymentRepository.java @@ -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 { + + Optional findByImpUid(String impUid); + Optional findByMerchantUid(String merchantUid); + + // 사용자별 결제 내역 조회 + List findByUserOrderByPaidAtDesc(User user); + + // 사용자와 소모임별 결제 내역 조회 + List findByUserAndSomoimOrderByPaidAtDesc(User user, Somoim somoim); + + // 회차에 대한 결제 내역 조회 + List findBySession(Session session); + + // 사용자가 특정 회차에 결제했는지 확인 + Optional findByUserAndSessionAndStatus(User user, Session session, Payment.PaymentStatus status); +} diff --git a/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java b/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java new file mode 100644 index 0000000..d6cf8db --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java @@ -0,0 +1,59 @@ +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Map; + +@Service +@RequiredArgsConstructor +public class PaymentService { + + private final PaymentRepository paymentRepository; + private final PortOneApiClient portOneApiClient; + + @Transactional + public void verifyPayment(PaymentVerifyRequest request, String userId) { + // 아임포트에서 결제 내역 조회 + Map paymentInfo = portOneApiClient.getPaymentInfo(request.impUid()); + + int amount = (int) paymentInfo.get("amount"); + String merchantUid = (String) paymentInfo.get("merchant_uid"); + + // DB 결제 정보 검증 + Payment payment = paymentRepository.findByMerchantUid(merchantUid) + .orElseThrow(() -> new IllegalArgumentException("해당 주문 없음")); + + if (!payment.getUser().getId().toString().equals(userId)) { + throw new SecurityException("본인 결제가 아님"); + } + + if (payment.getAmount() != amount) { + throw new IllegalArgumentException("결제 금액 불일치"); + } + + // 결제 상태 갱신 + payment.markPaid(); + paymentRepository.save(payment); + } + + @Transactional + public void refundPayment(PaymentRefundRequest request, String userId) { + Payment payment = paymentRepository.findByImpUid(request.impUid()) + .orElseThrow(() -> new IllegalArgumentException("결제 내역 없음")); + + if (!payment.getUser().getId().toString().equals(userId)) { + throw new SecurityException("본인 결제만 환불 가능"); + } + + portOneApiClient.requestRefund(request.impUid(), request.amount()); + + payment.markCancelled(); + paymentRepository.save(payment); + } +} \ No newline at end of file From f0bbde44631367d599d85bd91d4378a2d5787fee Mon Sep 17 00:00:00 2001 From: skdudnayoung Date: Thu, 15 May 2025 15:18:53 +0900 Subject: [PATCH 3/5] =?UTF-8?q?feat:=20=ED=9A=8C=EC=B0=A8=EB=B3=84=20?= =?UTF-8?q?=EA=B2=B0=EC=A0=9C=20=EC=84=A4=EA=B3=84=20-=20Session?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../session/controller/SessionController.java | 61 +++++++++++++++ .../domain/session/dto/SessionRequestDTO.java | 12 +++ .../session/dto/SessionResponseDTO.java | 30 ++++++++ .../moamoa/domain/session/entity/Session.java | 57 ++++++++++++++ .../session/repository/SessionRepository.java | 16 ++++ .../session/service/SessionService.java | 76 +++++++++++++++++++ 6 files changed, 252 insertions(+) create mode 100644 src/main/java/com/itda/moamoa/domain/session/controller/SessionController.java create mode 100644 src/main/java/com/itda/moamoa/domain/session/dto/SessionRequestDTO.java create mode 100644 src/main/java/com/itda/moamoa/domain/session/dto/SessionResponseDTO.java create mode 100644 src/main/java/com/itda/moamoa/domain/session/entity/Session.java create mode 100644 src/main/java/com/itda/moamoa/domain/session/repository/SessionRepository.java create mode 100644 src/main/java/com/itda/moamoa/domain/session/service/SessionService.java diff --git a/src/main/java/com/itda/moamoa/domain/session/controller/SessionController.java b/src/main/java/com/itda/moamoa/domain/session/controller/SessionController.java new file mode 100644 index 0000000..c3721d8 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/controller/SessionController.java @@ -0,0 +1,61 @@ +package com.itda.moamoa.domain.session.controller; + +import com.itda.moamoa.domain.session.dto.SessionRequestDTO; +import com.itda.moamoa.domain.session.dto.SessionResponseDTO; +import com.itda.moamoa.domain.session.entity.Session; +import com.itda.moamoa.domain.session.service.SessionService; +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.*; + +import java.util.List; + +@RestController +@RequestMapping("/api/sessions") +@RequiredArgsConstructor +public class SessionController { + + private final SessionService sessionService; + + // 회차 생성 + @PostMapping + public ResponseEntity createSession( + @AuthenticationPrincipal UserDetails user, + @RequestBody SessionRequestDTO request) { + + // TODO: 소모임 관리자 권한 확인 로직 추가 필요 + + SessionResponseDTO response = sessionService.createSession(request); + return ResponseEntity.ok(response); + } + + // 소모임별 회차 목록 조회 + @GetMapping("/somoim/{somoimId}") + public ResponseEntity> getSessions(@PathVariable Long somoimId) { + List sessions = sessionService.getSessions(somoimId); + return ResponseEntity.ok(sessions); + } + + // 회차 상세 조회 + @GetMapping("/{sessionId}") + public ResponseEntity getSession(@PathVariable Long sessionId) { + SessionResponseDTO session = sessionService.getSession(sessionId); + return ResponseEntity.ok(session); + } + + // 회차 상태 변경 + @PatchMapping("/{sessionId}/status") + public ResponseEntity updateSessionStatus( + @AuthenticationPrincipal UserDetails user, + @PathVariable Long sessionId, + @RequestParam String status) { + + // TODO: 소모임 관리자 권한 확인 로직 추가 필요 + + Session.SessionStatus sessionStatus = Session.SessionStatus.valueOf(status); + SessionResponseDTO response = sessionService.updateSessionStatus(sessionId, sessionStatus); + return ResponseEntity.ok(response); + } +} \ No newline at end of file diff --git a/src/main/java/com/itda/moamoa/domain/session/dto/SessionRequestDTO.java b/src/main/java/com/itda/moamoa/domain/session/dto/SessionRequestDTO.java new file mode 100644 index 0000000..bf04b62 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/dto/SessionRequestDTO.java @@ -0,0 +1,12 @@ +package com.itda.moamoa.domain.session.dto; + +import java.time.LocalDate; + +public record SessionRequestDTO( + Long somoimId, + int sessionNumber, + LocalDate sessionDate, + int price, + String location, + String description +) {} \ No newline at end of file diff --git a/src/main/java/com/itda/moamoa/domain/session/dto/SessionResponseDTO.java b/src/main/java/com/itda/moamoa/domain/session/dto/SessionResponseDTO.java new file mode 100644 index 0000000..b73997b --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/dto/SessionResponseDTO.java @@ -0,0 +1,30 @@ +package com.itda.moamoa.domain.session.dto; + +import com.itda.moamoa.domain.session.entity.Session; +import java.time.LocalDate; + +public record SessionResponseDTO( + Long id, + Long somoimId, + int sessionNumber, + LocalDate sessionDate, + int price, + String status, + String location, + String description, + int paymentCount +) { + public static SessionResponseDTO from(Session session) { + return new SessionResponseDTO( + session.getId(), + session.getSomoim().getId(), + session.getSessionNumber(), + session.getSessionDate(), + session.getPrice(), + session.getStatus().name(), + session.getLocation(), + session.getDescription(), + session.getPayments().size() + ); + } +} \ No newline at end of file diff --git a/src/main/java/com/itda/moamoa/domain/session/entity/Session.java b/src/main/java/com/itda/moamoa/domain/session/entity/Session.java new file mode 100644 index 0000000..f8e7909 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/entity/Session.java @@ -0,0 +1,57 @@ +package com.itda.moamoa.domain.session.entity; + +import com.itda.moamoa.domain.payment.entity.Payment; +import com.itda.moamoa.domain.somoim.entity.Somoim; +import com.itda.moamoa.global.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.util.ArrayList; +import java.util.List; + +@Entity +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Session extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "somoim_id") + private Somoim somoim; // 소모임 정보 + + private int sessionNumber; // 회차 번호 (1회차, 2회차 등) + + private LocalDate sessionDate; // 회차 진행 예정일 + + private int price; // 회차별 참가 비용 + + @Enumerated(EnumType.STRING) + private SessionStatus status; // 회차 상태 (SCHEDULED, IN_PROGRESS, COMPLETED, CANCELLED) + + private String location; // 회차 장소 + + private String description; // 회차 설명 + + @Builder.Default + @OneToMany(mappedBy = "session", cascade = CascadeType.ALL) + private List payments = new ArrayList<>(); // 회차 참가 결제 정보 + + // 회차 상태 변경 메서드 + public void updateStatus(SessionStatus status) { + this.status = status; + } + + public enum SessionStatus { + SCHEDULED, // 예정됨 + IN_PROGRESS, // 진행 중 + COMPLETED, // 완료됨 + CANCELLED // 취소됨 + } +} \ No newline at end of file diff --git a/src/main/java/com/itda/moamoa/domain/session/repository/SessionRepository.java b/src/main/java/com/itda/moamoa/domain/session/repository/SessionRepository.java new file mode 100644 index 0000000..ea34c58 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/repository/SessionRepository.java @@ -0,0 +1,16 @@ +package com.itda.moamoa.domain.session.repository; + +import com.itda.moamoa.domain.session.entity.Session; +import com.itda.moamoa.domain.somoim.entity.Somoim; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface SessionRepository extends JpaRepository { + + // 소모임 ID로 모든 회차 조회 + List findBySomoimOrderBySessionNumberAsc(Somoim somoim); + + // 소모임 ID와 회차 번호로 회차 조회 + Session findBySomoimAndSessionNumber(Somoim somoim, int sessionNumber); +} \ No newline at end of file diff --git a/src/main/java/com/itda/moamoa/domain/session/service/SessionService.java b/src/main/java/com/itda/moamoa/domain/session/service/SessionService.java new file mode 100644 index 0000000..efea558 --- /dev/null +++ b/src/main/java/com/itda/moamoa/domain/session/service/SessionService.java @@ -0,0 +1,76 @@ +package com.itda.moamoa.domain.session.service; + +import com.itda.moamoa.domain.session.dto.SessionRequestDTO; +import com.itda.moamoa.domain.session.dto.SessionResponseDTO; +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 lombok.RequiredArgsConstructor; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class SessionService { + + private final SessionRepository sessionRepository; + private final SomoimRepository somoimRepository; + + // 회차 생성 + @Transactional + public SessionResponseDTO createSession(SessionRequestDTO request) { + Somoim somoim = somoimRepository.findById(request.somoimId()) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 소모임입니다.")); + + Session session = Session.builder() + .somoim(somoim) + .sessionNumber(request.sessionNumber()) + .sessionDate(request.sessionDate()) + .price(request.price()) + .location(request.location()) + .description(request.description()) + .status(Session.SessionStatus.SCHEDULED) + .build(); + + sessionRepository.save(session); + + return SessionResponseDTO.from(session); + } + + // 소모임별 회차 목록 조회 + @Transactional(readOnly = true) + public List getSessions(Long somoimId) { + Somoim somoim = somoimRepository.findById(somoimId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 소모임입니다.")); + + return sessionRepository.findBySomoimOrderBySessionNumberAsc(somoim) + .stream() + .map(SessionResponseDTO::from) + .collect(Collectors.toList()); + } + + // 회차 상세 조회 + @Transactional(readOnly = true) + public SessionResponseDTO getSession(Long sessionId) { + Session session = sessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회차입니다.")); + + return SessionResponseDTO.from(session); + } + + // 회차 상태 변경 + @Transactional + public SessionResponseDTO updateSessionStatus(Long sessionId, Session.SessionStatus status) { + Session session = sessionRepository.findById(sessionId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회차입니다.")); + + session.updateStatus(status); + sessionRepository.save(session); + + return SessionResponseDTO.from(session); + } +} \ No newline at end of file From 17c7f1862ab618b587c2e0b531caca8a86564062 Mon Sep 17 00:00:00 2001 From: skdudnayoung Date: Thu, 15 May 2025 15:50:21 +0900 Subject: [PATCH 4/5] =?UTF-8?q?feat:=20/verify=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../payment/service/PaymentService.java | 105 ++++++++++++++++-- .../payment/service/PortOneApiClient.java | 27 +++-- .../global/config/RestTemplateConfig.java | 14 +++ src/main/resources/application.yml | 2 +- 4 files changed, 128 insertions(+), 20 deletions(-) create mode 100644 src/main/java/com/itda/moamoa/global/config/RestTemplateConfig.java diff --git a/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java b/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java index d6cf8db..95a63f5 100644 --- a/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java +++ b/src/main/java/com/itda/moamoa/domain/payment/service/PaymentService.java @@ -4,11 +4,18 @@ 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 @@ -16,6 +23,9 @@ 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) { @@ -24,22 +34,75 @@ public void verifyPayment(PaymentVerifyRequest request, String userId) { int amount = (int) paymentInfo.get("amount"); String merchantUid = (String) paymentInfo.get("merchant_uid"); + String impUid = (String) paymentInfo.get("imp_uid"); // DB 결제 정보 검증 - Payment payment = paymentRepository.findByMerchantUid(merchantUid) - .orElseThrow(() -> new IllegalArgumentException("해당 주문 없음")); - - if (!payment.getUser().getId().toString().equals(userId)) { - throw new SecurityException("본인 결제가 아님"); + Optional 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("결제 금액 불일치"); - } + if (payment.getAmount() != amount) { + throw new IllegalArgumentException("결제 금액 불일치"); + } - // 결제 상태 갱신 - payment.markPaid(); - paymentRepository.save(payment); + // 결제 상태 갱신 + 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 @@ -47,7 +110,25 @@ public void refundPayment(PaymentRefundRequest request, String userId) { Payment payment = paymentRepository.findByImpUid(request.impUid()) .orElseThrow(() -> new IllegalArgumentException("결제 내역 없음")); - if (!payment.getUser().getId().toString().equals(userId)) { + 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("본인 결제만 환불 가능"); } diff --git a/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java index 5562079..5eaa9e3 100644 --- a/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java +++ b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java @@ -16,24 +16,37 @@ public class PortOneApiClient { private final PortOneProperties portOneProperties; - private final ObjectMapper objectMapper = new ObjectMapper(); - private final RestTemplate restTemplate = new RestTemplate(); + 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(); + System.out.println("Using API Key: " + apiKey); + System.out.println("Using API Secret: " + apiSecret); + + if (apiKey == null || apiKey.isEmpty() || apiSecret == null || apiSecret.isEmpty()) { + throw new IllegalStateException("포트원 API 키 또는 시크릿 키가 설정되지 않았습니다."); + } - Map body = new HashMap<>(); - body.put("imp_key", portOneProperties.getApiKey()); - body.put("imp_secret", portOneProperties.getApiSecret()); + // 직접 JSON 문자열 생성 + String jsonBody = String.format("{\"imp_key\":\"%s\",\"imp_secret\":\"%s\"}", apiKey, apiSecret); + System.out.println("Request body: " + jsonBody); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); - HttpEntity> request = new HttpEntity<>(body, headers); + HttpEntity request = new HttpEntity<>(jsonBody, headers); ResponseEntity 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("아임포트 토큰 요청 실패"); + throw new IllegalStateException("아임포트 토큰 요청 실패: " + response.getBody()); } try { diff --git a/src/main/java/com/itda/moamoa/global/config/RestTemplateConfig.java b/src/main/java/com/itda/moamoa/global/config/RestTemplateConfig.java new file mode 100644 index 0000000..0e33f09 --- /dev/null +++ b/src/main/java/com/itda/moamoa/global/config/RestTemplateConfig.java @@ -0,0 +1,14 @@ +package com.itda.moamoa.global.config; + +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.client.RestTemplate; + +@Configuration +public class RestTemplateConfig { + + @Bean + public RestTemplate restTemplate() { + return new RestTemplate(); + } +} \ No newline at end of file diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2c02a0d..181bead 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: jpa: hibernate: - ddl-auto: create + ddl-auto: update properties: hibernate: format_sql: true From f28a379c4ddb52909894d0f80221fa426440127e Mon Sep 17 00:00:00 2001 From: skdudnayoung Date: Thu, 15 May 2025 15:52:40 +0900 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20/verify=20=EB=A1=9C=EA=B7=B8=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C=20=EB=B0=8F=20yml=20=EB=B3=B5=EA=B5=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../itda/moamoa/domain/payment/service/PortOneApiClient.java | 3 --- src/main/resources/application.yml | 2 +- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java index 5eaa9e3..d9d5a15 100644 --- a/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java +++ b/src/main/java/com/itda/moamoa/domain/payment/service/PortOneApiClient.java @@ -24,8 +24,6 @@ private String getAccessToken() { String apiKey = portOneProperties.getApiKey(); String apiSecret = portOneProperties.getApiSecret(); - System.out.println("Using API Key: " + apiKey); - System.out.println("Using API Secret: " + apiSecret); if (apiKey == null || apiKey.isEmpty() || apiSecret == null || apiSecret.isEmpty()) { throw new IllegalStateException("포트원 API 키 또는 시크릿 키가 설정되지 않았습니다."); @@ -33,7 +31,6 @@ private String getAccessToken() { // 직접 JSON 문자열 생성 String jsonBody = String.format("{\"imp_key\":\"%s\",\"imp_secret\":\"%s\"}", apiKey, apiSecret); - System.out.println("Request body: " + jsonBody); HttpHeaders headers = new HttpHeaders(); headers.setContentType(MediaType.APPLICATION_JSON); diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 181bead..2c02a0d 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -10,7 +10,7 @@ spring: jpa: hibernate: - ddl-auto: update + ddl-auto: create properties: hibernate: format_sql: true