diff --git a/src/main/java/com/sparta/tdd/domain/payment/repository/PaymentRepository.java b/src/main/java/com/sparta/tdd/domain/payment/repository/PaymentRepository.java index 258bbba9..c5c1be9f 100644 --- a/src/main/java/com/sparta/tdd/domain/payment/repository/PaymentRepository.java +++ b/src/main/java/com/sparta/tdd/domain/payment/repository/PaymentRepository.java @@ -2,7 +2,9 @@ import com.sparta.tdd.domain.payment.entity.Payment; import java.time.LocalDateTime; +import java.util.Optional; import java.util.UUID; +import org.springframework.data.jpa.repository.EntityGraph; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -18,4 +20,8 @@ void bulkSoftDeleteByUserId( @Param("deletedAt") LocalDateTime deletedAt, @Param("deletedBy") Long deletedBy ); + + @SuppressWarnings("NullableProblems") + @EntityGraph(attributePaths = {"order", "user"}) + Optional findById(UUID id); } diff --git a/src/main/java/com/sparta/tdd/domain/payment/service/PaymentResultProcessService.java b/src/main/java/com/sparta/tdd/domain/payment/service/PaymentResultProcessService.java index 580f60a6..644fb5cd 100644 --- a/src/main/java/com/sparta/tdd/domain/payment/service/PaymentResultProcessService.java +++ b/src/main/java/com/sparta/tdd/domain/payment/service/PaymentResultProcessService.java @@ -6,6 +6,7 @@ import com.sparta.tdd.domain.payment.enums.PaymentStatus; import com.sparta.tdd.global.exception.BusinessException; import com.sparta.tdd.global.exception.ErrorCode; +import java.time.LocalDateTime; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; @@ -38,17 +39,16 @@ private void processApproved(Payment payment, Order order) { // 주문 상태 변경 (PENDING -> DELIVERED) order.nextStatus(); - - // TODO: 포인트 적립 } private void processCancelled(Payment payment, Order order) { + if (LocalDateTime.now().minusMinutes(5).isAfter(payment.getCreatedAt())) { + throw new BusinessException(ErrorCode.PAYMENT_CANCEL_TIME_EXPIRED); + } // 주문 상태를 PENDING으로 복구 order.changeOrderStatus(OrderStatus.PENDING); // 환불처리는 진행 된 것으로 가정하겠습니다. - - // TODO 재고 복구, 적립된 포인트 회수 등 } private void processFailed(Payment payment, Order order) { diff --git a/src/main/java/com/sparta/tdd/domain/point/dto/PointRequest.java b/src/main/java/com/sparta/tdd/domain/point/dto/PointRequest.java new file mode 100644 index 00000000..a027df38 --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/dto/PointRequest.java @@ -0,0 +1,38 @@ +package com.sparta.tdd.domain.point.dto; + +import com.sparta.tdd.domain.point.enums.PointType; +import com.sparta.tdd.domain.user.entity.User; +import java.util.UUID; +import lombok.Builder; + +@Builder +public record PointRequest( + User user, + UUID referenceId, + PointType type, + Long amount, + String description +) { + + public static PointRequest forPayment(User user, UUID orderId, Long amount, + String description) { + return PointRequest.builder() + .user(user) + .referenceId(orderId) + .amount(amount) + .type(PointType.PAYMENT_EARNED) + .description(description) + .build(); + } + + public static PointRequest forReview(User user, UUID reviewId, Long amount, + String description) { + return PointRequest.builder() + .user(user) + .referenceId(reviewId) + .amount(amount) + .type(PointType.REVIEW_EARNED) + .description(description) + .build(); + } +} \ No newline at end of file diff --git a/src/main/java/com/sparta/tdd/domain/point/entity/PointHistory.java b/src/main/java/com/sparta/tdd/domain/point/entity/PointHistory.java new file mode 100644 index 00000000..d2e85a15 --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/entity/PointHistory.java @@ -0,0 +1,77 @@ +package com.sparta.tdd.domain.point.entity; + +import com.sparta.tdd.domain.point.dto.PointRequest; +import com.sparta.tdd.domain.point.enums.PointType; +import com.sparta.tdd.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "p_point_history") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PointHistory extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "point_history_id") + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "point_wallet_id") + private PointWallet wallet; + + @Column(name = "reference_id") + private UUID referenceId; + + @Column(name = "amount") + private Long amount; + + @Column(name = "type") + @Enumerated(EnumType.STRING) + private PointType type; + + @Column(name = "description") + private String description; + + @Column(name = "expire_at") + private LocalDateTime expireAt; + + @Builder + private PointHistory(PointWallet wallet, UUID referenceId, Long amount, + PointType type, String description, LocalDateTime expireAt) { + this.wallet = wallet; + this.referenceId = referenceId; + this.amount = amount; + this.type = type; + this.description = description; + this.expireAt = expireAt; + } + + public static PointHistory create(PointWallet wallet, PointRequest request, + LocalDateTime expireAt) { + return PointHistory.builder() + .wallet(wallet) + .referenceId(request.referenceId()) + .amount(request.amount()) + .type(request.type()) + .description(request.description()) + .expireAt(expireAt) + .build(); + } +} diff --git a/src/main/java/com/sparta/tdd/domain/point/entity/PointWallet.java b/src/main/java/com/sparta/tdd/domain/point/entity/PointWallet.java new file mode 100644 index 00000000..7b0e026a --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/entity/PointWallet.java @@ -0,0 +1,57 @@ +package com.sparta.tdd.domain.point.entity; + +import com.sparta.tdd.domain.user.entity.User; +import com.sparta.tdd.global.model.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.OneToOne; +import jakarta.persistence.Table; +import java.util.UUID; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Entity +@Getter +@Table(name = "p_point_wallet") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class PointWallet extends BaseEntity { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + @Column(name = "point_wallet_id", nullable = false, updatable = false) + private UUID id; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + @Column(name = "balance") + private Long balance; + + @Builder + public PointWallet(User user) { + this.user = user; + this.balance = 0L; + } + + public void addBalance(Long earnAmount) { + this.balance += earnAmount; + } + + public Long subtractBalance(Long amount) { + if (this.balance < amount) { + Long currentBalance = this.balance; + this.balance = 0L; + return amount - currentBalance; + } + this.balance -= amount; + return amount; + } +} diff --git a/src/main/java/com/sparta/tdd/domain/point/enums/PointType.java b/src/main/java/com/sparta/tdd/domain/point/enums/PointType.java new file mode 100644 index 00000000..d756e06c --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/enums/PointType.java @@ -0,0 +1,16 @@ +package com.sparta.tdd.domain.point.enums; + +import lombok.Getter; +import lombok.RequiredArgsConstructor; + +@Getter +@RequiredArgsConstructor +public enum PointType { + PAYMENT_EARNED("결제 적립"), + REVIEW_EARNED("리뷰 적립"), + PAYMENT_CANCELLED("결제 취소"), + USED("사용"), + EXPIRED("만료"); + + private final String description; +} diff --git a/src/main/java/com/sparta/tdd/domain/point/repository/PointHistoryRepository.java b/src/main/java/com/sparta/tdd/domain/point/repository/PointHistoryRepository.java new file mode 100644 index 00000000..d21ccc55 --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/repository/PointHistoryRepository.java @@ -0,0 +1,15 @@ +package com.sparta.tdd.domain.point.repository; + +import com.sparta.tdd.domain.point.entity.PointHistory; +import com.sparta.tdd.domain.point.enums.PointType; +import java.util.Optional; +import java.util.UUID; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PointHistoryRepository extends JpaRepository { + + boolean existsByReferenceIdAndTypeAndDeletedAtIsNull(UUID referenceId, PointType type); + + Optional findByReferenceIdAndTypeAndDeletedAtIsNull(UUID paymentId, + PointType pointType); +} diff --git a/src/main/java/com/sparta/tdd/domain/point/repository/PointWalletRepository.java b/src/main/java/com/sparta/tdd/domain/point/repository/PointWalletRepository.java new file mode 100644 index 00000000..b330eceb --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/repository/PointWalletRepository.java @@ -0,0 +1,10 @@ +package com.sparta.tdd.domain.point.repository; + +import com.sparta.tdd.domain.point.entity.PointWallet; +import java.util.Optional; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface PointWalletRepository extends JpaRepository { + + Optional findByUserId(Long id); +} diff --git a/src/main/java/com/sparta/tdd/domain/point/service/PointService.java b/src/main/java/com/sparta/tdd/domain/point/service/PointService.java new file mode 100644 index 00000000..5737a95c --- /dev/null +++ b/src/main/java/com/sparta/tdd/domain/point/service/PointService.java @@ -0,0 +1,85 @@ +package com.sparta.tdd.domain.point.service; + +import com.sparta.tdd.domain.point.dto.PointRequest; +import com.sparta.tdd.domain.point.entity.PointHistory; +import com.sparta.tdd.domain.point.entity.PointWallet; +import com.sparta.tdd.domain.point.enums.PointType; +import com.sparta.tdd.domain.point.repository.PointHistoryRepository; +import com.sparta.tdd.domain.point.repository.PointWalletRepository; +import com.sparta.tdd.domain.user.entity.User; +import com.sparta.tdd.global.exception.BusinessException; +import com.sparta.tdd.global.exception.ErrorCode; +import java.time.LocalDateTime; +import java.util.UUID; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Slf4j +@Service +@RequiredArgsConstructor +public class PointService { + + private final PointWalletRepository walletRepository; + private final PointHistoryRepository historyRepository; + + @Transactional + public void earnPoints(PointRequest request) { + if (isDuplicateEarn(request.referenceId(), request.type())) { + return; + } + + PointWallet wallet = getOrCreateWallet(request.user()); + wallet.addBalance(request.amount()); + + PointHistory pointHistory = PointHistory.create(wallet, request, + LocalDateTime.now().plusYears(1)); + + historyRepository.save(pointHistory); + } + + @Transactional + public void losePoints(User user, UUID paymentId) { + PointWallet wallet = findWalletById(user.getId()); + PointHistory pointHistory = findPointHistory(paymentId); + Long usedAmount = wallet.subtractBalance(pointHistory.getAmount()); + + PointHistory cancelledHistory = PointHistory.builder() + .wallet(wallet) + .referenceId(paymentId) + .amount(usedAmount) + .type(PointType.PAYMENT_CANCELLED) + .description("결제 취소 (결제번호: " + paymentId + ")") + .expireAt(LocalDateTime.now()) + .build(); + + historyRepository.save(cancelledHistory); + } + + private PointWallet findWalletById(Long userId) { + return walletRepository.findByUserId(userId) + .orElseThrow(() -> new BusinessException(ErrorCode.USER_NOT_FOUND)); + } + + private PointHistory findPointHistory(UUID paymentId) { + return historyRepository.findByReferenceIdAndTypeAndDeletedAtIsNull(paymentId, + PointType.PAYMENT_EARNED) + .orElseThrow(() -> new BusinessException(ErrorCode.PAYMENT_NOT_FOUND)); + } + + + private PointWallet getOrCreateWallet(User user) { + return walletRepository.findByUserId(user.getId()) + .orElseGet(() -> walletRepository.save(PointWallet.builder() + .user(user) + .build())); + } + + private boolean isDuplicateEarn(UUID referenceId, PointType type) { + if (referenceId == null || type == null) { + return false; + } + return historyRepository.existsByReferenceIdAndTypeAndDeletedAtIsNull(referenceId, type); + } +} diff --git a/src/main/java/com/sparta/tdd/domain/review/repository/ReviewRepository.java b/src/main/java/com/sparta/tdd/domain/review/repository/ReviewRepository.java index d0d3e470..23766d37 100644 --- a/src/main/java/com/sparta/tdd/domain/review/repository/ReviewRepository.java +++ b/src/main/java/com/sparta/tdd/domain/review/repository/ReviewRepository.java @@ -56,7 +56,9 @@ void bulkSoftDeleteByUserId( @Query("SELECT r.id FROM Review r WHERE r.store.id IN :storeIds AND r.deletedAt IS NULL") List findReviewIdsByStoreIds(@Param("storeIds") List storeIds); + @Query("SELECT COUNT(r) > 0 FROM Review r WHERE r.order.id = :orderId AND r.deletedAt IS NULL") + boolean existsByOrderId(@Param("orderId") UUID orderId); + @Query("SELECT COUNT(r) FROM Review r WHERE r.store.id = :storeId AND r.deletedAt IS NULL") Long countByStoreIdAndNotDeleted(@Param("storeId") UUID storeId); - } \ No newline at end of file diff --git a/src/main/java/com/sparta/tdd/domain/review/service/ReviewService.java b/src/main/java/com/sparta/tdd/domain/review/service/ReviewService.java index a4be491f..277752a3 100644 --- a/src/main/java/com/sparta/tdd/domain/review/service/ReviewService.java +++ b/src/main/java/com/sparta/tdd/domain/review/service/ReviewService.java @@ -51,6 +51,8 @@ public ReviewResponseDto createReview(Long userId, UUID orderId, ReviewRequestDt Store store = findStoreById(request.storeId()); Order order = findOrderById(orderId); + existsByOrderId(orderId); + Review review = request.toEntity(user, store, order); Review savedReview = reviewRepository.save(review); @@ -165,4 +167,10 @@ private Order findOrderById(UUID orderId) { return orderRepository.findById(orderId) .orElseThrow(() -> new BusinessException(ErrorCode.ORDER_NOT_FOUND)); } + + private void existsByOrderId(UUID orderId) { + if (reviewRepository.existsByOrderId(orderId)) { + throw new BusinessException(ErrorCode.DUPLICATE_REVIEW); + } + } } \ No newline at end of file diff --git a/src/main/java/com/sparta/tdd/global/aop/PointAspect.java b/src/main/java/com/sparta/tdd/global/aop/PointAspect.java new file mode 100644 index 00000000..c633fcdb --- /dev/null +++ b/src/main/java/com/sparta/tdd/global/aop/PointAspect.java @@ -0,0 +1,95 @@ +package com.sparta.tdd.global.aop; + +import com.sparta.tdd.domain.payment.entity.Payment; +import com.sparta.tdd.domain.payment.enums.PaymentStatus; +import com.sparta.tdd.domain.point.dto.PointRequest; +import com.sparta.tdd.domain.point.service.PointService; +import com.sparta.tdd.domain.review.dto.response.ReviewResponseDto; +import com.sparta.tdd.domain.review.entity.Review; +import com.sparta.tdd.domain.review.repository.ReviewRepository; +import com.sparta.tdd.domain.user.entity.User; +import com.sparta.tdd.global.exception.BusinessException; +import com.sparta.tdd.global.exception.ErrorCode; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.aspectj.lang.annotation.AfterReturning; +import org.aspectj.lang.annotation.Aspect; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Aspect +@Component +@Slf4j +@RequiredArgsConstructor +public class PointAspect { + + private final PointService pointService; + private final ReviewRepository reviewRepository; + + private static final double ORDER_POINT_RATE = 0.01; + private static final long REVIEW_POINT_AMOUNT = 500L; + + @AfterReturning( + pointcut = "execution(* com.sparta.tdd.domain.payment.service.PaymentResultProcessService.processPaymentResult(..)) && args(payment)", + argNames = "payment" + ) + public void processPointsAfterPaymentResult(Payment payment) { + try { + + User user = payment.getUser(); + + if (payment.getStatus() == PaymentStatus.COMPLETED) { + + log.info("결제 완료 payment_Id={}", payment.getId()); + + Long totalAmount = payment.getAmount(); + Long earnAmount = (long) Math.floor(totalAmount * ORDER_POINT_RATE); + + pointService.earnPoints(PointRequest.forPayment( + user, + payment.getId(), + earnAmount, + "결제 완료 적립 (결제번호 번호: " + payment.getId() + ")" + )); + } + + if (payment.getStatus() == PaymentStatus.CANCELLED) { + log.info("취소 완료 payment_Id={}", payment.getId()); + + pointService.losePoints(user, payment.getId()); + } + + } catch (BusinessException e) { + throw e; + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ErrorCode.POINT_PROCESSING_FAILED); + } + } + + @AfterReturning( + pointcut = "execution(* com.sparta.tdd.domain.review.service.ReviewService.createReview(..))", + returning = "result" + ) + public void earnPointsAfterReviewCreation(ReviewResponseDto result) { + try { + log.info("리뷰 작성 완료", result.reviewId()); + + Review review = reviewRepository.findById(result.reviewId()) + .orElseThrow(() -> new BusinessException(ErrorCode.REVIEW_NOT_FOUND)); + + User user = review.getUser(); + Long earnAmount = REVIEW_POINT_AMOUNT; + + pointService.earnPoints(PointRequest.forReview( + user, + review.getId(), + earnAmount, + "리뷰 적립 (리뷰 번호: " + review.getId() + ")" + )); + } catch (Exception e) { + log.error(e.getMessage()); + throw new BusinessException(ErrorCode.POINT_PROCESSING_FAILED); + } + } +} diff --git a/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java b/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java index 14af4a79..b9785d2f 100644 --- a/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java +++ b/src/main/java/com/sparta/tdd/global/exception/ErrorCode.java @@ -53,6 +53,7 @@ public enum ErrorCode { REVIEW_REPLY_ALREADY_EXISTS(HttpStatus.CONFLICT, "이미 답글이 존재합니다."), REVIEW_REPLY_PERMISSION_DENIED(HttpStatus.FORBIDDEN, "해당 가게의 소유자만 답글을 작성할 수 있습니다."), REVIEW_REPLY_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 답글입니다."), + DUPLICATE_REVIEW(HttpStatus.CONFLICT, "이미 해당 주문에 대한 리뷰가 존재합니다."), // PAYMENT 도메인 관련 PAYMENT_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 결제 내역입니다."), @@ -60,6 +61,8 @@ public enum ErrorCode { GET_STORE_PAYMENT_DENIED(HttpStatus.FORBIDDEN, "본인의 상점의 결제 내역만 조회할 수 있습니다."), PAYMENT_ALREADY_EXIST(HttpStatus.BAD_REQUEST, "주문에 대한 결제건이 이미 존재합니다."), INVALID_PAYMENT_REQUEST(HttpStatus.BAD_REQUEST, "올바른 주문 요청이 아닙니다."), + POINT_PROCESSING_FAILED(HttpStatus.BAD_REQUEST, "포인트 적립 관련 오류입니다."), + PAYMENT_CANCEL_TIME_EXPIRED(HttpStatus.CONFLICT, "결제 후 5분이 지나 취소할 수 없습니다."), // AI 도메인 관련 diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 1dc3735d..dc66f987 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -99,4 +99,5 @@ ai: naver: client: id: testClientId - secret: testClientSecret \ No newline at end of file + secret: testClientSecret +