diff --git a/build.gradle b/build.gradle index 379368e..262c23f 100644 --- a/build.gradle +++ b/build.gradle @@ -20,6 +20,7 @@ configurations { } repositories { + maven { url 'https://jitpack.io' } mavenCentral() } @@ -59,6 +60,9 @@ dependencies { // google maps implementation group: 'com.google.maps', name: 'google-maps-services', version: '2.2.0' + // payment + implementation 'com.github.iamport:iamport-rest-client-java:0.2.23' + compileOnly 'org.projectlombok:lombok' runtimeOnly 'com.mysql:mysql-connector-j' diff --git a/src/main/generated/com/gongspot/project/domain/order/entity/QOrder.java b/src/main/generated/com/gongspot/project/domain/order/entity/QOrder.java new file mode 100644 index 0000000..398061d --- /dev/null +++ b/src/main/generated/com/gongspot/project/domain/order/entity/QOrder.java @@ -0,0 +1,78 @@ +package com.gongspot.project.domain.order.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; +import com.querydsl.core.types.dsl.PathInits; + + +/** + * QOrder is a Querydsl query type for Order + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QOrder extends EntityPathBase { + + private static final long serialVersionUID = 2108355418L; + + private static final PathInits INITS = PathInits.DIRECT2; + + public static final QOrder order = new QOrder("order1"); + + public final com.gongspot.project.common.entity.QBaseEntity _super = new com.gongspot.project.common.entity.QBaseEntity(this); + + //inherited + public final DateTimePath createdAt = _super.createdAt; + + //inherited + public final DateTimePath deletedAt = _super.deletedAt; + + public final StringPath merchantUid = createString("merchantUid"); + + public final StringPath ordererName = createString("ordererName"); + + public final NumberPath orderId = createNumber("orderId", Long.class); + + public final EnumPath paymentStatus = createEnum("paymentStatus", PaymentStatus.class); + + public final EnumPath payMethod = createEnum("payMethod", PayMethod.class); + + public final com.gongspot.project.domain.point.entity.QPoint point; + + public final QProduct product; + + public final NumberPath totalPrice = createNumber("totalPrice", java.math.BigDecimal.class); + + //inherited + public final DateTimePath updatedAt = _super.updatedAt; + + public final com.gongspot.project.domain.user.entity.QUser user; + + public QOrder(String variable) { + this(Order.class, forVariable(variable), INITS); + } + + public QOrder(Path path) { + this(path.getType(), path.getMetadata(), PathInits.getFor(path.getMetadata(), INITS)); + } + + public QOrder(PathMetadata metadata) { + this(metadata, PathInits.getFor(metadata, INITS)); + } + + public QOrder(PathMetadata metadata, PathInits inits) { + this(Order.class, metadata, inits); + } + + public QOrder(Class type, PathMetadata metadata, PathInits inits) { + super(type, metadata, inits); + this.point = inits.isInitialized("point") ? new com.gongspot.project.domain.point.entity.QPoint(forProperty("point"), inits.get("point")) : null; + this.product = inits.isInitialized("product") ? new QProduct(forProperty("product")) : null; + this.user = inits.isInitialized("user") ? new com.gongspot.project.domain.user.entity.QUser(forProperty("user")) : null; + } + +} + diff --git a/src/main/generated/com/gongspot/project/domain/order/entity/QProduct.java b/src/main/generated/com/gongspot/project/domain/order/entity/QProduct.java new file mode 100644 index 0000000..4ff5089 --- /dev/null +++ b/src/main/generated/com/gongspot/project/domain/order/entity/QProduct.java @@ -0,0 +1,43 @@ +package com.gongspot.project.domain.order.entity; + +import static com.querydsl.core.types.PathMetadataFactory.*; + +import com.querydsl.core.types.dsl.*; + +import com.querydsl.core.types.PathMetadata; +import javax.annotation.processing.Generated; +import com.querydsl.core.types.Path; + + +/** + * QProduct is a Querydsl query type for Product + */ +@Generated("com.querydsl.codegen.DefaultEntitySerializer") +public class QProduct extends EntityPathBase { + + private static final long serialVersionUID = -197368325L; + + public static final QProduct product = new QProduct("product"); + + public final NumberPath id = createNumber("id", Long.class); + + public final StringPath name = createString("name"); + + public final NumberPath point = createNumber("point", Integer.class); + + public final NumberPath price = createNumber("price", java.math.BigDecimal.class); + + public QProduct(String variable) { + super(Product.class, forVariable(variable)); + } + + public QProduct(Path path) { + super(path.getType(), path.getMetadata()); + } + + public QProduct(PathMetadata metadata) { + super(Product.class, metadata); + } + +} + diff --git a/src/main/java/com/gongspot/project/common/code/status/ErrorStatus.java b/src/main/java/com/gongspot/project/common/code/status/ErrorStatus.java index 0b7652f..f263111 100644 --- a/src/main/java/com/gongspot/project/common/code/status/ErrorStatus.java +++ b/src/main/java/com/gongspot/project/common/code/status/ErrorStatus.java @@ -73,7 +73,13 @@ public enum ErrorStatus implements BaseErrorCode { FILE_SIZE_EXCEEDED(HttpStatus.BAD_REQUEST, "FILE4003", "파일 크기가 제한을 초과했습니다."), //New Place - NEW_PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "NEWPLACE4001", "새로운 공간을 찾을 수 없습니다."); + NEW_PLACE_NOT_FOUND(HttpStatus.NOT_FOUND, "NEWPLACE4001", "새로운 공간을 찾을 수 없습니다."), + + // 상품 + PRODUCT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT4001", "해당 상품을 찾을 수 없습니다."), + PAYMENT_AMOUNT_MISMATCH(HttpStatus.BAD_REQUEST, "PRODUCT4002", "결제 금액이 일치하지 않습니다."), + PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "PRODUCT4003", "결제 내역이 없습니다."), + PAYMENT_VERIFICATION_FAILED(HttpStatus.BAD_REQUEST, "PRODUCT4004", "결제 인증이 실패했습니다."), ; // 위에 적을 것 ! private final HttpStatus httpStatus; diff --git a/src/main/java/com/gongspot/project/domain/order/controller/OrderController.java b/src/main/java/com/gongspot/project/domain/order/controller/OrderController.java new file mode 100644 index 0000000..e743429 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/controller/OrderController.java @@ -0,0 +1,27 @@ +package com.gongspot.project.domain.order.controller; + +import com.gongspot.project.domain.order.dto.OrderRequestDTO; +import com.gongspot.project.domain.order.entity.Order; +import com.gongspot.project.domain.order.service.OrderService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.web.bind.annotation.*; + +@RestController +@RequestMapping("order") +@RequiredArgsConstructor +@Slf4j +public class OrderController { + + private final OrderService orderService; + + @PostMapping("/payment/validate") + public String validatePayment(@RequestBody OrderRequestDTO request) { + + Order validatedOrder = orderService.processPaymentDone(request); + + log.info("결제 검증 및 주문 처리 성공. 주문 번호: {}", validatedOrder.getMerchantUid()); + + return "결제 성공 및 서버 검증 완료"; + } +} \ No newline at end of file diff --git a/src/main/java/com/gongspot/project/domain/order/dto/OrderRequestDTO.java b/src/main/java/com/gongspot/project/domain/order/dto/OrderRequestDTO.java new file mode 100644 index 0000000..d933061 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/dto/OrderRequestDTO.java @@ -0,0 +1,22 @@ +package com.gongspot.project.domain.order.dto; + +import com.gongspot.project.domain.order.entity.PayMethod; +import lombok.*; + +import java.math.BigDecimal; + +@Getter +@Builder +@NoArgsConstructor +@AllArgsConstructor +@ToString +public class OrderRequestDTO { + + private String impUid; + private Long userId; + private Long productId; + private PayMethod payMethod; + private BigDecimal totalPrice; + private String merchantUid; + +} diff --git a/src/main/java/com/gongspot/project/domain/order/entity/Order.java b/src/main/java/com/gongspot/project/domain/order/entity/Order.java new file mode 100644 index 0000000..f20835f --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/entity/Order.java @@ -0,0 +1,86 @@ +package com.gongspot.project.domain.order.entity; + +import com.gongspot.project.common.entity.BaseEntity; +import com.gongspot.project.domain.point.entity.Point; +import com.gongspot.project.domain.user.entity.User; +import jakarta.persistence.*; +import lombok.*; +import java.math.BigDecimal; + +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Entity +@Table(name = "order") +public class Order extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "order_id") + private Long orderId; // PK + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "order_name") + private String ordererName; // 주문자 이름 + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "product_id", nullable = false) + private Product product; + + @Enumerated(EnumType.STRING) + @Column(name = "pay_method") + private PayMethod payMethod; // 결제 방식 + + @Column(length = 100, name = "merchant_uid") + private String merchantUid; // 주문번호 + + @Column(name = "total_price", nullable = false) + private BigDecimal totalPrice; // 결제 금액 + + @Enumerated(EnumType.STRING) + @Column(name = "payment_status") + private PaymentStatus paymentStatus; + + @OneToOne(cascade = CascadeType.ALL) + @JoinColumn(name = "point_id") + private Point point; + + @Builder(access = AccessLevel.PRIVATE) + private Order(User user, Product product, String merchantUid, PayMethod payMethod, String ordererName, BigDecimal totalPrice) { + this.user = user; + this.product = product; + this.merchantUid = merchantUid; + this.payMethod = payMethod; + this.ordererName = ordererName; + this.totalPrice = totalPrice; + this.paymentStatus = PaymentStatus.PENDING; // 초기 상태는 PENDING + } + + public static Order createOrder(User user, Product product) { + return Order.builder() + .user(user) + .product(product) + .ordererName(user.getNickname()) + .totalPrice(product.getPrice()) + .build(); + } + + public void markAsPaid(String merchantUid, PayMethod payMethod) { + this.merchantUid = merchantUid; + this.payMethod = payMethod; + this.paymentStatus = PaymentStatus.SUCCESS; + } + + + public void markAsCanceled() { + this.paymentStatus = PaymentStatus.CANCELLED; + } + + + public void addPoint(Point point) { + this.point = point; + } +} diff --git a/src/main/java/com/gongspot/project/domain/order/entity/PayMethod.java b/src/main/java/com/gongspot/project/domain/order/entity/PayMethod.java new file mode 100644 index 0000000..e9df4bb --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/entity/PayMethod.java @@ -0,0 +1,11 @@ +package com.gongspot.project.domain.order.entity; + +public enum PayMethod { + CARD, // 신용/체크카드 + BANK, // 계좌이체 + PHONE, // 휴대폰 결제 + VIRTUAL, // 가상계좌 + KAKAO, // 카카오페이 + NAVER, // 네이버페이 + PAYCO // 페이코 +} diff --git a/src/main/java/com/gongspot/project/domain/order/entity/PaymentStatus.java b/src/main/java/com/gongspot/project/domain/order/entity/PaymentStatus.java new file mode 100644 index 0000000..cb131e7 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/entity/PaymentStatus.java @@ -0,0 +1,9 @@ +package com.gongspot.project.domain.order.entity; + +public enum PaymentStatus { + PENDING, + SUCCESS, + FAILED, + CANCELLED, + READY +} diff --git a/src/main/java/com/gongspot/project/domain/order/entity/Product.java b/src/main/java/com/gongspot/project/domain/order/entity/Product.java new file mode 100644 index 0000000..26b8688 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/entity/Product.java @@ -0,0 +1,36 @@ +package com.gongspot.project.domain.order.entity; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.math.BigDecimal; + +@Getter +@Setter +@NoArgsConstructor +@Entity +@Table(name = "product") +public class Product { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "product_id") + private Long id; + + @Column(name = "name", nullable = false) + private String name; + + @Column(name = "price", nullable = false) + private BigDecimal price; + + @Column(name = "point", nullable = false) + private Integer point; + + public Product(String name, BigDecimal price, Integer point) { + this.name = name; + this.price = price; + this.point = point; + } +} diff --git a/src/main/java/com/gongspot/project/domain/order/repository/OrderRepository.java b/src/main/java/com/gongspot/project/domain/order/repository/OrderRepository.java new file mode 100644 index 0000000..bf64d81 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/repository/OrderRepository.java @@ -0,0 +1,10 @@ +package com.gongspot.project.domain.order.repository; + +import com.gongspot.project.domain.order.entity.Order; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface OrderRepository extends JpaRepository { + Order findByMerchantUid(String merchantUid); +} diff --git a/src/main/java/com/gongspot/project/domain/order/repository/ProductRepository.java b/src/main/java/com/gongspot/project/domain/order/repository/ProductRepository.java new file mode 100644 index 0000000..5c90272 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/repository/ProductRepository.java @@ -0,0 +1,10 @@ +package com.gongspot.project.domain.order.repository; + +import com.gongspot.project.domain.order.entity.Product; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +@Repository +public interface ProductRepository extends JpaRepository { + Product findByName(String name); +} diff --git a/src/main/java/com/gongspot/project/domain/order/service/OrderService.java b/src/main/java/com/gongspot/project/domain/order/service/OrderService.java new file mode 100644 index 0000000..957b698 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/service/OrderService.java @@ -0,0 +1,79 @@ +package com.gongspot.project.domain.order.service; + +import com.gongspot.project.common.code.status.ErrorStatus; +import com.gongspot.project.common.exception.GeneralException; +import com.gongspot.project.domain.order.dto.OrderRequestDTO; +import com.gongspot.project.domain.order.entity.Order; +import com.gongspot.project.domain.order.entity.PayMethod; +import com.gongspot.project.domain.order.repository.OrderRepository; +import com.gongspot.project.domain.order.entity.Product; +import com.gongspot.project.domain.order.repository.ProductRepository; +import com.gongspot.project.domain.point.entity.Point; +import com.gongspot.project.domain.point.repository.PointRepository; +import com.gongspot.project.domain.user.entity.User; +import com.gongspot.project.domain.user.repository.UserRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDate; + +/** + * 클라이언트의 결제 요청을 받아 서버 측에서 결제를 검증하고, + * 주문 및 관련 엔티티의 상태를 업데이트합니다. + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class OrderService { + + private final OrderRepository orderRepository; + private final UserRepository userRepository; + private final ProductRepository productRepository; + private final PortoneApiService portoneApiService; + private final PointRepository pointRepository; + + /** + * 클라이언트로부터 전달받은 결제 정보를 바탕으로 결제 완료를 처리합니다. + * @param request 결제 정보를 담고 있는 DTO + * @return 결제가 완료된 주문 엔티티 + */ + @Transactional + public Order processPaymentDone(OrderRequestDTO request) { + log.info("Processing payment for merchantUid: {}", request.getMerchantUid()); + + User user = userRepository.findById(request.getUserId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.MEMBER_NOT_FOUND)); + + Product product = productRepository.findById(request.getProductId()) + .orElseThrow(() -> new GeneralException(ErrorStatus.PRODUCT_NOT_FOUND)); + + // PayMethod가 null일 경우 CARD로 기본값 설정 + PayMethod payMethod = request.getPayMethod() != null ? request.getPayMethod() : PayMethod.CARD; + + Order order = Order.createOrder(user, product); + + portoneApiService.verifyPayment(request.getImpUid(), request.getTotalPrice()); + + // 결제 성공 처리: Order 엔티티의 상태 업데이트 + order.markAsPaid(request.getMerchantUid(), payMethod); + + Point point = Point.builder() + .user(user) + .place(null) + .updatedPoint(product.getPoint()) + .date(LocalDate.now()) + .content(product.getName() + " 포인트 구매 확인") + .build(); + + pointRepository.save(point); + order.addPoint(point); + + // 주문 엔티티 저장 + Order savedOrder = orderRepository.save(order); + log.info("Order saved successfully with ID: {}", savedOrder.getOrderId()); + + return savedOrder; + } +} diff --git a/src/main/java/com/gongspot/project/domain/order/service/PortoneApiService.java b/src/main/java/com/gongspot/project/domain/order/service/PortoneApiService.java new file mode 100644 index 0000000..663dd18 --- /dev/null +++ b/src/main/java/com/gongspot/project/domain/order/service/PortoneApiService.java @@ -0,0 +1,64 @@ +package com.gongspot.project.domain.order.service; + +import com.gongspot.project.common.code.status.ErrorStatus; +import com.gongspot.project.common.exception.GeneralException; +import com.siot.IamportRestClient.IamportClient; +import com.siot.IamportRestClient.exception.IamportResponseException; +import com.siot.IamportRestClient.response.Payment; +import jakarta.annotation.PostConstruct; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; + +import java.io.IOException; +import java.math.BigDecimal; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PortoneApiService { + + private IamportClient iamportClient; + + @Value("${imp.api-key}") + private String apiKey; + + @Value("${imp.api.secretkey}") + private String secretKey; + + @PostConstruct + public void init() { + this.iamportClient = new IamportClient(apiKey, secretKey); + } + + /** + * imp_uid를 사용하여 포트원 서버에서 결제 정보를 조회 & 금액 검증 + * + * @param impUid 클라이언트로부터 전달받은 결제 고유번호 + * @param clientAmount 클라이언트가 요청한 결제 금액 + */ + public void verifyPayment(String impUid, BigDecimal clientAmount) { + try { + Payment payment = iamportClient.paymentByImpUid(impUid).getResponse(); + + if (payment == null) { + log.error("Payment not found for impUid: {}", impUid); + throw new GeneralException(ErrorStatus.PAYMENT_NOT_FOUND); + } + + // 서버 DB에 저장된 금액(clientAmount)과 아임포트 서버의 실제 결제 금액(payment.getAmount()) 비교 + BigDecimal portoneAmount = payment.getAmount(); + if (portoneAmount.compareTo(clientAmount) != 0) { + log.error("Payment amount mismatch. Portone amount: {}, Client amount: {}", portoneAmount, clientAmount); + throw new GeneralException(ErrorStatus.PAYMENT_AMOUNT_MISMATCH); + } + + log.info("Payment verification successful for impUid: {}", impUid); + + } catch (IamportResponseException | IOException e) { + log.error("Failed to verify payment with impUid: {}", impUid, e); + throw new GeneralException(ErrorStatus.PAYMENT_VERIFICATION_FAILED); + } + } +} diff --git a/src/main/java/com/gongspot/project/domain/point/entity/Point.java b/src/main/java/com/gongspot/project/domain/point/entity/Point.java index 87510a7..9158112 100644 --- a/src/main/java/com/gongspot/project/domain/point/entity/Point.java +++ b/src/main/java/com/gongspot/project/domain/point/entity/Point.java @@ -4,13 +4,14 @@ import com.gongspot.project.domain.place.entity.Place; import com.gongspot.project.domain.user.entity.User; import jakarta.persistence.*; -import lombok.Getter; -import lombok.Setter; +import lombok.*; import java.time.LocalDate; @Getter @Setter +@NoArgsConstructor +@AllArgsConstructor @Entity @Table(name = "Points", uniqueConstraints = @UniqueConstraint( @@ -20,6 +21,7 @@ indexes = @Index(name = "idx_points_user_place_date_content", columnList = "user_id, place_id, date, content") ) +@Builder public class Point extends BaseEntity { // 포인트 내역 관리하는 엔티티 diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties index c58ebab..5f5187e 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -65,3 +65,8 @@ cloud.aws.s3.profile-bucket=gongspot-profile-photos # multipart ?? ??? ?? spring.servlet.multipart.max-file-size=10MB spring.servlet.multipart.max-request-size=10MB + +# iamport payment +imp.api-key = ${IMP_API_KEY} +imp.api.secretkey = ${IMP_SECRET_KEY} +imp.imp_uid=${IMP_UID} \ No newline at end of file diff --git a/src/main/resources/static/favicon.svg b/src/main/resources/static/favicon.svg new file mode 100644 index 0000000..42f9200 --- /dev/null +++ b/src/main/resources/static/favicon.svg @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + + + +