diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminConfigureGifticonTokenRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminConfigureGifticonTokenRequest.java new file mode 100644 index 0000000..bec8990 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminConfigureGifticonTokenRequest.java @@ -0,0 +1,19 @@ +package mannabom_server.manabom.application.admin.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.NoArgsConstructor; + +@Getter +@NoArgsConstructor +public class AdminConfigureGifticonTokenRequest { + + @NotBlank(message = "templateToken은 필수입니다.") + @Size(max = 512, message = "templateToken은 512자를 초과할 수 없습니다.") + private String templateToken; + + @NotBlank(message = "reason은 필수입니다.") + @Size(max = 500, message = "reason은 500자를 초과할 수 없습니다.") + private String reason; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminGifticonPaymentActionRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminGifticonPaymentActionRequest.java new file mode 100644 index 0000000..531bd38 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminGifticonPaymentActionRequest.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.application.admin.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + +public record AdminGifticonPaymentActionRequest( + @NotBlank(message = "reason은 필수입니다.") + @Size(max = 500, message = "reason은 500자를 초과할 수 없습니다.") + String reason +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdatePolicyRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdatePolicyRequest.java index 08902de..14e39a8 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdatePolicyRequest.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminUpdatePolicyRequest.java @@ -4,13 +4,15 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.math.BigDecimal; + @Getter @NoArgsConstructor public class AdminUpdatePolicyRequest { @NotBlank(message = "key는 필수입니다.") private String key; - private Integer value; + private BigDecimal value; private boolean resetToDefault; diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentPageResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentPageResponse.java new file mode 100644 index 0000000..ed5b386 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentPageResponse.java @@ -0,0 +1,12 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import java.util.List; + +public record AdminGifticonPaymentPageResponse( + List contents, + long totalCount, + int totalPages, + int page, + int size +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentResponse.java new file mode 100644 index 0000000..dc1056c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonPaymentResponse.java @@ -0,0 +1,45 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; + +import java.time.Instant; +import java.util.List; + +public record AdminGifticonPaymentResponse( + Long gifticonPaymentId, + String orderId, + String maskedPaymentKey, + Long userId, + Long targetProfileId, + Long gifticonProductId, + String productName, + int amount, + GifticonPaymentStatus paymentStatus, + String paymentStatusDescription, + GifticonMessageCreationStatus messageCreationStatus, + String messageCreationStatusDescription, + int messageCreationAttemptCount, + Instant lastMessageCreationAttemptAt, + String messageCreationFailureReason, + Long messageRequestId, + MessageRequestStatus messageRequestStatus, + int refundAttemptCount, + Instant lastRefundAttemptAt, + String paymentFailureReason, + Instant approvedAt, + Instant refundedAt, + Instant createdAt, + Instant updatedAt, + List attentionReasons, + String tossPaymentStatus, + Boolean tossStatusMismatch, + String tossVerificationError +) { + public record AttentionReason( + String code, + String description + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductResponse.java new file mode 100644 index 0000000..6be0a65 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductResponse.java @@ -0,0 +1,35 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; + +import java.time.Instant; + +public record AdminGifticonProductResponse( + Long gifticonProductId, + String templateTraceId, + String templateName, + String productName, + String brandName, + String productThumbnailImageUrl, + int productPrice, + int salePrice, + boolean available, + boolean templateTokenConfigured, + Instant lastSyncedAt +) { + public static AdminGifticonProductResponse from(GifticonProduct product) { + return new AdminGifticonProductResponse( + product.getGifticonProductId(), + String.valueOf(product.getTemplateTraceId()), + product.getTemplateName(), + product.getProductName(), + product.getBrandName(), + product.getProductThumbnailImageUrl(), + product.getProductPrice(), + product.getSalePrice(), + product.isAvailable(), + product.hasTemplateToken(), + product.getLastSyncedAt() + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductSliceResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductSliceResponse.java new file mode 100644 index 0000000..2614602 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductSliceResponse.java @@ -0,0 +1,10 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import java.util.List; + +public record AdminGifticonProductSliceResponse( + List contents, + Long nextCursor, + boolean hasNext +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonSyncResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonSyncResponse.java new file mode 100644 index 0000000..78800f9 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonSyncResponse.java @@ -0,0 +1,9 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import java.time.Instant; + +public record AdminGifticonSyncResponse( + int synchronizedCount, + Instant synchronizedAt +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/enums/GifticonPaymentAttentionReason.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/enums/GifticonPaymentAttentionReason.java new file mode 100644 index 0000000..5ab6e50 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/enums/GifticonPaymentAttentionReason.java @@ -0,0 +1,21 @@ +package mannabom_server.manabom.application.admin.enums; + +public enum GifticonPaymentAttentionReason { + REFUND_FAILED("환불 요청이 실패하여 운영자 확인이 필요합니다."), + REFUND_PROCESSING_STALE("환불 처리가 제한 시간을 넘겨 멈춰 있습니다."), + REFUND_ATTEMPTS_EXHAUSTED("자동 환불 최대 시도 횟수를 소진했습니다."), + MESSAGE_CREATION_STALE("결제 후 메시지가 장시간 생성되지 않았습니다."), + MESSAGE_CREATION_FAILED_WITHOUT_REFUND("메시지 생성이 실패했지만 환불이 시작되지 않았습니다."), + PAID_WITHOUT_MESSAGE("결제는 완료됐지만 연결된 메시지 요청이 없습니다."), + TOSS_STATUS_MISMATCH("애플리케이션 결제 상태와 토스 결제 상태가 일치하지 않습니다."); + + private final String description; + + GifticonPaymentAttentionReason(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java index 3492541..90771c6 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminAuditService.java @@ -99,7 +99,12 @@ private PageRequest pageRequest(int page, int size) { } private void requireAuditReadable(AdminPrincipal admin) { - if (admin.hasAnyRole(AdminRole.SUPER_ADMIN, AdminRole.OPERATOR, AdminRole.SUPPORT)) { + if (admin.hasAnyRole( + AdminRole.SUPER_ADMIN, + AdminRole.OPERATOR, + AdminRole.SUPPORT, + AdminRole.FINANCE + )) { return; } throw new SecurityException("감사 로그를 조회할 권한이 없습니다."); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonPaymentService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonPaymentService.java new file mode 100644 index 0000000..b453431 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonPaymentService.java @@ -0,0 +1,440 @@ +package mannabom_server.manabom.application.admin.service; + +import com.fasterxml.jackson.core.JsonProcessingException; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonPaymentPageResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonPaymentResponse; +import mannabom_server.manabom.application.admin.enums.GifticonPaymentAttentionReason; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.PaymentLookup; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.PaymentResult; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; +import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; +import mannabom_server.manabom.domain.admin.enums.AdminRole; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; + +@Service +@RequiredArgsConstructor +public class AdminGifticonPaymentService { + + private static final int MAX_PAGE_SIZE = 100; + private static final Set UNAPPROVED_TOSS_STATUSES = Set.of( + "READY", + "IN_PROGRESS", + "WAITING_FOR_DEPOSIT" + ); + + private final GifticonPaymentRepository paymentRepository; + private final GifticonPaymentService paymentService; + private final GifticonPaymentGateway paymentGateway; + private final TossPaymentsProperties properties; + private final AdminAuditService adminAuditService; + private final ObjectMapper objectMapper; + + @Transactional(readOnly = true) + public AdminGifticonPaymentPageResponse getPayments( + AdminPrincipal admin, + int page, + int size, + GifticonPaymentStatus paymentStatus, + GifticonMessageCreationStatus messageCreationStatus, + MessageRequestStatus messageRequestStatus, + Long userId, + String orderId, + boolean attentionOnly + ) { + requireAnyRole(admin, AdminRole.SUPER_ADMIN, AdminRole.FINANCE, AdminRole.SUPPORT); + validatePage(page, size); + AttentionThresholds thresholds = thresholds(); + Page payments = paymentRepository.findForAdmin( + paymentStatus, + messageCreationStatus, + messageRequestStatus, + userId, + StringUtils.hasText(orderId) ? orderId.trim() : "", + attentionOnly, + GifticonPaymentStatus.REFUND_FAILED, + GifticonPaymentStatus.REFUND_PROCESSING, + GifticonPaymentStatus.PAID, + GifticonMessageCreationStatus.FAILED, + thresholds.refundStaleBefore(), + thresholds.messageStaleBefore(), + thresholds.maxRefundAttempts(), + PageRequest.of(page, size) + ); + return new AdminGifticonPaymentPageResponse( + payments.getContent().stream() + .map(payment -> toResponse(payment, thresholds, null, null, null)) + .toList(), + payments.getTotalElements(), + payments.getTotalPages(), + payments.getNumber(), + payments.getSize() + ); + } + + public AdminGifticonPaymentResponse getPayment( + AdminPrincipal admin, + Long paymentId + ) { + requireAnyRole(admin, AdminRole.SUPER_ADMIN, AdminRole.FINANCE, AdminRole.SUPPORT); + return verifiedResponse(paymentId); + } + + private AdminGifticonPaymentResponse verifiedResponse(Long paymentId) { + GifticonPayment payment = findPayment(paymentId); + PaymentResult tossResult = null; + String verificationError = null; + try { + tossResult = paymentGateway.getPayment(new PaymentLookup( + payment.getPaymentKey(), + payment.getOrderId() + )); + } catch (RuntimeException e) { + verificationError = safeError(e); + } + Boolean mismatch = tossResult == null + ? null + : isTossStatusMismatch(payment.getStatus(), tossResult.status()); + return toResponse( + payment, + thresholds(), + tossResult == null ? null : tossResult.status(), + mismatch, + verificationError + ); + } + + public AdminGifticonPaymentResponse retryMessage( + AdminPrincipal admin, + Long paymentId, + String reason, + String ipAddress + ) { + requireAnyRole(admin, AdminRole.SUPER_ADMIN, AdminRole.OPERATOR); + validateReason(reason); + GifticonPayment beforePayment = findPayment(paymentId); + if (beforePayment.getStatus() != GifticonPaymentStatus.PAID + || beforePayment.getMessageRequest() != null) { + throw new IllegalStateException("메시지가 없는 결제 완료 건만 재시도할 수 있습니다."); + } + AdminGifticonPaymentResponse before = localResponse(beforePayment); + + try { + paymentService.retryMessageForAdmin(paymentId); + auditPaymentAction( + admin, + AdminAuditActionType.GIFTICON_PAYMENT_MESSAGE_RETRY, + paymentId, + before, + reason, + ipAddress, + null + ); + return verifiedResponse(paymentId); + } catch (RuntimeException e) { + auditPaymentAction( + admin, + AdminAuditActionType.GIFTICON_PAYMENT_MESSAGE_RETRY, + paymentId, + before, + reason, + ipAddress, + e + ); + throw e; + } + } + + public AdminGifticonPaymentResponse forceRefund( + AdminPrincipal admin, + Long paymentId, + String reason, + String ipAddress + ) { + requireAnyRole(admin, AdminRole.SUPER_ADMIN, AdminRole.FINANCE); + validateReason(reason); + GifticonPayment beforePayment = findPayment(paymentId); + AdminGifticonPaymentResponse before = localResponse(beforePayment); + + try { + paymentService.refundForAdmin(paymentId); + auditPaymentAction( + admin, + AdminAuditActionType.GIFTICON_PAYMENT_FORCE_REFUND, + paymentId, + before, + reason, + ipAddress, + null + ); + return verifiedResponse(paymentId); + } catch (RuntimeException e) { + auditPaymentAction( + admin, + AdminAuditActionType.GIFTICON_PAYMENT_FORCE_REFUND, + paymentId, + before, + reason, + ipAddress, + e + ); + throw e; + } + } + + private AdminGifticonPaymentResponse localResponse(GifticonPayment payment) { + return toResponse(payment, thresholds(), null, null, null); + } + + private AdminGifticonPaymentResponse toResponse( + GifticonPayment payment, + AttentionThresholds thresholds, + String tossStatus, + Boolean tossStatusMismatch, + String tossVerificationError + ) { + List attentionReasons = + attentionReasons(payment, thresholds); + if (Boolean.TRUE.equals(tossStatusMismatch)) { + attentionReasons.add(attentionReason( + GifticonPaymentAttentionReason.TOSS_STATUS_MISMATCH + )); + } + return new AdminGifticonPaymentResponse( + payment.getGifticonPaymentId(), + payment.getOrderId(), + mask(payment.getPaymentKey()), + payment.getUserId(), + payment.getTargetProfileId(), + payment.getProduct().getGifticonProductId(), + payment.getProduct().getProductName(), + payment.getAmount(), + payment.getStatus(), + payment.getStatus().getDescription(), + payment.getMessageCreationStatus(), + payment.getMessageCreationStatus().getDescription(), + payment.getMessageCreationAttemptCount(), + payment.getLastMessageCreationAttemptAt(), + payment.getMessageCreationFailureReason(), + payment.getMessageRequest() == null ? null : payment.getMessageRequest().getId(), + payment.getMessageRequest() == null ? null : payment.getMessageRequest().getStatus(), + payment.getRefundAttemptCount(), + payment.getLastRefundAttemptAt(), + payment.getFailureReason(), + payment.getApprovedAt(), + payment.getRefundedAt(), + payment.getCreatedAt(), + payment.getUpdatedAt(), + List.copyOf(attentionReasons), + tossStatus, + tossStatusMismatch, + tossVerificationError + ); + } + + private List attentionReasons( + GifticonPayment payment, + AttentionThresholds thresholds + ) { + List reasons = new ArrayList<>(); + if (payment.getStatus() == GifticonPaymentStatus.REFUND_FAILED) { + reasons.add(attentionReason(GifticonPaymentAttentionReason.REFUND_FAILED)); + } + if (payment.getStatus() == GifticonPaymentStatus.REFUND_PROCESSING + && (payment.getLastRefundAttemptAt() == null + || payment.getLastRefundAttemptAt().isBefore(thresholds.refundStaleBefore()))) { + reasons.add(attentionReason( + GifticonPaymentAttentionReason.REFUND_PROCESSING_STALE + )); + } + if (payment.getRefundAttemptCount() >= thresholds.maxRefundAttempts() + && payment.getStatus() != GifticonPaymentStatus.REFUNDED) { + reasons.add(attentionReason( + GifticonPaymentAttentionReason.REFUND_ATTEMPTS_EXHAUSTED + )); + } + boolean paidWithoutMessage = payment.getStatus() == GifticonPaymentStatus.PAID + && payment.getMessageRequest() == null + && payment.getApprovedAt() != null + && payment.getApprovedAt().isBefore(thresholds.messageStaleBefore()); + if (paidWithoutMessage) { + reasons.add(attentionReason(GifticonPaymentAttentionReason.PAID_WITHOUT_MESSAGE)); + reasons.add(attentionReason(GifticonPaymentAttentionReason.MESSAGE_CREATION_STALE)); + } + if (payment.getMessageCreationStatus() == GifticonMessageCreationStatus.FAILED + && payment.getStatus() == GifticonPaymentStatus.PAID) { + reasons.add(attentionReason( + GifticonPaymentAttentionReason.MESSAGE_CREATION_FAILED_WITHOUT_REFUND + )); + } + return reasons; + } + + private AdminGifticonPaymentResponse.AttentionReason attentionReason( + GifticonPaymentAttentionReason reason + ) { + return new AdminGifticonPaymentResponse.AttentionReason( + reason.name(), + reason.getDescription() + ); + } + + private boolean isTossStatusMismatch( + GifticonPaymentStatus localStatus, + String tossStatus + ) { + if (!StringUtils.hasText(tossStatus)) { + return true; + } + return switch (localStatus) { + case READY, CONFIRMING -> !UNAPPROVED_TOSS_STATUSES.contains(tossStatus); + case PAID -> !"DONE".equals(tossStatus); + case REFUND_PENDING, REFUND_PROCESSING, REFUND_FAILED -> + !"DONE".equals(tossStatus); + case REFUNDED -> !"CANCELED".equals(tossStatus); + }; + } + + private AttentionThresholds thresholds() { + Instant now = Instant.now(); + return new AttentionThresholds( + now.minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )), + now.minus( + Math.max( + 1L, + properties.getUnusedPaymentRefund().getGracePeriodMinutes() + ), + ChronoUnit.MINUTES + ), + Math.max(1, properties.getRefundRetry().getMaxAttempts()) + ); + } + + private GifticonPayment findPayment(Long paymentId) { + return paymentRepository.findByIdWithMessageRequest(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + } + + private void validatePage(int page, int size) { + if (page < 0) { + throw new IllegalArgumentException("page는 0 이상이어야 합니다."); + } + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("size는 1 이상 100 이하여야 합니다."); + } + } + + private void audit( + AdminPrincipal admin, + AdminAuditActionType action, + Long paymentId, + Object before, + Object after, + String reason, + String ipAddress + ) { + adminAuditService.log( + admin.adminId(), + action, + AdminAuditTargetType.GIFTICON_PAYMENT, + paymentId, + toJson(before), + toJson(after), + reason, + ipAddress + ); + } + + private void auditPaymentAction( + AdminPrincipal admin, + AdminAuditActionType action, + Long paymentId, + AdminGifticonPaymentResponse before, + String reason, + String ipAddress, + RuntimeException failure + ) { + AdminGifticonPaymentResponse after = localResponse(findPayment(paymentId)); + Object afterValue = failure == null + ? after + : new FailedAdminAction(after, safeError(failure)); + audit(admin, action, paymentId, before, afterValue, reason, ipAddress); + } + + private void validateReason(String reason) { + if (!StringUtils.hasText(reason)) { + throw new IllegalArgumentException("관리자 처리 사유는 필수입니다."); + } + if (reason.trim().length() > 500) { + throw new IllegalArgumentException("관리자 처리 사유는 500자를 초과할 수 없습니다."); + } + } + + private String toJson(Object value) { + try { + return objectMapper.writeValueAsString(value); + } catch (JsonProcessingException e) { + return String.valueOf(value); + } + } + + private String safeError(RuntimeException e) { + String message = e.getMessage(); + return e.getClass().getSimpleName() + + (StringUtils.hasText(message) ? ": " + message : ""); + } + + private String mask(String value) { + if (!StringUtils.hasText(value)) { + return null; + } + if (value.length() <= 8) { + return "****"; + } + return value.substring(0, 4) + + "****" + + value.substring(value.length() - 4); + } + + private void requireAnyRole(AdminPrincipal admin, AdminRole... roles) { + if (admin == null || !admin.hasAnyRole(roles)) { + throw new IllegalStateException("해당 관리자 권한으로 수행할 수 없는 작업입니다."); + } + } + + private record AttentionThresholds( + Instant refundStaleBefore, + Instant messageStaleBefore, + int maxRefundAttempts + ) { + } + + private record FailedAdminAction( + AdminGifticonPaymentResponse payment, + String error + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java new file mode 100644 index 0000000..865b203 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java @@ -0,0 +1,124 @@ +package mannabom_server.manabom.application.admin.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.request.AdminConfigureGifticonTokenRequest; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonProductResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonProductSliceResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonSyncResponse; +import mannabom_server.manabom.application.gifticon.port.GifticonTokenCipher; +import mannabom_server.manabom.application.gifticon.service.GifticonCatalogSynchronizer; +import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; +import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; +import mannabom_server.manabom.domain.admin.enums.AdminRole; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminGifticonService { + + private static final int MAX_PAGE_SIZE = 100; + + private final GifticonProductRepository gifticonProductRepository; + private final AdminAuditService adminAuditService; + private final GifticonTokenCipher gifticonTokenCipher; + private final GifticonCatalogSynchronizer gifticonCatalogSynchronizer; + + @Transactional(readOnly = true) + public AdminGifticonProductSliceResponse getProducts( + AdminPrincipal admin, + Long cursor, + int size, + Boolean tokenConfigured, + String keyword + ) { + requireSuperAdmin(admin); + if (cursor != null && cursor < 0) { + throw new IllegalArgumentException("cursor는 0 이상이어야 합니다."); + } + if (size <= 0 || size > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("size는 1 이상 100 이하여야 합니다."); + } + + List fetched = gifticonProductRepository.findProductsForAdminAfter( + cursor == null ? 0L : cursor, + tokenConfigured, + StringUtils.hasText(keyword) ? keyword.trim() : "", + PageRequest.of(0, size + 1) + ); + boolean hasNext = fetched.size() > size; + List current = hasNext ? fetched.subList(0, size) : fetched; + List contents = current.stream() + .map(AdminGifticonProductResponse::from) + .toList(); + Long nextCursor = hasNext + ? current.get(current.size() - 1).getGifticonProductId() + : null; + return new AdminGifticonProductSliceResponse(contents, nextCursor, hasNext); + } + + public AdminGifticonSyncResponse synchronizeCatalog( + AdminPrincipal admin, + String ipAddress + ) { + requireSuperAdmin(admin); + GifticonCatalogSynchronizer.SynchronizationResult result = + gifticonCatalogSynchronizer.synchronize(); + + adminAuditService.log( + admin.adminId(), + AdminAuditActionType.GIFTICON_CATALOG_SYNC, + AdminAuditTargetType.GIFTICON_PRODUCT, + null, + null, + "synchronizedCount=" + result.synchronizedCount(), + "관리자 수동 동기화", + ipAddress + ); + return new AdminGifticonSyncResponse( + result.synchronizedCount(), + result.synchronizedAt() + ); + } + + @Transactional + public AdminGifticonProductResponse configureTemplateToken( + AdminPrincipal admin, + Long gifticonProductId, + AdminConfigureGifticonTokenRequest request, + String ipAddress + ) { + requireSuperAdmin(admin); + GifticonProduct product = gifticonProductRepository.findById(gifticonProductId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 상품을 찾을 수 없습니다.")); + boolean configuredBefore = product.hasTemplateToken(); + product.configureEncryptedTemplateToken( + gifticonTokenCipher.encrypt(request.getTemplateToken().trim()) + ); + + adminAuditService.log( + admin.adminId(), + AdminAuditActionType.GIFTICON_TEMPLATE_TOKEN_UPDATE, + AdminAuditTargetType.GIFTICON_PRODUCT, + gifticonProductId, + "templateTokenConfigured=" + configuredBefore, + "templateTokenConfigured=true", + request.getReason(), + ipAddress + ); + return AdminGifticonProductResponse.from(product); + } + + private void requireSuperAdmin(AdminPrincipal admin) { + if (admin == null || !admin.hasRole(AdminRole.SUPER_ADMIN)) { + throw new IllegalStateException("SUPER_ADMIN 권한이 필요합니다."); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminPolicyService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminPolicyService.java index b6db792..353c3f2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminPolicyService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminPolicyService.java @@ -7,12 +7,17 @@ import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; import mannabom_server.manabom.domain.admin.enums.AdminRole; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; +import mannabom_server.manabom.domain.gifticon.service.GifticonPriceCalculator; import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; import mannabom_server.manabom.policy.model.RuntimePolicySnapshot; import mannabom_server.manabom.policy.service.RuntimePolicyService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; + @Service @RequiredArgsConstructor public class AdminPolicyService { @@ -20,6 +25,8 @@ public class AdminPolicyService { private final RuntimePolicyService runtimePolicyService; private final AdminAuditService adminAuditService; private final ObjectMapper objectMapper; + private final GifticonProductRepository gifticonProductRepository; + private final GifticonPriceCalculator gifticonPriceCalculator; @Transactional(readOnly = true) public RuntimePolicySnapshot getPolicy(AdminPrincipal admin) { @@ -44,33 +51,37 @@ public RuntimePolicySnapshot updatePolicy(AdminPrincipal admin, } RuntimePolicySnapshot after = runtimePolicyService.snapshot(); + if ("gifticon.pricing.markupPercent".equals(request.getKey())) { + recalculateAllGifticonPrices(after.getGifticon().getPricing().getMarkupPercent()); + } adminAuditService.log(admin.adminId(), AdminAuditActionType.POLICY_UPDATE, AdminAuditTargetType.POLICY, null, toJson(before), toJson(after), request.getReason(), ipAddress); return after; } - private void updatePolicy(String key, int value) { + private void updatePolicy(String key, BigDecimal value) { switch (key) { - case "match.cooldownHours" -> runtimePolicyService.updateMatchCooldownHours(value); - case "match.candidatePoolSize" -> runtimePolicyService.updateMatchCandidatePoolSize(value); - case "match.pickPoolSize" -> runtimePolicyService.updateMatchPickPoolSize(value); - case "ting.vipThreshold" -> runtimePolicyService.updateTingVipThreshold(value); - case "ting.cost.extraProfile" -> runtimePolicyService.updateTingCostExtraProfile(value); - case "ting.cost.extraProfileBundle5" -> runtimePolicyService.updateTingCostExtraProfileBundle5(value); - case "ting.cost.message" -> runtimePolicyService.updateTingCostMessage(value); - case "ting.cost.like" -> runtimePolicyService.updateTingCostLike(value); - case "ting.cost.viewExtraPhoto" -> runtimePolicyService.updateTingCostViewExtraPhoto(value); - case "ting.cost.viewScore" -> runtimePolicyService.updateTingCostViewScore(value); - case "ting.cost.viewLikedMeProfile" -> runtimePolicyService.updateTingCostViewLikedMeProfile(value); - case "ting.cost.viewHighScoreProfile" -> runtimePolicyService.updateTingCostViewHighScoreProfile(value); - case "benefit.basic.dailyProfile" -> runtimePolicyService.updateBenefitBasicDailyProfile(value); - case "benefit.basic.dailyLoveView" -> runtimePolicyService.updateBenefitBasicDailyLoveView(value); - case "benefit.membership.cycleExtraProfiles" -> runtimePolicyService.updateBenefitMembershipCycleExtraProfiles(value); - case "benefit.membership.cycleFreeMessages" -> runtimePolicyService.updateBenefitMembershipCycleFreeMessages(value); - case "benefit.membership.cycleFreeLikes" -> runtimePolicyService.updateBenefitMembershipCycleFreeLikes(value); - case "benefit.vip.dailyExtraProfiles" -> runtimePolicyService.updateBenefitVipDailyExtraProfiles(value); - case "benefit.vip.dailyFreeMessages" -> runtimePolicyService.updateBenefitVipDailyFreeMessages(value); - case "benefit.vip.dailyFreeLikes" -> runtimePolicyService.updateBenefitVipDailyFreeLikes(value); + case "match.cooldownHours" -> runtimePolicyService.updateMatchCooldownHours(toInt(key, value)); + case "match.candidatePoolSize" -> runtimePolicyService.updateMatchCandidatePoolSize(toInt(key, value)); + case "match.pickPoolSize" -> runtimePolicyService.updateMatchPickPoolSize(toInt(key, value)); + case "ting.vipThreshold" -> runtimePolicyService.updateTingVipThreshold(toInt(key, value)); + case "ting.cost.extraProfile" -> runtimePolicyService.updateTingCostExtraProfile(toInt(key, value)); + case "ting.cost.extraProfileBundle5" -> runtimePolicyService.updateTingCostExtraProfileBundle5(toInt(key, value)); + case "ting.cost.message" -> runtimePolicyService.updateTingCostMessage(toInt(key, value)); + case "ting.cost.like" -> runtimePolicyService.updateTingCostLike(toInt(key, value)); + case "ting.cost.viewExtraPhoto" -> runtimePolicyService.updateTingCostViewExtraPhoto(toInt(key, value)); + case "ting.cost.viewScore" -> runtimePolicyService.updateTingCostViewScore(toInt(key, value)); + case "ting.cost.viewLikedMeProfile" -> runtimePolicyService.updateTingCostViewLikedMeProfile(toInt(key, value)); + case "ting.cost.viewHighScoreProfile" -> runtimePolicyService.updateTingCostViewHighScoreProfile(toInt(key, value)); + case "benefit.basic.dailyProfile" -> runtimePolicyService.updateBenefitBasicDailyProfile(toInt(key, value)); + case "benefit.basic.dailyLoveView" -> runtimePolicyService.updateBenefitBasicDailyLoveView(toInt(key, value)); + case "benefit.membership.cycleExtraProfiles" -> runtimePolicyService.updateBenefitMembershipCycleExtraProfiles(toInt(key, value)); + case "benefit.membership.cycleFreeMessages" -> runtimePolicyService.updateBenefitMembershipCycleFreeMessages(toInt(key, value)); + case "benefit.membership.cycleFreeLikes" -> runtimePolicyService.updateBenefitMembershipCycleFreeLikes(toInt(key, value)); + case "benefit.vip.dailyExtraProfiles" -> runtimePolicyService.updateBenefitVipDailyExtraProfiles(toInt(key, value)); + case "benefit.vip.dailyFreeMessages" -> runtimePolicyService.updateBenefitVipDailyFreeMessages(toInt(key, value)); + case "benefit.vip.dailyFreeLikes" -> runtimePolicyService.updateBenefitVipDailyFreeLikes(toInt(key, value)); + case "gifticon.pricing.markupPercent" -> runtimePolicyService.updateGifticonMarkupPercent(value); default -> throw new IllegalArgumentException("지원하지 않는 정책 key입니다: " + key); } } @@ -97,10 +108,28 @@ private void resetPolicy(String key) { case "benefit.vip.dailyExtraProfiles" -> runtimePolicyService.resetBenefitVipDailyExtraProfilesToDefault(); case "benefit.vip.dailyFreeMessages" -> runtimePolicyService.resetBenefitVipDailyFreeMessagesToDefault(); case "benefit.vip.dailyFreeLikes" -> runtimePolicyService.resetBenefitVipDailyFreeLikesToDefault(); + case "gifticon.pricing.markupPercent" -> runtimePolicyService.resetGifticonMarkupPercentToDefault(); default -> throw new IllegalArgumentException("지원하지 않는 정책 key입니다: " + key); } } + private int toInt(String key, BigDecimal value) { + try { + return value.intValueExact(); + } catch (ArithmeticException e) { + throw new IllegalArgumentException(key + "는 정수여야 합니다."); + } + } + + private void recalculateAllGifticonPrices(BigDecimal markupPercent) { + for (GifticonProduct product : gifticonProductRepository.findAll()) { + product.updateSalePrice(gifticonPriceCalculator.calculateSalePrice( + product.getProductPrice(), + markupPercent + )); + } + } + private String toJson(Object value) { try { return objectMapper.writeValueAsString(value); diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java index e35a25b..c7bd7d5 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminReportService.java @@ -3,6 +3,7 @@ import jakarta.persistence.EntityManager; import jakarta.persistence.TypedQuery; import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; import mannabom_server.manabom.application.admin.dto.request.AdminProcessReportRequest; import mannabom_server.manabom.application.admin.dto.response.AdminReportDetailResponse; import mannabom_server.manabom.application.admin.dto.response.AdminReportListResponse; @@ -14,6 +15,8 @@ import mannabom_server.manabom.domain.admin.enums.UserAccountStatus; import mannabom_server.manabom.domain.admin.repository.UserAccountRestrictionRepository; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.report.entity.Report; import mannabom_server.manabom.domain.report.entity.ReportStatus; @@ -41,6 +44,7 @@ public class AdminReportService { private final ReportRepository reportRepository; private final ProfileRepository profileRepository; private final TingWalletRepository tingWalletRepository; + private final TingTransactionRecorder tingTransactionRecorder; private final UserAccountRestrictionRepository userAccountRestrictionRepository; private final AdminAuditService adminAuditService; @@ -177,9 +181,27 @@ private void grantTargetTing(AdminPrincipal admin, String before = walletLabel(wallet); if (tingGrant > 0) { wallet.addTing(tingGrant); + tingTransactionRecorder.recordPaid( + wallet, + TingTransactionType.REPORT_COMPENSATION, + tingGrant, + TingTransactionReferenceType.REPORT, + String.valueOf(report.getId()), + "REPORT:" + report.getId() + ":PAID_COMPENSATION", + request.getWalletReason() + ); } if (eventTingGrant > 0) { wallet.addEventTing(eventTingGrant); + tingTransactionRecorder.recordEvent( + wallet, + TingTransactionType.REPORT_COMPENSATION, + eventTingGrant, + TingTransactionReferenceType.REPORT, + String.valueOf(report.getId()), + "REPORT:" + report.getId() + ":EVENT_COMPENSATION", + request.getWalletReason() + ); } adminAuditService.log(admin.adminId(), AdminAuditActionType.WALLET_ADJUST, AdminAuditTargetType.TING_WALLET, targetUserId, before, walletLabel(wallet), diff --git a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminWalletService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminWalletService.java index cf0423a..83ea4d8 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminWalletService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminWalletService.java @@ -4,10 +4,13 @@ import mannabom_server.manabom.application.admin.dto.request.AdminActivateMembershipRequest; import mannabom_server.manabom.application.admin.dto.request.AdminAdjustWalletRequest; import mannabom_server.manabom.application.admin.dto.response.AdminWalletResponse; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; import mannabom_server.manabom.domain.admin.enums.AdminRole; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.user.repository.UserRepository; import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; @@ -26,6 +29,7 @@ public class AdminWalletService { private final TingWalletRepository tingWalletRepository; private final AdminAuditService adminAuditService; private final RuntimePolicyService runtimePolicyService; + private final TingTransactionRecorder tingTransactionRecorder; @Transactional(readOnly = true) public AdminWalletResponse getWallet(AdminPrincipal admin, Long userId) { @@ -56,6 +60,28 @@ public AdminWalletResponse adjustWallet(AdminPrincipal admin, applyTingDelta(wallet, tingDelta); applyEventTingDelta(wallet, eventTingDelta); + if (tingDelta != 0) { + tingTransactionRecorder.recordPaid( + wallet, + TingTransactionType.ADMIN_ADJUSTMENT, + tingDelta, + TingTransactionReferenceType.ADMIN, + String.valueOf(admin.adminId()), + null, + request.getReason() + ); + } + if (eventTingDelta != 0) { + tingTransactionRecorder.recordEvent( + wallet, + TingTransactionType.ADMIN_ADJUSTMENT, + eventTingDelta, + TingTransactionReferenceType.ADMIN, + String.valueOf(admin.adminId()), + null, + request.getReason() + ); + } String after = "ting=" + wallet.getTing() + ", eventTing=" + wallet.getEventTing(); adminAuditService.log(admin.adminId(), AdminAuditActionType.WALLET_ADJUST, diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatGifticonInfo.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatGifticonInfo.java new file mode 100644 index 0000000..18ecf35 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatGifticonInfo.java @@ -0,0 +1,25 @@ +package mannabom_server.manabom.application.chat.dto.response; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; + +public record ChatGifticonInfo( + Long paymentId, + Long productId, + String productName, + String brandName, + String productImageUrl, + String productThumbnailImageUrl +) { + public static ChatGifticonInfo from(GifticonPayment payment) { + GifticonProduct product = payment.getProduct(); + return new ChatGifticonInfo( + payment.getGifticonPaymentId(), + product.getGifticonProductId(), + product.getProductName(), + product.getBrandName(), + product.getProductImageUrl(), + product.getProductThumbnailImageUrl() + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java index 59e659a..6b404a3 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageEvent.java @@ -19,4 +19,6 @@ public class ChatMessageEvent { private String clientMessageId; private Instant sendAt; + + private ChatGifticonInfo gifticon; } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java index 9f3f3f9..5feb77f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/dto/response/ChatMessageResponse.java @@ -3,6 +3,7 @@ import lombok.Builder; import lombok.Getter; import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; import java.time.Instant; @@ -15,6 +16,7 @@ public class ChatMessageResponse { private Instant createdAt; private Long senderId; + private ChatGifticonInfo gifticon; public static ChatMessageResponse of(ChatMessage msg){ return ChatMessageResponse.builder() @@ -23,6 +25,10 @@ public static ChatMessageResponse of(ChatMessage msg){ .messageType(msg.getType().name()) .senderId(msg.getUser().getUserId()) .content(msg.getContent()) + .gifticon(msg.getType() == ChatMessageType.GIFTICON + && msg.getGifticonPayment() != null + ? ChatGifticonInfo.from(msg.getGifticonPayment()) + : null) .build(); } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java index 2924c2d..51a7f2c 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/chat/service/ChatService.java @@ -10,6 +10,7 @@ import mannabom_server.manabom.domain.chat.entity.ChatMessage; import mannabom_server.manabom.domain.chat.entity.ChatRoom; import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; @@ -52,6 +53,10 @@ public class ChatService { //채팅 보내기 @Transactional public void sendMessage(ChatSendRequest request, Long userId) { + if (request.getMessageType() != ChatMessageType.TEXT + && request.getMessageType() != ChatMessageType.IMAGE) { + throw new IllegalArgumentException("일반 채팅에서는 텍스트와 이미지만 직접 전송할 수 있습니다."); + } ChatRoom chatRoom = chatRoomRepository.findById(request.getRoomId()) .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 채팅방입니다.")); ChatMember sender = chatMemberRepository.findByRoomIdAndUser_UserIdAndStatus(request.getRoomId(), userId, ChatMemberStatus.ACTIVATE) diff --git a/manabom/src/main/java/mannabom_server/manabom/application/currency/service/TingTransactionRecorder.java b/manabom/src/main/java/mannabom_server/manabom/application/currency/service/TingTransactionRecorder.java new file mode 100644 index 0000000..c510cce --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/currency/service/TingTransactionRecorder.java @@ -0,0 +1,97 @@ +package mannabom_server.manabom.application.currency.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.domain.currency.entity.TingTransaction; +import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingBalanceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; +import mannabom_server.manabom.domain.currency.repository.TingTransactionRepository; +import org.springframework.stereotype.Service; +import org.springframework.util.StringUtils; + +@Service +@RequiredArgsConstructor +public class TingTransactionRecorder { + + private final TingTransactionRepository tingTransactionRepository; + + public TingTransaction recordPaid( + TingWallet wallet, + TingTransactionType transactionType, + int amountDelta, + TingTransactionReferenceType referenceType, + String referenceId, + String idempotencyKey, + String description + ) { + return record( + wallet, + TingBalanceType.PAID, + transactionType, + amountDelta, + wallet.getTing(), + referenceType, + referenceId, + idempotencyKey, + description + ); + } + + public TingTransaction recordEvent( + TingWallet wallet, + TingTransactionType transactionType, + int amountDelta, + TingTransactionReferenceType referenceType, + String referenceId, + String idempotencyKey, + String description + ) { + return record( + wallet, + TingBalanceType.EVENT, + transactionType, + amountDelta, + wallet.getEventTing(), + referenceType, + referenceId, + idempotencyKey, + description + ); + } + + private TingTransaction record( + TingWallet wallet, + TingBalanceType balanceType, + TingTransactionType transactionType, + int amountDelta, + int balanceAfter, + TingTransactionReferenceType referenceType, + String referenceId, + String idempotencyKey, + String description + ) { + if (wallet == null) { + throw new IllegalArgumentException("팅 거래를 기록할 지갑은 필수입니다."); + } + if (StringUtils.hasText(idempotencyKey)) { + TingTransaction existing = tingTransactionRepository + .findByIdempotencyKey(idempotencyKey.trim()) + .orElse(null); + if (existing != null) { + return existing; + } + } + return tingTransactionRepository.save(new TingTransaction( + wallet.getUserId(), + balanceType, + transactionType, + amountDelta, + balanceAfter, + referenceType, + referenceId, + idempotencyKey, + description + )); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/ConfirmGifticonPaymentRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/ConfirmGifticonPaymentRequest.java new file mode 100644 index 0000000..ea31959 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/ConfirmGifticonPaymentRequest.java @@ -0,0 +1,14 @@ +package mannabom_server.manabom.application.gifticon.dto.request; + +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Positive; + +public record ConfirmGifticonPaymentRequest( + @NotBlank(message = "paymentKey는 필수입니다.") + String paymentKey, + @NotBlank(message = "orderId는 필수입니다.") + String orderId, + @Positive(message = "amount는 0보다 커야 합니다.") + int amount +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareChatGifticonPaymentRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareChatGifticonPaymentRequest.java new file mode 100644 index 0000000..b853429 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareChatGifticonPaymentRequest.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.application.gifticon.dto.request; + +import jakarta.validation.constraints.NotNull; + +public record PrepareChatGifticonPaymentRequest( + @NotNull(message = "gifticonProductId는 필수입니다.") + Long gifticonProductId, + @NotNull(message = "roomId는 필수입니다.") + Long roomId +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareGifticonPaymentRequest.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareGifticonPaymentRequest.java new file mode 100644 index 0000000..8ddc261 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/request/PrepareGifticonPaymentRequest.java @@ -0,0 +1,17 @@ +package mannabom_server.manabom.application.gifticon.dto.request; + +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; +import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; + +public record PrepareGifticonPaymentRequest( + @NotNull(message = "gifticonProductId는 필수입니다.") + Long gifticonProductId, + @NotNull(message = "targetProfileId는 필수입니다.") + Long targetProfileId, + @Size(max = 200, message = "message는 200자를 초과할 수 없습니다.") + String message, + @NotNull(message = "source는 필수입니다.") + MessageSource source +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentPrepareResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentPrepareResponse.java new file mode 100644 index 0000000..39cfa1b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentPrepareResponse.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.application.gifticon.dto.response; + +public record GifticonPaymentPrepareResponse( + Long gifticonPaymentId, + String orderId, + String customerKey, + String orderName, + int amount, + String clientKey +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentResponse.java new file mode 100644 index 0000000..f6e1da0 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonPaymentResponse.java @@ -0,0 +1,45 @@ +package mannabom_server.manabom.application.gifticon.dto.response; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; + +public record GifticonPaymentResponse( + Long gifticonPaymentId, + String orderId, + int amount, + GifticonPaymentStatus status, + GifticonMessageCreationStatus messageCreationStatus, + Long messageRequestId, + GifticonPaymentPurpose purpose, + Long chatRoomId, + Long receiverUserId, + Long chatMessageId, + GifticonOrderStatus gifticonOrderStatus, + String gifticonOrderFailureReason +) { + public static GifticonPaymentResponse from(GifticonPayment payment) { + return new GifticonPaymentResponse( + payment.getGifticonPaymentId(), + payment.getOrderId(), + payment.getAmount(), + payment.getStatus(), + payment.getMessageCreationStatus(), + payment.getMessageRequest() == null + ? null + : payment.getMessageRequest().getId(), + payment.getPurpose(), + payment.getChatRoom() == null ? null : payment.getChatRoom().getId(), + payment.getReceiverUserId(), + payment.getChatMessage() == null ? null : payment.getChatMessage().getId(), + payment.getGifticonOrder() == null + ? null + : payment.getGifticonOrder().getStatus(), + payment.getGifticonOrder() == null + ? null + : payment.getGifticonOrder().getFailureReason() + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductResponse.java new file mode 100644 index 0000000..87a4bdc --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductResponse.java @@ -0,0 +1,39 @@ +package mannabom_server.manabom.application.gifticon.dto.response; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; + +import java.time.LocalDateTime; + +public record GifticonProductResponse( + Long gifticonProductId, + String templateTraceId, + String templateName, + String itemType, + String productName, + String brandName, + String productImageUrl, + String productThumbnailImageUrl, + String brandImageUrl, + int productPrice, + int salePrice, + LocalDateTime startAt, + LocalDateTime endAt +) { + public static GifticonProductResponse from(GifticonProduct product) { + return new GifticonProductResponse( + product.getGifticonProductId(), + String.valueOf(product.getTemplateTraceId()), + product.getTemplateName(), + product.getItemType(), + product.getProductName(), + product.getBrandName(), + product.getProductImageUrl(), + product.getProductThumbnailImageUrl(), + product.getBrandImageUrl(), + product.getProductPrice(), + product.getSalePrice(), + product.getStartAt(), + product.getEndAt() + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductSliceResponse.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductSliceResponse.java new file mode 100644 index 0000000..3cfac87 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductSliceResponse.java @@ -0,0 +1,10 @@ +package mannabom_server.manabom.application.gifticon.dto.response; + +import java.util.List; + +public record GifticonProductSliceResponse( + List contents, + Long nextCursor, + boolean hasNext +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEvent.java new file mode 100644 index 0000000..9325dbf --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEvent.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.application.gifticon.event; + +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; + +public record ChatGifticonDeliveryFailedEvent( + Long senderUserId, + Long paymentId, + Long roomId, + GifticonPaymentStatus paymentStatus +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEventListener.java new file mode 100644 index 0000000..91c87cb --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonDeliveryFailedEventListener.java @@ -0,0 +1,42 @@ +package mannabom_server.manabom.application.gifticon.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.domain.meeting.enums.SseEventName; +import org.springframework.context.event.EventListener; +import org.springframework.stereotype.Component; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatGifticonDeliveryFailedEventListener { + + private final NotificationService notificationService; + + @EventListener + public void notifySender(ChatGifticonDeliveryFailedEvent event) { + try { + notificationService.sendNotification( + event.senderUserId(), + SseEventName.GIFTICON_DELIVERY_FAILED, + "기프티콘 발송 실패", + "기프티콘을 보내지 못해 결제 환불을 요청했습니다.", + Map.of( + "paymentId", event.paymentId(), + "roomId", event.roomId(), + "paymentStatus", event.paymentStatus().name() + ) + ); + } catch (RuntimeException exception) { + log.error( + "채팅 기프티콘 발송 실패 알림 전송 실패. senderUserId={}, paymentId={}", + event.senderUserId(), + event.paymentId(), + exception + ); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEvent.java new file mode 100644 index 0000000..9c38391 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEvent.java @@ -0,0 +1,17 @@ +package mannabom_server.manabom.application.gifticon.event; + +import mannabom_server.manabom.application.chat.dto.response.ChatGifticonInfo; + +import java.time.Instant; + +public record ChatGifticonMessageCreatedEvent( + Long roomId, + Long messageId, + Long senderUserId, + Long receiverUserId, + String senderNickname, + String content, + Instant createdAt, + ChatGifticonInfo gifticon +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEventListener.java new file mode 100644 index 0000000..45ecf2b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/ChatGifticonMessageCreatedEventListener.java @@ -0,0 +1,61 @@ +package mannabom_server.manabom.application.gifticon.event; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.chat.dto.response.ChatMessageEvent; +import mannabom_server.manabom.application.notification.service.NotificationService; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; +import mannabom_server.manabom.domain.meeting.enums.SseEventName; +import org.springframework.messaging.simp.SimpMessagingTemplate; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +import java.util.Map; + +@Slf4j +@Component +@RequiredArgsConstructor +public class ChatGifticonMessageCreatedEventListener { + + private final SimpMessagingTemplate messagingTemplate; + private final NotificationService notificationService; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void notifyReceiver(ChatGifticonMessageCreatedEvent event) { + messagingTemplate.convertAndSend( + "/topic/rooms/" + event.roomId(), + ChatMessageEvent.builder() + .roomId(event.roomId()) + .senderUserId(event.senderUserId()) + .messageType(ChatMessageType.GIFTICON.name()) + .content(event.content()) + .messageId(event.messageId()) + .sendAt(event.createdAt()) + .gifticon(event.gifticon()) + .build() + ); + + try { + notificationService.sendNotification( + event.receiverUserId(), + SseEventName.NEW_CHAT_MESSAGE, + event.senderNickname(), + "🎁 기프티콘을 보냈습니다.", + Map.of( + "roomId", event.roomId(), + "messageId", event.messageId(), + "messageType", ChatMessageType.GIFTICON.name() + ) + ); + } catch (RuntimeException exception) { + log.error( + "채팅 기프티콘 수신 알림 전송 실패. roomId={}, messageId={}, receiverUserId={}", + event.roomId(), + event.messageId(), + event.receiverUserId(), + exception + ); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEvent.java new file mode 100644 index 0000000..2d1e603 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEvent.java @@ -0,0 +1,4 @@ +package mannabom_server.manabom.application.gifticon.event; + +public record GifticonOrderReadyEvent(Long gifticonOrderId) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEventListener.java new file mode 100644 index 0000000..1447495 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEventListener.java @@ -0,0 +1,21 @@ +package mannabom_server.manabom.application.gifticon.event; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonOrderProcessor; +import org.springframework.stereotype.Component; +import org.springframework.scheduling.annotation.Async; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class GifticonOrderReadyEventListener { + + private final GifticonOrderProcessor gifticonOrderProcessor; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + @Async + public void requestGift(GifticonOrderReadyEvent event) { + gifticonOrderProcessor.process(event.gifticonOrderId()); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundEventListener.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundEventListener.java new file mode 100644 index 0000000..bccb85f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundEventListener.java @@ -0,0 +1,21 @@ +package mannabom_server.manabom.application.gifticon.event; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import org.springframework.scheduling.annotation.Async; +import org.springframework.stereotype.Component; +import org.springframework.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class GifticonPaymentRefundEventListener { + + private final GifticonPaymentService gifticonPaymentService; + + @Async + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void refund(GifticonPaymentRefundRequestedEvent event) { + gifticonPaymentService.refund(event.gifticonPaymentId()); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundRequestedEvent.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundRequestedEvent.java new file mode 100644 index 0000000..a0373a5 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonPaymentRefundRequestedEvent.java @@ -0,0 +1,4 @@ +package mannabom_server.manabom.application.gifticon.event; + +public record GifticonPaymentRefundRequestedEvent(Long gifticonPaymentId) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonOrderRequester.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonOrderRequester.java new file mode 100644 index 0000000..afe32e8 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonOrderRequester.java @@ -0,0 +1,8 @@ +package mannabom_server.manabom.application.gifticon.port; + +import mannabom_server.manabom.application.gifticon.port.command.GifticonOrderCommand; + +public interface GifticonOrderRequester { + + void requestGift(GifticonOrderCommand command); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonPaymentGateway.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonPaymentGateway.java new file mode 100644 index 0000000..d70cf31 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonPaymentGateway.java @@ -0,0 +1,39 @@ +package mannabom_server.manabom.application.gifticon.port; + +public interface GifticonPaymentGateway { + + PaymentResult confirm(ConfirmPaymentCommand command); + + PaymentResult cancel(CancelPaymentCommand command); + + PaymentResult getPayment(PaymentLookup query); + + record ConfirmPaymentCommand( + String paymentKey, + String orderId, + int amount, + String idempotencyKey + ) { + } + + record CancelPaymentCommand( + String paymentKey, + String cancelReason, + String idempotencyKey + ) { + } + + record PaymentLookup( + String paymentKey, + String orderId + ) { + } + + record PaymentResult( + String paymentKey, + String orderId, + int totalAmount, + String status + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTemplateProvider.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTemplateProvider.java new file mode 100644 index 0000000..5bdcba4 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTemplateProvider.java @@ -0,0 +1,10 @@ +package mannabom_server.manabom.application.gifticon.port; + +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; + +import java.util.List; + +public interface GifticonTemplateProvider { + + List findAliveTemplates(); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTokenCipher.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTokenCipher.java new file mode 100644 index 0000000..b72bf94 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTokenCipher.java @@ -0,0 +1,8 @@ +package mannabom_server.manabom.application.gifticon.port; + +public interface GifticonTokenCipher { + + String encrypt(String plainToken); + + String decrypt(String encryptedToken); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java new file mode 100644 index 0000000..da20044 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.application.gifticon.port.command; + +public record GifticonOrderCommand( + String templateToken, + String senderNickname, + String receiverPhone, + String receiverName, + String externalKey, + String externalOrderId +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java new file mode 100644 index 0000000..e29308c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java @@ -0,0 +1,38 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.service.GifticonCatalogSynchronizer; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.kakao.giftbiz.sync", + name = "enabled", + havingValue = "true" +) +public class GifticonCatalogSyncScheduler { + + private final GifticonCatalogSynchronizer gifticonCatalogSynchronizer; + + @Scheduled( + initialDelayString = "${app.kakao.giftbiz.sync.initial-delay:10000}", + fixedDelayString = "${app.kakao.giftbiz.sync.fixed-delay:1800000}" + ) + public void synchronize() { + try { + GifticonCatalogSynchronizer.SynchronizationResult result = + gifticonCatalogSynchronizer.synchronize(); + log.info( + "[Gift Biz] 템플릿 DB 동기화 완료: {}개", + result.synchronizedCount() + ); + } catch (Exception e) { + log.error("[Gift Biz] 템플릿 동기화 실패. 기존 상품 DB를 유지합니다.", e); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonMessageCreationRetryScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonMessageCreationRetryScheduler.java new file mode 100644 index 0000000..9090512 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonMessageCreationRetryScheduler.java @@ -0,0 +1,59 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.toss-payments.message-creation-retry", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class GifticonMessageCreationRetryScheduler { + + private static final List RETRYABLE_STATUSES = List.of( + GifticonMessageCreationStatus.PENDING, + GifticonMessageCreationStatus.PROCESSING, + GifticonMessageCreationStatus.RETRY_PENDING + ); + + private final GifticonPaymentRepository paymentRepository; + private final GifticonPaymentService paymentService; + private final TossPaymentsProperties properties; + + @Scheduled( + initialDelayString = "${app.toss-payments.message-creation-retry.initial-delay:60000}", + fixedDelayString = "${app.toss-payments.message-creation-retry.fixed-delay:60000}" + ) + public void retry() { + TossPaymentsProperties.MessageCreationRetry retry = + properties.getMessageCreationRetry(); + List paymentIds = paymentRepository.findMessageCreationRetryIds( + GifticonPaymentStatus.PAID, + GifticonPaymentPurpose.MESSAGE_REQUEST, + RETRYABLE_STATUSES, + retry.getMaxAttempts(), + GifticonMessageCreationStatus.PROCESSING, + Instant.now().minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )), + PageRequest.of(0, Math.max(1, retry.getBatchSize())) + ); + paymentIds.forEach(paymentService::createMessageForPaidPayment); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonOrderRetryScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonOrderRetryScheduler.java new file mode 100644 index 0000000..6874677 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonOrderRetryScheduler.java @@ -0,0 +1,55 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonOrderProcessor; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonOrderRepository; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config.GiftbizProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; +import java.time.Instant; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.kakao.giftbiz.order.retry", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class GifticonOrderRetryScheduler { + + private static final List RETRYABLE_STATUSES = + List.of( + GifticonOrderStatus.PENDING, + GifticonOrderStatus.PROCESSING, + GifticonOrderStatus.FAILED + ); + + private final GifticonOrderRepository gifticonOrderRepository; + private final GifticonOrderProcessor gifticonOrderProcessor; + private final GiftbizProperties giftbizProperties; + + @Scheduled( + initialDelayString = "${app.kakao.giftbiz.order.retry.initial-delay:60000}", + fixedDelayString = "${app.kakao.giftbiz.order.retry.fixed-delay:60000}" + ) + public void retry() { + GiftbizProperties.Retry retry = giftbizProperties.getOrder().getRetry(); + List orderIds = gifticonOrderRepository.findRetryableOrderIds( + RETRYABLE_STATUSES, + retry.getMaxAttempts(), + GifticonOrderStatus.PROCESSING, + Instant.now().minusSeconds(Math.max( + 60L, + giftbizProperties.getRequestTimeoutSeconds() * 3L + )), + PageRequest.of(0, retry.getBatchSize()) + ); + orderIds.forEach(gifticonOrderProcessor::process); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonPaymentRefundRetryScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonPaymentRefundRetryScheduler.java new file mode 100644 index 0000000..9a2c47a --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonPaymentRefundRetryScheduler.java @@ -0,0 +1,54 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.util.List; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.toss-payments.refund-retry", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class GifticonPaymentRefundRetryScheduler { + + private static final List RETRYABLE_STATUSES = List.of( + GifticonPaymentStatus.REFUND_PENDING, + GifticonPaymentStatus.REFUND_PROCESSING, + GifticonPaymentStatus.REFUND_FAILED + ); + + private final GifticonPaymentRepository paymentRepository; + private final GifticonPaymentService paymentService; + private final TossPaymentsProperties properties; + + @Scheduled( + initialDelayString = "${app.toss-payments.refund-retry.initial-delay:60000}", + fixedDelayString = "${app.toss-payments.refund-retry.fixed-delay:60000}" + ) + public void retry() { + TossPaymentsProperties.RefundRetry retry = properties.getRefundRetry(); + List paymentIds = paymentRepository.findRefundRetryIds( + RETRYABLE_STATUSES, + retry.getMaxAttempts(), + GifticonPaymentStatus.REFUND_PROCESSING, + Instant.now().minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )), + PageRequest.of(0, retry.getBatchSize()) + ); + paymentIds.forEach(paymentService::refund); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/UnusedGifticonPaymentRefundScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/UnusedGifticonPaymentRefundScheduler.java new file mode 100644 index 0000000..f84e0f1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/UnusedGifticonPaymentRefundScheduler.java @@ -0,0 +1,56 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.data.domain.PageRequest; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; + +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.toss-payments.unused-payment-refund", + name = "enabled", + havingValue = "true", + matchIfMissing = true +) +public class UnusedGifticonPaymentRefundScheduler { + + private final GifticonPaymentRepository paymentRepository; + private final GifticonPaymentService paymentService; + private final TossPaymentsProperties properties; + + @Scheduled( + initialDelayString = "${app.toss-payments.unused-payment-refund.initial-delay:60000}", + fixedDelayString = "${app.toss-payments.unused-payment-refund.fixed-delay:60000}" + ) + public void refundUnusedPayments() { + TossPaymentsProperties.UnusedPaymentRefund policy = + properties.getUnusedPaymentRefund(); + long gracePeriodMinutes = Math.max(1L, policy.getGracePeriodMinutes()); + Instant approvedBefore = Instant.now() + .minus(gracePeriodMinutes, ChronoUnit.MINUTES); + List paymentIds = paymentRepository.findUnusedPaidPaymentIds( + GifticonPaymentStatus.PAID, + GifticonPaymentPurpose.MESSAGE_REQUEST, + approvedBefore, + PageRequest.of(0, Math.max(1, policy.getBatchSize())) + ); + + paymentIds.forEach(paymentId -> + paymentService.requestExpiredUnusedPaymentRefund( + paymentId, + approvedBefore + ) + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogService.java new file mode 100644 index 0000000..ec7e593 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogService.java @@ -0,0 +1,172 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonProductResponse; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonProductSliceResponse; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; +import mannabom_server.manabom.domain.gifticon.service.GifticonPriceCalculator; +import mannabom_server.manabom.policy.service.RuntimePolicyService; +import org.springframework.data.domain.PageRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.util.StringUtils; + +import java.time.Clock; +import java.time.Instant; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.function.Function; +import java.util.stream.Collectors; + +@Service +@RequiredArgsConstructor +public class GifticonCatalogService { + + private static final ZoneId KOREA_ZONE = ZoneId.of("Asia/Seoul"); + private static final int MAX_PAGE_SIZE = 100; + + private final GifticonProductRepository gifticonProductRepository; + private final GifticonPriceCalculator gifticonPriceCalculator; + private final RuntimePolicyService runtimePolicyService; + private final Clock clock = Clock.system(KOREA_ZONE); + + @Transactional(readOnly = true) + public GifticonProductSliceResponse getAvailableProducts( + String category, + Long cursor, + int size + ) { + if (size < 1 || size > MAX_PAGE_SIZE) { + throw new IllegalArgumentException("size는 1 이상 100 이하여야 합니다."); + } + if (cursor != null && cursor < 0) { + throw new IllegalArgumentException("cursor는 0 이상이어야 합니다."); + } + + LocalDateTime now = LocalDateTime.now(clock); + String normalizedCategory = StringUtils.hasText(category) ? category.trim() : null; + long cursorId = cursor == null ? 0L : cursor; + List fetchedProducts = gifticonProductRepository + .findAvailableProductsAfter( + now, + normalizedCategory, + cursorId, + PageRequest.of(0, size + 1) + ); + + boolean hasNext = fetchedProducts.size() > size; + List currentProducts = hasNext + ? fetchedProducts.subList(0, size) + : fetchedProducts; + List contents = currentProducts.stream() + .map(GifticonProductResponse::from) + .toList(); + Long nextCursor = hasNext && !currentProducts.isEmpty() + ? currentProducts.get(currentProducts.size() - 1).getGifticonProductId() + : null; + + return new GifticonProductSliceResponse(contents, nextCursor, hasNext); + } + + @Transactional + public int synchronizeAliveTemplates(List fetchedTemplates) { + Objects.requireNonNull(fetchedTemplates, "기프티콘 템플릿 목록은 null일 수 없습니다."); + + fetchedTemplates.forEach(this::validateTemplate); + Map templatesByTraceId = fetchedTemplates.stream() + .collect(Collectors.toMap( + GifticonTemplateSnapshot::templateTraceId, + Function.identity(), + (first, second) -> second, + LinkedHashMap::new + )); + + if (templatesByTraceId.isEmpty()) { + throw new IllegalStateException("활성 템플릿이 0개이므로 동기화를 중단합니다."); + } + + Map productsByTraceId = gifticonProductRepository + .findAllByTemplateTraceIdIn(templatesByTraceId.keySet()) + .stream() + .collect(Collectors.toMap(GifticonProduct::getTemplateTraceId, Function.identity())); + + Instant syncedAt = clock.instant(); + List synchronizedProducts = templatesByTraceId.values().stream() + .map(template -> synchronizeProduct( + productsByTraceId.computeIfAbsent( + template.templateTraceId(), + GifticonProduct::new + ), + template, + syncedAt + )) + .toList(); + + gifticonProductRepository.saveAllAndFlush(synchronizedProducts); + gifticonProductRepository.markProductsNotSeenSinceUnavailable(syncedAt); + return synchronizedProducts.size(); + } + + private GifticonProduct synchronizeProduct( + GifticonProduct gifticonProduct, + GifticonTemplateSnapshot template, + Instant syncedAt + ) { + gifticonProduct.synchronize( + template.templateName(), + template.startAt(), + template.endAt(), + template.status(), + template.budgetType(), + template.sentCount() == null ? 0L : template.sentCount(), + template.senderName(), + template.messageCardImageUrl(), + template.messageCardText(), + template.itemType(), + template.productName(), + template.brandName(), + template.productImageUrl(), + template.productThumbnailImageUrl(), + template.brandImageUrl(), + template.productPrice(), + gifticonPriceCalculator.calculateSalePrice( + template.productPrice(), + runtimePolicyService.snapshot() + .getGifticon() + .getPricing() + .getMarkupPercent() + ), + syncedAt + ); + return gifticonProduct; + } + + private void validateTemplate(GifticonTemplateSnapshot template) { + if (template == null) { + throw new IllegalArgumentException("기프티콘 템플릿에 null 항목이 포함되어 있습니다."); + } + if (template.templateTraceId() == null) { + throw new IllegalArgumentException("기프티콘 템플릿 식별자가 없습니다."); + } + if (!StringUtils.hasText(template.templateName())) { + throw new IllegalArgumentException("기프티콘 템플릿 이름이 없습니다."); + } + if (!StringUtils.hasText(template.status())) { + throw new IllegalArgumentException("기프티콘 템플릿 상태가 없습니다."); + } + if (!StringUtils.hasText(template.itemType()) + || !StringUtils.hasText(template.productName()) + || !StringUtils.hasText(template.brandName())) { + throw new IllegalArgumentException("기프티콘 상품의 필수 정보가 누락되었습니다."); + } + if (template.productPrice() == null || template.productPrice() < 0) { + throw new IllegalArgumentException("기프티콘 상품 가격이 올바르지 않습니다."); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogSynchronizer.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogSynchronizer.java new file mode 100644 index 0000000..8156877 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogSynchronizer.java @@ -0,0 +1,41 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.port.GifticonTemplateProvider; +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import org.springframework.stereotype.Service; + +import java.time.Instant; +import java.util.List; +import java.util.concurrent.atomic.AtomicBoolean; + +@Service +@RequiredArgsConstructor +public class GifticonCatalogSynchronizer { + + private final GifticonTemplateProvider gifticonTemplateProvider; + private final GifticonCatalogService gifticonCatalogService; + private final AtomicBoolean synchronizing = new AtomicBoolean(false); + + public SynchronizationResult synchronize() { + if (!synchronizing.compareAndSet(false, true)) { + throw new IllegalStateException("기프티콘 상품 동기화가 이미 진행 중입니다."); + } + + try { + List templates = + gifticonTemplateProvider.findAliveTemplates(); + int synchronizedCount = + gifticonCatalogService.synchronizeAliveTemplates(templates); + return new SynchronizationResult(synchronizedCount, Instant.now()); + } finally { + synchronizing.set(false); + } + } + + public record SynchronizationResult( + int synchronizedCount, + Instant synchronizedAt + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderAttemptService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderAttemptService.java new file mode 100644 index 0000000..fbdc15c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderAttemptService.java @@ -0,0 +1,120 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.port.GifticonTokenCipher; +import mannabom_server.manabom.application.gifticon.port.command.GifticonOrderCommand; +import mannabom_server.manabom.domain.gifticon.entity.GifticonOrder; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.repository.GifticonOrderRepository; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config.GiftbizProperties; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GifticonOrderAttemptService { + + private final GifticonOrderRepository gifticonOrderRepository; + private final GifticonTokenCipher gifticonTokenCipher; + private final GiftbizProperties giftbizProperties; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public GifticonOrderAttempt prepare(Long gifticonOrderId) { + GifticonOrder order = gifticonOrderRepository.findByIdForUpdate(gifticonOrderId) + .orElse(null); + if (order == null) { + log.warn("[Gift Biz] 발송 대기 주문을 찾을 수 없습니다. gifticonOrderId={}", gifticonOrderId); + return null; + } + + int timeoutSeconds = giftbizProperties.getRequestTimeoutSeconds(); + Instant now = Instant.now(); + if (!order.canStartAttempt( + giftbizProperties.getOrder().getRetry().getMaxAttempts(), + now.minusSeconds(Math.max(60L, timeoutSeconds * 3L)) + )) { + return null; + } + + order.markProcessing(now); + try { + GifticonProduct product = order.getPayment() == null + ? order.getMessageRequest().getGifticonProduct() + : order.getPayment().getProduct(); + if (product == null || !product.hasTemplateToken()) { + throw new IllegalStateException("발송 가능한 템플릿 토큰이 등록되지 않은 기프티콘 상품입니다."); + } + return new GifticonOrderAttempt( + new GifticonOrderCommand( + gifticonTokenCipher.decrypt(product.getEncryptedTemplateToken()), + order.getSenderNickname(), + order.getReceiverPhone(), + order.getReceiverName(), + order.getExternalKey(), + order.getExternalOrderId() + ), + order.getExternalOrderId() + ); + } catch (RuntimeException e) { + order.markFailed(now, safeFailureReason(e)); + log.error( + "[Gift Biz] 발송 데이터 준비 실패. gifticonOrderId={}", + gifticonOrderId, + e + ); + return null; + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void markRequested(Long gifticonOrderId, Instant requestedAt) { + GifticonOrder order = gifticonOrderRepository.findByIdForUpdate(gifticonOrderId) + .orElseThrow(() -> new IllegalStateException("기프티콘 주문을 찾을 수 없습니다.")); + if (order.getStatus() == GifticonOrderStatus.PROCESSING) { + order.markRequested(requestedAt); + } + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public OrderFailure markFailed(Long gifticonOrderId, Instant failedAt, String reason) { + GifticonOrder order = gifticonOrderRepository.findByIdForUpdate(gifticonOrderId) + .orElseThrow(() -> new IllegalStateException("기프티콘 주문을 찾을 수 없습니다.")); + if (order.getStatus() == GifticonOrderStatus.PROCESSING) { + order.markFailed(failedAt, reason); + } + boolean attemptsExhausted = order.hasExhaustedAttempts( + giftbizProperties.getOrder().getRetry().getMaxAttempts() + ); + Long chatPaymentId = order.getPayment() != null + && order.getPayment().getPurpose() + == GifticonPaymentPurpose.CHAT + ? order.getPayment().getGifticonPaymentId() + : null; + return new OrderFailure(chatPaymentId, attemptsExhausted); + } + + public record GifticonOrderAttempt( + GifticonOrderCommand command, + String externalOrderId + ) { + } + + public record OrderFailure( + Long chatPaymentId, + boolean attemptsExhausted + ) { + } + + private String safeFailureReason(RuntimeException exception) { + String message = exception.getMessage(); + return exception.getClass().getSimpleName() + + (message == null || message.isBlank() ? "" : ": " + message); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderCompletionService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderCompletionService.java new file mode 100644 index 0000000..c0c472f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderCompletionService.java @@ -0,0 +1,77 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.chat.dto.response.ChatGifticonInfo; +import mannabom_server.manabom.application.gifticon.event.ChatGifticonMessageCreatedEvent; +import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.enums.ChatMessageType; +import mannabom_server.manabom.domain.chat.repository.ChatMessageRepository; +import mannabom_server.manabom.domain.gifticon.entity.GifticonOrder; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.repository.GifticonOrderRepository; +import mannabom_server.manabom.domain.user.entity.Profile; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.ProfileRepository; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class GifticonOrderCompletionService { + + private final GifticonOrderRepository orderRepository; + private final ChatMessageRepository chatMessageRepository; + private final UserRepository userRepository; + private final ProfileRepository profileRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void completeRequested(Long orderId, Instant requestedAt) { + GifticonOrder order = orderRepository.findByIdForUpdate(orderId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 주문을 찾을 수 없습니다.")); + if (order.getStatus() == GifticonOrderStatus.REQUESTED) { + return; + } + if (order.getStatus() != GifticonOrderStatus.PROCESSING) { + throw new IllegalStateException("발송 처리 중인 기프티콘 주문이 아닙니다."); + } + + order.markRequested(requestedAt); + GifticonPayment payment = order.getPayment(); + if (payment.getPurpose() != GifticonPaymentPurpose.CHAT + || payment.getChatMessage() != null) { + return; + } + + User sender = userRepository.findById(payment.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 발신자를 찾을 수 없습니다.")); + Profile senderProfile = profileRepository.findByUser(sender) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 발신자 프로필을 찾을 수 없습니다.")); + String content = payment.getProduct().getProductName() + " 기프티콘을 보냈습니다."; + ChatMessage chatMessage = chatMessageRepository.save(ChatMessage.builder() + .room(payment.getChatRoom()) + .type(ChatMessageType.GIFTICON) + .content(content) + .user(sender) + .build()); + payment.attachToChatMessage(chatMessage); + + eventPublisher.publishEvent(new ChatGifticonMessageCreatedEvent( + payment.getChatRoom().getId(), + chatMessage.getId(), + payment.getUserId(), + payment.getReceiverUserId(), + senderProfile.getNickName(), + content, + chatMessage.getCreatedAt(), + ChatGifticonInfo.from(payment) + )); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessor.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessor.java new file mode 100644 index 0000000..c3b7e88 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessor.java @@ -0,0 +1,74 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.port.GifticonOrderRequester; +import org.springframework.stereotype.Service; + +import java.time.Clock; +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GifticonOrderProcessor { + + private final GifticonOrderAttemptService attemptService; + private final GifticonOrderCompletionService completionService; + private final GifticonOrderRequester gifticonOrderRequester; + private final GifticonPaymentService paymentService; + private final Clock clock = Clock.systemUTC(); + + public void process(Long gifticonOrderId) { + GifticonOrderAttemptService.GifticonOrderAttempt attempt = + attemptService.prepare(gifticonOrderId); + if (attempt == null) { + return; + } + + Instant attemptedAt = clock.instant(); + try { + gifticonOrderRequester.requestGift(attempt.command()); + } catch (Exception e) { + String failureReason = safeFailureReason(e); + GifticonOrderAttemptService.OrderFailure failure = + attemptService.markFailed(gifticonOrderId, attemptedAt, failureReason); + log.error( + "[Gift Biz] 선물 발송 요청 실패. gifticonOrderId={}, externalOrderId={}", + gifticonOrderId, + attempt.externalOrderId(), + e + ); + if (failure.chatPaymentId() != null && failure.attemptsExhausted()) { + paymentService.failChatDeliveryAndRefund( + failure.chatPaymentId(), + failureReason + ); + } + return; + } + + try { + completionService.completeRequested(gifticonOrderId, attemptedAt); + log.info( + "[Gift Biz] 선물 발송 요청 접수 완료. gifticonOrderId={}, externalOrderId={}", + gifticonOrderId, + attempt.externalOrderId() + ); + } catch (RuntimeException e) { + attemptService.markFailed(gifticonOrderId, attemptedAt, safeFailureReason(e)); + log.error( + "[Gift Biz] 발송 접수 후 내부 상태 반영 실패. gifticonOrderId={}, externalOrderId={}", + gifticonOrderId, + attempt.externalOrderId(), + e + ); + } + } + + private String safeFailureReason(Exception exception) { + String message = exception.getMessage(); + return exception.getClass().getSimpleName() + + (message == null || message.isBlank() ? "" : ": " + message); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderService.java new file mode 100644 index 0000000..4144f56 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderService.java @@ -0,0 +1,157 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.event.GifticonOrderReadyEvent; +import mannabom_server.manabom.domain.gifticon.entity.GifticonOrder; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonOrderRepository; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.messageRequest.entity.MessageRequest; +import mannabom_server.manabom.domain.user.entity.Profile; +import mannabom_server.manabom.domain.user.entity.User; +import mannabom_server.manabom.domain.user.repository.ProfileRepository; +import mannabom_server.manabom.domain.user.repository.UserRepository; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +@Service +@RequiredArgsConstructor +public class GifticonOrderService { + + private final GifticonOrderRepository gifticonOrderRepository; + private final GifticonPaymentRepository gifticonPaymentRepository; + private final ChatMemberRepository chatMemberRepository; + private final UserRepository userRepository; + private final ProfileRepository profileRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public GifticonOrderStatus prepareOrder(MessageRequest messageRequest) { + GifticonProduct product = messageRequest.getGifticonProduct(); + if (product == null) { + throw new IllegalArgumentException("메시지 요청에 기프티콘 상품이 없습니다."); + } + if (!messageRequest.hasPaidGifticonPayment()) { + throw new IllegalStateException("기프티콘 원화 결제가 완료되지 않았습니다."); + } + if (!product.isOrderableAt(java.time.LocalDateTime.now())) { + throw new IllegalStateException("현재 발송할 수 없는 기프티콘 상품입니다."); + } + + Profile senderProfile = profileRepository.findByUser_UserId(messageRequest.getFromUserId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 발신자의 프로필을 찾을 수 없습니다.")); + String senderNickname = senderProfile.getNickName(); + if (senderNickname == null || senderNickname.isBlank()) { + throw new IllegalStateException("기프티콘을 보내려면 프로필 닉네임이 필요합니다."); + } + + User receiver = userRepository.findById(messageRequest.getToUserId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 수신자를 찾을 수 없습니다.")); + String receiverPhone = normalizeKoreanMobile(receiver.getPhoneNum()); + String receiverName = receiver.getUserName(); + if (receiverName == null || receiverName.isBlank()) { + receiverName = "만나봄 회원"; + } + + String externalOrderId = "MESSAGE-GIFT-" + messageRequest.getId(); + String externalKey = externalOrderId + "-" + messageRequest.getToUserId(); + GifticonPayment payment = messageRequest.getGifticonPayment(); + GifticonOrder order = gifticonOrderRepository.save(new GifticonOrder( + payment, + messageRequest, + senderNickname, + receiverPhone, + receiverName, + externalKey, + externalOrderId + )); + eventPublisher.publishEvent(new GifticonOrderReadyEvent(order.getGifticonOrderId())); + return order.getStatus(); + } + + @Transactional + public GifticonOrderStatus prepareChatOrder(Long paymentId) { + GifticonPayment payment = gifticonPaymentRepository.findByIdForUpdate(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + if (payment.getPurpose() != GifticonPaymentPurpose.CHAT) { + throw new IllegalArgumentException("채팅 기프티콘 결제가 아닙니다."); + } + if (payment.getStatus() != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제가 완료되지 않은 채팅 기프티콘입니다."); + } + if (payment.getChatRoom().getChatStatus() != ChatStatus.ENABLED + || !chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + payment.getChatRoom().getId(), + payment.getUserId(), + ChatMemberStatus.ACTIVATE + ) + || !chatMemberRepository.existsByRoomIdAndUser_UserIdAndStatus( + payment.getChatRoom().getId(), + payment.getReceiverUserId(), + ChatMemberStatus.ACTIVATE + )) { + throw new IllegalStateException("결제 후 채팅방 참여 상태가 변경되어 기프티콘을 발송할 수 없습니다."); + } + if (payment.getChatMessage() != null) { + return GifticonOrderStatus.REQUESTED; + } + + GifticonOrder existing = gifticonOrderRepository + .findByPayment_GifticonPaymentId(paymentId) + .orElse(null); + if (existing != null) { + return existing.getStatus(); + } + + Profile senderProfile = profileRepository.findByUser_UserId(payment.getUserId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 발신자의 프로필을 찾을 수 없습니다.")); + String senderNickname = senderProfile.getNickName(); + if (senderNickname == null || senderNickname.isBlank()) { + throw new IllegalStateException("기프티콘을 보내려면 프로필 닉네임이 필요합니다."); + } + + User receiver = userRepository.findById(payment.getReceiverUserId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 수신자를 찾을 수 없습니다.")); + String receiverPhone = normalizeKoreanMobile(receiver.getPhoneNum()); + String receiverName = receiver.getUserName(); + if (receiverName == null || receiverName.isBlank()) { + receiverName = "만나봄 회원"; + } + + String externalOrderId = "CHAT-GIFT-" + payment.getGifticonPaymentId(); + String externalKey = externalOrderId + "-" + payment.getReceiverUserId(); + GifticonOrder order = gifticonOrderRepository.save(new GifticonOrder( + payment, + null, + senderNickname, + receiverPhone, + receiverName, + externalKey, + externalOrderId + )); + eventPublisher.publishEvent(new GifticonOrderReadyEvent(order.getGifticonOrderId())); + return order.getStatus(); + } + + private String normalizeKoreanMobile(String phoneNumber) { + if (phoneNumber == null || phoneNumber.isBlank()) { + throw new IllegalStateException("기프티콘을 받으려면 휴대폰 번호 등록이 필요합니다."); + } + String digits = phoneNumber.replaceAll("[^0-9]", ""); + if (digits.startsWith("82")) { + digits = "0" + digits.substring(2); + } + if (!digits.matches("01[016789][0-9]{7,8}")) { + throw new IllegalStateException("등록된 휴대폰 번호 형식이 올바르지 않습니다."); + } + return digits; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentService.java new file mode 100644 index 0000000..6d4c313 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentService.java @@ -0,0 +1,422 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.dto.request.ConfirmGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.request.PrepareChatGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.request.PrepareGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonPaymentPrepareResponse; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonPaymentResponse; +import mannabom_server.manabom.application.gifticon.event.GifticonPaymentRefundRequestedEvent; +import mannabom_server.manabom.application.gifticon.event.ChatGifticonDeliveryFailedEvent; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.CancelPaymentCommand; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.ConfirmPaymentCommand; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.PaymentResult; +import mannabom_server.manabom.domain.chat.entity.ChatMember; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.chat.enums.ChatMemberStatus; +import mannabom_server.manabom.domain.chat.enums.ChatRoomType; +import mannabom_server.manabom.domain.chat.enums.ChatStatus; +import mannabom_server.manabom.domain.chat.repository.ChatMemberRepository; +import mannabom_server.manabom.domain.chat.repository.ChatRoomRepository; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import mannabom_server.manabom.application.messageRequest.service.MessageRequestService; +import org.springframework.dao.TransientDataAccessException; +import org.springframework.stereotype.Service; +import org.springframework.context.ApplicationEventPublisher; +import org.springframework.transaction.annotation.Transactional; +import org.springframework.transaction.CannotCreateTransactionException; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.Instant; +import java.util.List; +import java.util.UUID; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GifticonPaymentService { + + private static final String CONFIRM_STATUS = "DONE"; + private static final String CANCEL_STATUS = "CANCELED"; + private static final String REJECTED_MESSAGE_REFUND_REASON = "메시지 요청 거절"; + private static final String UNUSED_PAYMENT_REFUND_REASON = "미사용 기프티콘 결제 취소"; + + private final GifticonPaymentRepository paymentRepository; + private final GifticonProductRepository productRepository; + private final GifticonPaymentStateService stateService; + private final GifticonPaymentGateway paymentGateway; + private final TossPaymentsProperties properties; + private final ApplicationEventPublisher eventPublisher; + private final MessageRequestService messageRequestService; + private final GifticonOrderService gifticonOrderService; + private final ChatRoomRepository chatRoomRepository; + private final ChatMemberRepository chatMemberRepository; + + @Transactional + public GifticonPaymentPrepareResponse prepare( + Long userId, + PrepareGifticonPaymentRequest request + ) { + if (userId == null) { + throw new IllegalArgumentException("결제 사용자 정보가 필요합니다."); + } + if (!StringUtils.hasText(properties.getClientKey())) { + throw new IllegalStateException("TOSS_PAYMENTS_CLIENT_KEY 설정이 필요합니다."); + } + + messageRequestService.validateMessageIntent( + userId, + request.targetProfileId(), + request.message(), + request.source() + ); + + GifticonProduct product = productRepository.findById(request.gifticonProductId()) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 상품을 찾을 수 없습니다.")); + if (!product.isOrderableAt(LocalDateTime.now())) { + throw new IllegalStateException("현재 결제할 수 없는 기프티콘 상품입니다."); + } + + GifticonPayment payment = paymentRepository.save(new GifticonPayment( + userId, + product, + createOrderId(), + createCustomerKey(), + request.targetProfileId(), + request.message(), + request.source() + )); + return new GifticonPaymentPrepareResponse( + payment.getGifticonPaymentId(), + payment.getOrderId(), + payment.getCustomerKey(), + orderName(product), + payment.getAmount(), + properties.getClientKey().trim() + ); + } + + @Transactional + public GifticonPaymentPrepareResponse prepareChat( + Long userId, + PrepareChatGifticonPaymentRequest request + ) { + validatePrepareConfiguration(userId); + ChatRoom room = chatRoomRepository.findById(request.roomId()) + .orElseThrow(() -> new IllegalArgumentException("채팅방을 찾을 수 없습니다.")); + if (room.getChatStatus() != ChatStatus.ENABLED + || (room.getType() != ChatRoomType.PROFILE_MATCH + && room.getType() != ChatRoomType.LOVEVIEW_MATCH)) { + throw new IllegalStateException("활성화된 1대1 채팅방에서만 기프티콘을 보낼 수 있습니다."); + } + + List members = chatMemberRepository.findAllByRoomIdAndStatus( + room.getId(), + ChatMemberStatus.ACTIVATE + ); + boolean senderParticipates = members.stream() + .anyMatch(member -> member.getUser().getUserId().equals(userId)); + if (!senderParticipates || members.size() != 2) { + throw new IllegalStateException("활성 멤버가 2명인 1대1 채팅방에서만 기프티콘을 보낼 수 있습니다."); + } + Long receiverUserId = members.stream() + .map(member -> member.getUser().getUserId()) + .filter(memberUserId -> !memberUserId.equals(userId)) + .findFirst() + .orElseThrow(() -> new IllegalStateException("기프티콘 수신자를 찾을 수 없습니다.")); + + GifticonProduct product = findOrderableProduct(request.gifticonProductId()); + GifticonPayment payment = paymentRepository.save(GifticonPayment.createForChat( + userId, + product, + createOrderId(), + createCustomerKey(), + room, + receiverUserId + )); + return prepareResponse(payment, product); + } + + public GifticonPaymentResponse confirm( + Long userId, + ConfirmGifticonPaymentRequest request + ) { + GifticonPaymentStateService.ConfirmationAttempt attempt = + stateService.startConfirmation( + userId, + request.orderId(), + request.paymentKey(), + request.amount() + ); + if (!attempt.alreadyPaid()) { + try { + PaymentResult result = paymentGateway.confirm(new ConfirmPaymentCommand( + attempt.paymentKey(), + attempt.orderId(), + attempt.amount(), + "GIFTICON_CONFIRM_" + attempt.paymentId() + )); + validateConfirmation(attempt, result); + stateService.completeConfirmation(attempt.paymentId()); + } catch (RuntimeException e) { + stateService.failConfirmation(attempt.paymentId(), safeFailureReason(e)); + throw e; + } + } + + fulfillPaidPayment(attempt.paymentId()); + return getPayment(userId, attempt.paymentId()); + } + + private void fulfillPaidPayment(Long paymentId) { + GifticonPayment payment = paymentRepository.findByIdWithMessageRequest(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + if (payment.getPurpose() == GifticonPaymentPurpose.CHAT) { + try { + gifticonOrderService.prepareChatOrder(paymentId); + } catch (RuntimeException e) { + failChatDeliveryAndRefund(paymentId, safeFailureReason(e)); + throw e; + } + return; + } + createMessageForPaidPayment(paymentId); + } + + public void createMessageForPaidPayment(Long paymentId) { + if (!stateService.startMessageCreation(paymentId)) { + return; + } + + createMessageAfterStarted(paymentId); + } + + public void retryMessageForAdmin(Long paymentId) { + stateService.startMessageCreationForAdmin(paymentId); + createMessageAfterStarted(paymentId); + } + + private void createMessageAfterStarted(Long paymentId) { + try { + messageRequestService.sendPaidGifticonMessage(paymentId); + } catch (RuntimeException e) { + GifticonMessageCreationStatus messageStatus = + stateService.failMessageCreation( + paymentId, + safeFailureReason(e), + isRetryableMessageCreationFailure(e) + ); + log.error( + "결제 완료 후 기프티콘 메시지 생성 실패. paymentId={}, messageStatus={}", + paymentId, + messageStatus, + e + ); + if (messageStatus == GifticonMessageCreationStatus.FAILED) { + refund(paymentId); + } + } + } + + @Transactional(readOnly = true) + public GifticonPaymentResponse getPayment(Long userId, Long paymentId) { + GifticonPayment payment = paymentRepository.findByIdWithMessageRequest(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + if (userId == null || !userId.equals(payment.getUserId())) { + throw new IllegalArgumentException("본인의 기프티콘 결제만 조회할 수 있습니다."); + } + return GifticonPaymentResponse.from(payment); + } + + @Transactional + public GifticonPaymentResponse cancelUnusedPayment(Long userId, Long paymentId) { + GifticonPayment payment = paymentRepository.findByIdForUpdate(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + validateOwner(payment, userId); + if (payment.getMessageRequest() != null + || payment.getChatMessage() != null + || payment.getGifticonOrder() != null) { + throw new IllegalStateException("이미 발송 처리에 사용된 기프티콘 결제입니다."); + } + + requestRefundIfPaid(payment); + return GifticonPaymentResponse.from(payment); + } + + @Transactional + public void requestExpiredUnusedPaymentRefund( + Long paymentId, + Instant approvedBefore + ) { + GifticonPayment payment = paymentRepository.findByIdForUpdate(paymentId) + .orElse(null); + if (payment == null + || payment.getMessageRequest() != null + || payment.getPurpose() != GifticonPaymentPurpose.MESSAGE_REQUEST + || payment.getStatus() != GifticonPaymentStatus.PAID + || payment.getApprovedAt() == null + || payment.getApprovedAt().isAfter(approvedBefore)) { + return; + } + + requestRefundIfPaid(payment); + } + + public void refund(Long paymentId) { + executeRefund(stateService.startRefund(paymentId)); + } + + public void refundForAdmin(Long paymentId) { + executeRefund(stateService.startRefundForAdmin(paymentId)); + } + + public void failChatDeliveryAndRefund(Long paymentId, String reason) { + stateService.failChatDelivery(paymentId, reason); + refund(paymentId); + GifticonPayment payment = paymentRepository.findByIdWithMessageRequest(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + eventPublisher.publishEvent(new ChatGifticonDeliveryFailedEvent( + payment.getUserId(), + paymentId, + payment.getChatRoom().getId(), + payment.getStatus() + )); + } + + private void executeRefund(GifticonPaymentStateService.RefundAttempt attempt) { + if (attempt == null) { + return; + } + + try { + PaymentResult result = paymentGateway.cancel(new CancelPaymentCommand( + attempt.paymentKey(), + attempt.unusedPayment() + ? UNUSED_PAYMENT_REFUND_REASON + : REJECTED_MESSAGE_REFUND_REASON, + "GIFTICON_REFUND_" + attempt.paymentId() + )); + if (!CANCEL_STATUS.equals(result.status())) { + throw new IllegalStateException( + "토스 결제가 전액 취소 상태가 아닙니다: " + result.status() + ); + } + stateService.completeRefund(attempt.paymentId()); + } catch (RuntimeException e) { + stateService.failRefund(attempt.paymentId(), safeFailureReason(e)); + log.error( + "토스 기프티콘 결제 환불 실패. paymentId={}, orderId={}", + attempt.paymentId(), + attempt.orderId(), + e + ); + } + } + + private void validateConfirmation( + GifticonPaymentStateService.ConfirmationAttempt attempt, + PaymentResult result + ) { + if (!attempt.paymentKey().equals(result.paymentKey()) + || !attempt.orderId().equals(result.orderId()) + || attempt.amount() != result.totalAmount() + || !CONFIRM_STATUS.equals(result.status())) { + throw new IllegalStateException("토스 결제 승인 결과가 서버 주문 정보와 일치하지 않습니다."); + } + } + + private void requestRefundIfPaid(GifticonPayment payment) { + if (payment.getStatus() == GifticonPaymentStatus.PAID) { + payment.requestRefund(); + eventPublisher.publishEvent( + new GifticonPaymentRefundRequestedEvent(payment.getGifticonPaymentId()) + ); + return; + } + if (payment.getStatus() != GifticonPaymentStatus.REFUND_PENDING + && payment.getStatus() != GifticonPaymentStatus.REFUND_PROCESSING + && payment.getStatus() != GifticonPaymentStatus.REFUND_FAILED + && payment.getStatus() != GifticonPaymentStatus.REFUNDED) { + throw new IllegalStateException("결제가 완료된 미사용 기프티콘만 취소할 수 있습니다."); + } + } + + private void validateOwner(GifticonPayment payment, Long userId) { + if (userId == null || !userId.equals(payment.getUserId())) { + throw new IllegalArgumentException("본인의 기프티콘 결제만 취소할 수 있습니다."); + } + } + + private String createOrderId() { + return "GIFTICON_" + UUID.randomUUID().toString().replace("-", ""); + } + + private String createCustomerKey() { + return "CUSTOMER_" + UUID.randomUUID().toString().replace("-", ""); + } + + private String orderName(GifticonProduct product) { + String name = product.getProductName() + " 기프티콘"; + return name.length() <= 100 ? name : name.substring(0, 100); + } + + private void validatePrepareConfiguration(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("결제 사용자 정보가 필요합니다."); + } + if (!StringUtils.hasText(properties.getClientKey())) { + throw new IllegalStateException("TOSS_PAYMENTS_CLIENT_KEY 설정이 필요합니다."); + } + } + + private GifticonProduct findOrderableProduct(Long productId) { + GifticonProduct product = productRepository.findById(productId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 상품을 찾을 수 없습니다.")); + if (!product.isOrderableAt(LocalDateTime.now())) { + throw new IllegalStateException("현재 결제할 수 없는 기프티콘 상품입니다."); + } + return product; + } + + private GifticonPaymentPrepareResponse prepareResponse( + GifticonPayment payment, + GifticonProduct product + ) { + return new GifticonPaymentPrepareResponse( + payment.getGifticonPaymentId(), + payment.getOrderId(), + payment.getCustomerKey(), + orderName(product), + payment.getAmount(), + properties.getClientKey().trim() + ); + } + + private String safeFailureReason(RuntimeException exception) { + String message = exception.getMessage(); + return exception.getClass().getSimpleName() + + (message == null || message.isBlank() ? "" : ": " + message); + } + + private boolean isRetryableMessageCreationFailure(Throwable exception) { + Throwable current = exception; + while (current != null) { + if (current instanceof TransientDataAccessException + || current instanceof CannotCreateTransactionException) { + return true; + } + current = current.getCause(); + } + return false; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentStateService.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentStateService.java new file mode 100644 index 0000000..e0b5f58 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonPaymentStateService.java @@ -0,0 +1,243 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Propagation; +import org.springframework.transaction.annotation.Transactional; + +import java.time.Instant; + +@Service +@RequiredArgsConstructor +public class GifticonPaymentStateService { + + private final GifticonPaymentRepository paymentRepository; + private final TossPaymentsProperties properties; + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public ConfirmationAttempt startConfirmation( + Long userId, + String orderId, + String paymentKey, + int amount + ) { + GifticonPayment payment = paymentRepository.findByOrderIdForUpdate(orderId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제 주문을 찾을 수 없습니다.")); + validateOwner(payment, userId); + if (payment.getAmount() != amount) { + throw new IllegalArgumentException("결제 금액이 서버 주문 금액과 일치하지 않습니다."); + } + if (payment.getStatus() == GifticonPaymentStatus.PAID) { + if (!paymentKey.equals(payment.getPaymentKey())) { + throw new IllegalStateException("이미 다른 결제 키로 승인된 주문입니다."); + } + return ConfirmationAttempt.alreadyPaid(payment); + } + + Instant now = Instant.now(); + payment.startConfirmation( + paymentKey, + now, + now.minusSeconds(Math.max(60L, properties.getRequestTimeoutSeconds() * 3L)) + ); + return ConfirmationAttempt.pending(payment); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public GifticonPayment completeConfirmation(Long paymentId) { + GifticonPayment payment = findForUpdate(paymentId); + payment.markPaid(Instant.now()); + return payment; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failConfirmation(Long paymentId, String reason) { + findForUpdate(paymentId).markConfirmationFailed(reason); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public boolean startMessageCreation(Long paymentId) { + GifticonPayment payment = findForUpdate(paymentId); + int maxAttempts = Math.max( + 1, + properties.getMessageCreationRetry().getMaxAttempts() + ); + Instant now = Instant.now(); + if (!payment.canStartMessageCreation( + maxAttempts, + now.minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )) + )) { + return false; + } + payment.startMessageCreation(now); + return true; + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void startMessageCreationForAdmin(Long paymentId) { + GifticonPayment payment = findForUpdate(paymentId); + Instant now = Instant.now(); + if (payment.getPurpose() != GifticonPaymentPurpose.MESSAGE_REQUEST + || payment.getStatus() != GifticonPaymentStatus.PAID + || payment.getMessageRequest() != null) { + throw new IllegalStateException("메시지가 없는 결제 완료 건만 재시도할 수 있습니다."); + } + if (payment.getMessageCreationStatus() == GifticonMessageCreationStatus.PROCESSING + && payment.getLastMessageCreationAttemptAt() != null + && !payment.getLastMessageCreationAttemptAt().isBefore( + now.minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )) + )) { + throw new IllegalStateException("메시지 생성 처리가 진행 중입니다."); + } + payment.startMessageCreation(now); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public GifticonMessageCreationStatus failMessageCreation( + Long paymentId, + String reason, + boolean retryable + ) { + GifticonPayment payment = findForUpdate(paymentId); + payment.markMessageCreationFailed( + reason, + retryable, + Math.max(1, properties.getMessageCreationRetry().getMaxAttempts()) + ); + if (payment.getMessageCreationStatus() == GifticonMessageCreationStatus.FAILED) { + payment.requestRefund(); + } + return payment.getMessageCreationStatus(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failChatDelivery(Long paymentId, String reason) { + GifticonPayment payment = findForUpdate(paymentId); + payment.markChatDeliveryFailed(reason); + payment.requestRefund(); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public RefundAttempt startRefund(Long paymentId) { + GifticonPayment payment = findForUpdate(paymentId); + Instant now = Instant.now(); + int maxAttempts = properties.getRefundRetry().getMaxAttempts(); + if (!payment.canStartRefund( + maxAttempts, + now.minusSeconds(Math.max(60L, properties.getRequestTimeoutSeconds() * 3L)) + )) { + return null; + } + payment.startRefund(now); + return new RefundAttempt( + payment.getGifticonPaymentId(), + payment.getPaymentKey(), + payment.getOrderId(), + payment.getMessageRequest() == null && payment.getChatMessage() == null + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public RefundAttempt startRefundForAdmin(Long paymentId) { + GifticonPayment payment = findForUpdate(paymentId); + if (payment.getStatus() == GifticonPaymentStatus.PAID) { + if (payment.getMessageRequest() != null || payment.getChatMessage() != null) { + throw new IllegalStateException("수신자에게 공개된 결제는 강제 환불할 수 없습니다."); + } + if (payment.getGifticonOrder() != null + && payment.getGifticonOrder().getStatus() != GifticonOrderStatus.FAILED) { + throw new IllegalStateException("기프티콘 발송 처리가 진행 중인 결제는 강제 환불할 수 없습니다."); + } + payment.requestRefund(); + } + + Instant now = Instant.now(); + if (!payment.canStartRefund( + Integer.MAX_VALUE, + now.minusSeconds(Math.max( + 60L, + properties.getRequestTimeoutSeconds() * 3L + )) + )) { + throw new IllegalStateException("현재 상태에서는 강제 환불을 시작할 수 없습니다."); + } + payment.startRefund(now); + return new RefundAttempt( + payment.getGifticonPaymentId(), + payment.getPaymentKey(), + payment.getOrderId(), + payment.getMessageRequest() == null && payment.getChatMessage() == null + ); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void completeRefund(Long paymentId) { + findForUpdate(paymentId).markRefunded(Instant.now()); + } + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void failRefund(Long paymentId, String reason) { + findForUpdate(paymentId).markRefundFailed(reason); + } + + private GifticonPayment findForUpdate(Long paymentId) { + return paymentRepository.findByIdForUpdate(paymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + } + + private void validateOwner(GifticonPayment payment, Long userId) { + if (userId == null || !userId.equals(payment.getUserId())) { + throw new IllegalArgumentException("본인의 기프티콘 결제만 처리할 수 있습니다."); + } + } + + public record ConfirmationAttempt( + Long paymentId, + String paymentKey, + String orderId, + int amount, + boolean alreadyPaid + ) { + static ConfirmationAttempt pending(GifticonPayment payment) { + return from(payment, false); + } + + static ConfirmationAttempt alreadyPaid(GifticonPayment payment) { + return from(payment, true); + } + + private static ConfirmationAttempt from( + GifticonPayment payment, + boolean alreadyPaid + ) { + return new ConfirmationAttempt( + payment.getGifticonPaymentId(), + payment.getPaymentKey(), + payment.getOrderId(), + payment.getAmount(), + alreadyPaid + ); + } + } + + public record RefundAttempt( + Long paymentId, + String paymentKey, + String orderId, + boolean unusedPayment + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java b/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java index fc35569..1967a4b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/like/service/LikeService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import mannabom_server.manabom.application.chat.service.ChatRoomService; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; import mannabom_server.manabom.application.currency.service.TingWalletService; import mannabom_server.manabom.application.like.dto.response.SendLikeResponseDto; import mannabom_server.manabom.application.currency.dto.response.CheckTingWalletResponseDto; @@ -10,6 +11,8 @@ import mannabom_server.manabom.application.pushService.service.pushSender.PushService; import mannabom_server.manabom.application.signal.dto.response.RespondSignalResponseDto; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.likeRequest.entity.LikeRequest; import mannabom_server.manabom.domain.likeRequest.enums.LikeSource; @@ -41,6 +44,7 @@ public class LikeService { private final ProfileRecommendHistoryRepository profileRecommendHistoryRepository; private final LoveViewRecommendHistoryRepository loveViewRecommendHistoryRepository; private final ChatRoomService chatRoomService; + private final TingTransactionRecorder tingTransactionRecorder; @Transactional public SendLikeResponseDto sendLike(Long fromUserId, Long toProfileId, LikeSource source){ @@ -63,6 +67,9 @@ public SendLikeResponseDto sendLike(Long fromUserId, Long toProfileId, LikeSourc .ifPresent(existing -> { throw new IllegalStateException("이미 요청을 보냈습니다."); }); + LikeRequest likeRequest = likeRequestRepository.save( + new LikeRequest(fromUserId, toUserId, source) + ); int vipLikeRemains = 0; int membershipLikeRemains = 0; @@ -85,19 +92,34 @@ public SendLikeResponseDto sendLike(Long fromUserId, Long toProfileId, LikeSourc if(tingWallet.getEventTing() >= likeCost) { tingWallet.spendEventTing(likeCost); + tingTransactionRecorder.recordEvent( + tingWallet, + TingTransactionType.LIKE_REQUEST, + -likeCost, + TingTransactionReferenceType.LIKE_REQUEST, + String.valueOf(likeRequest.getId()), + "LIKE_REQUEST:" + likeRequest.getId() + ":EVENT_COST", + "호감 요청 비용" + ); } else if (vipLikeRemains > 0){ tingWallet.consumeVipFreeLike(today); } else if (membershipLikeRemains > 0) { tingWallet.consumeMembershipFreeLike(now); } else if (tingWallet.getTing() >= likeCost) { tingWallet.spendTing(likeCost); + tingTransactionRecorder.recordPaid( + tingWallet, + TingTransactionType.LIKE_REQUEST, + -likeCost, + TingTransactionReferenceType.LIKE_REQUEST, + String.valueOf(likeRequest.getId()), + "LIKE_REQUEST:" + likeRequest.getId() + ":PAID_COST", + "호감 요청 비용" + ); } else { throw new IllegalStateException("보유 재화가 부족합니다.(팅, 아밴트 팅, 맴버쉽, vip 혜택권 등)"); } - LikeRequest likeRequest = new LikeRequest(fromUserId, toUserId, source); - likeRequestRepository.save(likeRequest); - try { pushService.sendToUser(toUserId, PushMessages.likeReceived(fromUserId)); } catch (Exception e) { diff --git a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/dto/request/SendMessageRequestDto.java b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/dto/request/SendMessageRequestDto.java index 07f51e4..699308b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/dto/request/SendMessageRequestDto.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/dto/request/SendMessageRequestDto.java @@ -1,6 +1,7 @@ package mannabom_server.manabom.application.messageRequest.dto.request; import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; import lombok.Getter; import lombok.NoArgsConstructor; import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; @@ -14,5 +15,6 @@ public class SendMessageRequestDto { @NotNull(message = "source는 필수입니다.") private MessageSource source; + @Size(max = 200, message = "message는 200자를 초과할 수 없습니다.") private String message; } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java index 23b4b47..ba6a242 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java @@ -5,12 +5,22 @@ import mannabom_server.manabom.application.chat.service.ChatRoomService; import mannabom_server.manabom.application.currency.dto.response.CheckTingWalletResponseDto; import mannabom_server.manabom.application.currency.service.TingWalletService; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; +import mannabom_server.manabom.application.gifticon.service.GifticonOrderService; +import mannabom_server.manabom.application.gifticon.event.GifticonPaymentRefundRequestedEvent; import mannabom_server.manabom.application.messageRequest.dto.response.SendMessageResponseDto; import mannabom_server.manabom.application.pushService.PushMessages; import mannabom_server.manabom.application.pushService.service.pushSender.PushService; import mannabom_server.manabom.application.signal.dto.response.RespondSignalResponseDto; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonPaymentRepository; import mannabom_server.manabom.domain.matching.entity.LoveViewRecommendHistory; import mannabom_server.manabom.domain.matching.entity.ProfileRecommendHistory; import mannabom_server.manabom.domain.matching.repository.LoveViewRecommendHistoryRepository; @@ -24,6 +34,7 @@ import mannabom_server.manabom.policy.service.RuntimePolicyService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import org.springframework.context.ApplicationEventPublisher; import java.time.LocalDate; import java.time.LocalDateTime; @@ -41,17 +52,80 @@ public class MessageRequestService { private final ProfileRecommendHistoryRepository profileRecommendHistoryRepository; private final LoveViewRecommendHistoryRepository loveViewRecommendHistoryRepository; private final ChatRoomService chatRoomService; + private final GifticonPaymentRepository gifticonPaymentRepository; + private final GifticonOrderService gifticonOrderService; + private final TingTransactionRecorder tingTransactionRecorder; + private final ApplicationEventPublisher eventPublisher; + + @Transactional(readOnly = true) + public void validateMessageIntent( + Long fromUserId, + Long toProfileId, + String message, + MessageSource source + ) { + Long toUserId = validateMessageRequestInput( + fromUserId, + toProfileId, + message, + source + ); + ensureMessageRequestDoesNotExist(fromUserId, toUserId); + } @Transactional - public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfileId, String message, MessageSource source) { - if(fromUserId == null) throw new IllegalArgumentException("요청자의 정보를 찾을 수 없습니다."); - if(toProfileId == null) throw new IllegalArgumentException("toProfileId가 비어있습니다."); + public Long sendPaidGifticonMessage(Long gifticonPaymentId) { + GifticonPayment payment = gifticonPaymentRepository + .findByIdForUpdate(gifticonPaymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + if (payment.getMessageRequest() != null) { + return payment.getMessageRequest().getId(); + } + if (payment.getStatus() != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제가 완료되지 않은 기프티콘입니다."); + } - Profile toProfile = profileRepository.findById(toProfileId) - .orElseThrow(() -> new IllegalArgumentException("대상자의 프로필을 찾을 수 없습니다.")); - Long toUserId = toProfile.getUser().getUserId(); + createMessageRequest( + payment.getUserId(), + payment.getTargetProfileId(), + payment.getMessage(), + payment.getMessageSource(), + payment.getGifticonPaymentId() + ); + return payment.getMessageRequest().getId(); + } + + @Transactional + public SendMessageResponseDto sendMessageRequest( + Long fromUserId, + Long toProfileId, + String message, + MessageSource source + ) { + return createMessageRequest(fromUserId, toProfileId, message, source, null); + } - if(fromUserId.equals(toUserId)) throw new IllegalArgumentException("본인에게 메시지 요청을 보낼 수 없습니다."); + private SendMessageResponseDto createMessageRequest( + Long fromUserId, + Long toProfileId, + String message, + MessageSource source, + Long gifticonPaymentId + ) { + Long toUserId = validateMessageRequestInput( + fromUserId, + toProfileId, + message, + source + ); + GifticonPayment gifticonPayment = findPaidGifticonPayment( + fromUserId, + gifticonPaymentId + ); + GifticonProduct gifticonProduct = gifticonPayment == null + ? null + : gifticonPayment.getProduct(); + ensureMessageRequestDoesNotExist(fromUserId, toUserId); RuntimePolicySnapshot p = runtimePolicyService.snapshot(); LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); @@ -59,10 +133,16 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile TingWallet tingWallet = tingWalletRepository.findByUserIdForUpdate(fromUserId) .orElseGet(() -> tingWalletRepository.save(new TingWallet(fromUserId))); - messageRequestRepository.findByFromUserIdAndToUserId(fromUserId, toUserId) - .ifPresent(existing -> { - throw new IllegalStateException("이미 요청을 보냈습니다."); - }); + MessageRequest messageRequest = messageRequestRepository.save(new MessageRequest( + fromUserId, + toUserId, + message, + source, + gifticonProduct + )); + if (gifticonPayment != null) { + gifticonPayment.attachTo(messageRequest); + } int vipMessageRemains = 0; int membershipMessageRemains = 0; @@ -85,19 +165,34 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile if(tingWallet.getEventTing() >= messageCost) { tingWallet.spendEventTing(messageCost); + tingTransactionRecorder.recordEvent( + tingWallet, + TingTransactionType.MESSAGE_REQUEST, + -messageCost, + TingTransactionReferenceType.MESSAGE_REQUEST, + String.valueOf(messageRequest.getId()), + "MESSAGE_REQUEST:" + messageRequest.getId() + ":EVENT_COST", + "메시지 요청 비용" + ); } else if (vipMessageRemains > 0){ tingWallet.consumeVipFreeMessage(today); } else if (membershipMessageRemains > 0) { tingWallet.consumeMembershipFreeMessage(now); } else if (tingWallet.getTing() >= messageCost) { tingWallet.spendTing(messageCost); + tingTransactionRecorder.recordPaid( + tingWallet, + TingTransactionType.MESSAGE_REQUEST, + -messageCost, + TingTransactionReferenceType.MESSAGE_REQUEST, + String.valueOf(messageRequest.getId()), + "MESSAGE_REQUEST:" + messageRequest.getId() + ":PAID_COST", + "메시지 요청 비용" + ); } else { throw new IllegalStateException("보유 재화가 부족합니다.(팅, 아밴트 팅, 맴버쉽, vip 혜택권 등)"); } - MessageRequest messageRequest = new MessageRequest(fromUserId, toUserId, message, source); - messageRequestRepository.save(messageRequest); - try { pushService.sendToUser(toUserId, PushMessages.messageRequestReceived(fromUserId, message)); } catch (Exception e) { @@ -118,6 +213,66 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile } + private GifticonPayment findPaidGifticonPayment( + Long userId, + Long gifticonPaymentId + ) { + if (gifticonPaymentId == null) { + return null; + } + GifticonPayment payment = gifticonPaymentRepository + .findByIdForUpdate(gifticonPaymentId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 결제를 찾을 수 없습니다.")); + if (!userId.equals(payment.getUserId())) { + throw new IllegalArgumentException("본인의 기프티콘 결제만 사용할 수 있습니다."); + } + if (payment.getStatus() != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제가 완료되지 않은 기프티콘입니다."); + } + if (payment.getMessageRequest() != null) { + throw new IllegalStateException("이미 메시지 요청에 사용된 기프티콘 결제입니다."); + } + if (!payment.getProduct().isOrderableAt(LocalDateTime.now())) { + throw new IllegalStateException("현재 선택할 수 없는 기프티콘 상품입니다."); + } + return payment; + } + + private Long validateMessageRequestInput( + Long fromUserId, + Long toProfileId, + String message, + MessageSource source + ) { + if (fromUserId == null) { + throw new IllegalArgumentException("요청자의 정보를 찾을 수 없습니다."); + } + if (toProfileId == null) { + throw new IllegalArgumentException("toProfileId가 비어있습니다."); + } + if (source == null) { + throw new IllegalArgumentException("source가 비어있습니다."); + } + if (message != null && message.length() > 200) { + throw new IllegalArgumentException("메시지는 200자를 초과할 수 없습니다."); + } + + Profile toProfile = profileRepository.findById(toProfileId) + .orElseThrow(() -> new IllegalArgumentException("대상자의 프로필을 찾을 수 없습니다.")); + Long toUserId = toProfile.getUser().getUserId(); + if (fromUserId.equals(toUserId)) { + throw new IllegalArgumentException("본인에게 메시지 요청을 보낼 수 없습니다."); + } + return toUserId; + } + + private void ensureMessageRequestDoesNotExist(Long fromUserId, Long toUserId) { + messageRequestRepository.findByFromUserIdAndToUserId(fromUserId, toUserId) + .ifPresent(existing -> { + throw new IllegalStateException("이미 요청을 보냈습니다."); + }); + } + @Transactional public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long messageRequestId, boolean accepted, String rejectReason) { if (responderUserId == null) throw new IllegalArgumentException("응답자의 정보를 찾을 수 없습니다."); @@ -130,11 +285,18 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long } Long chatRoomId = null; + GifticonOrderStatus gifticonOrderStatus = null; + String gifticonPaymentStatus = null; if (accepted) { messageRequest.accept(); chatRoomId = createChatRoom(messageRequest.getFromUserId(), messageRequest.getToUserId(), messageRequest.getSource()); + if (messageRequest.getGifticonProduct() != null) { + gifticonPaymentStatus = confirmGiftPaymentForAcceptance(messageRequest); + gifticonOrderStatus = gifticonOrderService.prepareOrder(messageRequest); + } } else { messageRequest.reject(rejectReason); + gifticonPaymentStatus = requestGifticonRefund(messageRequest); } try { @@ -147,9 +309,39 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long .accepted(accepted) .chatRoomId(chatRoomId) .status(messageRequest.getStatus().name()) + .gifticonOrderStatus(gifticonOrderStatus == null ? null : gifticonOrderStatus.name()) + .gifticonPaymentStatus( + gifticonPaymentStatus + ) .build(); } + private String confirmGiftPaymentForAcceptance(MessageRequest messageRequest) { + GifticonPayment payment = gifticonPaymentRepository + .findByMessageRequestIdForUpdate(messageRequest.getId()) + .orElseThrow(() -> new IllegalStateException("기프티콘 결제 정보를 찾을 수 없습니다.")); + if (payment.getStatus() != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("기프티콘 원화 결제가 완료되지 않았습니다."); + } + return payment.getStatus().name(); + } + + private String requestGifticonRefund(MessageRequest messageRequest) { + if (messageRequest.getGifticonProduct() == null) { + return null; + } + GifticonPayment payment = gifticonPaymentRepository + .findByMessageRequestIdForUpdate(messageRequest.getId()) + .orElseThrow(() -> new IllegalStateException( + "메시지 요청의 기프티콘 결제를 찾을 수 없습니다." + )); + payment.requestRefund(); + eventPublisher.publishEvent( + new GifticonPaymentRefundRequestedEvent(payment.getGifticonPaymentId()) + ); + return payment.getStatus().name(); + } + private Long createChatRoom(Long requesterUserId, Long targetUserId, MessageSource source) { if (source == MessageSource.PROFILE_MATCH) { ProfileRecommendHistory history = profileRecommendHistoryRepository diff --git a/manabom/src/main/java/mannabom_server/manabom/application/partner/service/PartnerService.java b/manabom/src/main/java/mannabom_server/manabom/application/partner/service/PartnerService.java index 275bbb2..1e7b289 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/partner/service/PartnerService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/partner/service/PartnerService.java @@ -10,7 +10,11 @@ import mannabom_server.manabom.application.partner.dto.request.UnlockTargetPhotoRequestDto; import mannabom_server.manabom.application.partner.dto.response.*; import mannabom_server.manabom.application.common.port.FileStoragePort; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingBalanceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.likeRequest.entity.LikeRequest; import mannabom_server.manabom.domain.likeRequest.enums.LikeStatus; @@ -57,6 +61,7 @@ public class PartnerService { private final TingWalletRepository tingWalletRepository; private final ProfileRatingRepository profileRatingRepository; private final ProfileScoreViewUnlockRepository profileScoreViewUnlockRepository; + private final TingTransactionRecorder tingTransactionRecorder; @Transactional(readOnly = true) public GetTargetProfileDetailResponseDto getTargetProfileDetail(Long requesterUserId, GetTargetProfileDetailRequestDto request){ @@ -205,13 +210,16 @@ public UnlockTargetPhotoResponseDto unlockTargetPhoto(Long requesterUserId, Unlo } int cost = p.getTing().getCost().getViewExtraPhoto(); - if(tingWallet.getEventTing() >= cost){ - tingWallet.spendEventTing(cost); - } else if (tingWallet.getTing() >= cost) { - tingWallet.spendTing(cost); - } else { - throw new IllegalStateException("이벤트 팅과 팅이 부족합니다."); - } + spendEventFirst( + tingWallet, + cost, + TingTransactionType.EXTRA_PHOTO_UNLOCK, + TingTransactionReferenceType.PROFILE_PHOTO, + targetUserId + ":" + photoId, + "PROFILE_PHOTO:" + requesterUserId + ":" + targetUserId + ":" + photoId, + "상대 프로필 추가 사진 잠금 해제", + "이벤트 팅과 팅이 부족합니다." + ); profileExtraPhotoUnlockRepository.save( new ProfileExtraPhotoUnlock(requesterUserId, targetUserId, photoId) @@ -239,13 +247,16 @@ public void purchaseAdditionalProfileByTing(Long userId, PurchaseAdditionalProfi throw new IllegalArgumentException("추가로 구매하는 프로필의 갯수가 1 또는 5가 아닙니다."); } - if(tingWallet.getEventTing() >= cost){ - tingWallet.spendEventTing(cost); - }else if(tingWallet.getTing() >= cost){ - tingWallet.spendTing(cost); - }else { - throw new IllegalStateException("팅이나 이벤트 팅이 부족합니다."); - } + spendEventFirst( + tingWallet, + cost, + TingTransactionType.EXTRA_PROFILE_PURCHASE, + TingTransactionReferenceType.USER, + String.valueOf(userId), + null, + "추가 프로필 이용권 " + num + "개 구매", + "팅이나 이벤트 팅이 부족합니다." + ); tingWallet.addExtraProfileByTing(num); } @@ -272,13 +283,16 @@ public GetReceivedScoreResponseDto getReceivedScore(Long userId, GetReceivedScor alreadyViewed = profileScoreViewUnlockRepository.existsByRequesterUserIdAndTargetUserId(userId, targetUserId); if(!alreadyViewed){ int cost = p.getTing().getCost().getViewScore(); - if(tingWallet.getEventTing() >= cost){ - tingWallet.spendEventTing(cost); - } else if (tingWallet.getTing() >= cost){ - tingWallet.spendTing(cost); - } else { - throw new IllegalStateException("팅 또는 이벤트 팅이 부족합니다."); - } + spendEventFirst( + tingWallet, + cost, + TingTransactionType.PROFILE_SCORE_UNLOCK, + TingTransactionReferenceType.PROFILE, + String.valueOf(targetProfileId), + "PROFILE_SCORE:" + userId + ":" + targetUserId, + "상대방이 준 프로필 점수 열람", + "팅 또는 이벤트 팅이 부족합니다." + ); profileScoreViewUnlockRepository.save(new ProfileScoreViewUnlock(userId, targetUserId)); } } @@ -300,4 +314,51 @@ public CheckReceivedScoreResponseDto checkReceivedScore(Long userId, Long target return new CheckReceivedScoreResponseDto(false); } } + + private void spendEventFirst( + TingWallet wallet, + int cost, + TingTransactionType transactionType, + TingTransactionReferenceType referenceType, + String referenceId, + String idempotencyKey, + String description, + String insufficientBalanceMessage + ) { + if (wallet.getEventTing() + wallet.getTing() < cost) { + throw new IllegalStateException(insufficientBalanceMessage); + } + + int eventTingToSpend = Math.min(wallet.getEventTing(), cost); + if (eventTingToSpend > 0) { + wallet.spendEventTing(eventTingToSpend); + tingTransactionRecorder.recordEvent( + wallet, + transactionType, + -eventTingToSpend, + referenceType, + referenceId, + balanceIdempotencyKey(idempotencyKey, TingBalanceType.EVENT), + description + ); + } + + int paidTingToSpend = cost - eventTingToSpend; + if (paidTingToSpend > 0) { + wallet.spendTing(paidTingToSpend); + tingTransactionRecorder.recordPaid( + wallet, + transactionType, + -paidTingToSpend, + referenceType, + referenceId, + balanceIdempotencyKey(idempotencyKey, TingBalanceType.PAID), + description + ); + } + } + + private String balanceIdempotencyKey(String idempotencyKey, TingBalanceType balanceType) { + return idempotencyKey == null ? null : idempotencyKey + ":" + balanceType.name(); + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/signal/dto/response/RespondSignalResponseDto.java b/manabom/src/main/java/mannabom_server/manabom/application/signal/dto/response/RespondSignalResponseDto.java index f049b28..7b55b53 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/signal/dto/response/RespondSignalResponseDto.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/signal/dto/response/RespondSignalResponseDto.java @@ -11,4 +11,6 @@ public class RespondSignalResponseDto { private boolean accepted; private Long chatRoomId; private String status; + private String gifticonOrderStatus; + private String gifticonPaymentStatus; } diff --git a/manabom/src/main/java/mannabom_server/manabom/application/signup/service/SignupService.java b/manabom/src/main/java/mannabom_server/manabom/application/signup/service/SignupService.java index 4644144..e5edb3e 100644 --- a/manabom/src/main/java/mannabom_server/manabom/application/signup/service/SignupService.java +++ b/manabom/src/main/java/mannabom_server/manabom/application/signup/service/SignupService.java @@ -5,9 +5,12 @@ import mannabom_server.manabom.application.signup.dto.request.*; import mannabom_server.manabom.application.signup.dto.response.*; import mannabom_server.manabom.application.common.port.FileStoragePort; +import mannabom_server.manabom.application.currency.service.TingTransactionRecorder; import mannabom_server.manabom.domain.auth.entity.RefreshToken; import mannabom_server.manabom.domain.auth.repository.RefreshTokenRepository; import mannabom_server.manabom.domain.currency.entity.TingWallet; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.question.entity.Question; import mannabom_server.manabom.domain.question.entity.QuestionAnswer; @@ -64,6 +67,7 @@ public class SignupService { private final RedisTemplate redisTemplate; private final TingWalletRepository tingWalletRepository; + private final TingTransactionRecorder tingTransactionRecorder; private static final int BONUS_OPTIONAL_TEXT_MALE = 11; private static final int BONUS_OPTIONAL_TEXT_FEMALE = 6; @@ -414,8 +418,21 @@ protected User createUserWithProfile(SignupProgress progress) { if(!tingWalletRepository.existsById(user.getUserId())) { TingWallet wallet = new TingWallet(user.getUserId()); //wallet.addEventTing(initialPoints); (기본 지급을 답변 보상으로 대체) - wallet.addEventTing(signupBonusEventTing); + if (signupBonusEventTing > 0) { + wallet.addEventTing(signupBonusEventTing); + } tingWalletRepository.save(wallet); + if (signupBonusEventTing > 0) { + tingTransactionRecorder.recordEvent( + wallet, + TingTransactionType.SIGNUP_BONUS, + signupBonusEventTing, + TingTransactionReferenceType.USER, + String.valueOf(user.getUserId()), + "SIGNUP:" + user.getUserId() + ":BONUS", + "회원가입 프로필 작성 보너스" + ); + } int savedEventTing = wallet.getEventTing(); log.info("해당 유저 팅 지갑 생성 및 보너스 팅 지급 완료, 지급된 이벤트 팅 : {}", savedEventTing); }else diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java index e3beb8e..401791f 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditActionType.java @@ -12,6 +12,10 @@ public enum AdminAuditActionType { WALLET_ADJUST, MEMBERSHIP_ACTIVATE, POLICY_UPDATE, + GIFTICON_CATALOG_SYNC, + GIFTICON_TEMPLATE_TOKEN_UPDATE, + GIFTICON_PAYMENT_MESSAGE_RETRY, + GIFTICON_PAYMENT_FORCE_REFUND, PUSH_SEND, REPORT_PROCESS } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java index 63e7f62..1637131 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/admin/enums/AdminAuditTargetType.java @@ -4,6 +4,8 @@ public enum AdminAuditTargetType { ADMIN, USER, TING_WALLET, + GIFTICON_PRODUCT, + GIFTICON_PAYMENT, POLICY, PUSH, REPORT diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java index 1a689c5..a28348e 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/entity/ChatMessage.java @@ -8,6 +8,7 @@ import mannabom_server.manabom.domain.chat.enums.ChatMessageType; import mannabom_server.manabom.domain.chat.enums.ChatMessageTypeConverter; import mannabom_server.manabom.domain.common.BaseTimeEntity; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; import mannabom_server.manabom.domain.user.entity.User; /** @@ -37,5 +38,8 @@ public class ChatMessage extends BaseTimeEntity { private String content; + @OneToOne(mappedBy = "chatMessage", fetch = FetchType.LAZY) + private GifticonPayment gifticonPayment; + } diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/enums/ChatMessageType.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/enums/ChatMessageType.java index 39a87fd..09af127 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/enums/ChatMessageType.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/enums/ChatMessageType.java @@ -8,7 +8,8 @@ public enum ChatMessageType { TEXT((short)0), //텍스트 IMAGE((short)1), //이미지 - SYSTEM((short)2); + SYSTEM((short)2), + GIFTICON((short)3); //발송 요청이 접수된 기프티콘 private final short code; @@ -20,6 +21,7 @@ public static ChatMessageType from(short code){ public String getDisplayMessage(String content){ return switch(this){ case IMAGE -> "📷 사진을 보냈습니다."; + case GIFTICON -> "🎁 기프티콘을 보냈습니다."; case TEXT,SYSTEM-> content; }; diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java index 9bcfd17..7a9abcf 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/chat/repository/ChatMessageRepository.java @@ -13,10 +13,29 @@ public interface ChatMessageRepository extends JpaRepository { Optional findTopByRoomIdOrderByIdDesc(Long roomId); - @Query("SELECT m FROM ChatMessage m JOIN FETCH m.user u WHERE m.room.id = :roomId AND m.id > :lastReadId ORDER BY m.id ASC") + @Query(""" + SELECT m + FROM ChatMessage m + JOIN FETCH m.user u + LEFT JOIN FETCH m.gifticonPayment payment + LEFT JOIN FETCH payment.product + WHERE m.room.id = :roomId + AND m.id > :lastReadId + ORDER BY m.id ASC + """) List findChatMessagesAfter(@Param("roomId") Long roomId, @Param("lastReadId") Long lastReadId, Pageable pageable); - @Query("SELECT m FROM ChatMessage m JOIN FETCH m.user u JOIN Profile p on p.user.userId = u.userId WHERE m.room.id = :roomId AND m.id < :firstMessageId ORDER BY m.id desc ") + @Query(""" + SELECT m + FROM ChatMessage m + JOIN FETCH m.user u + JOIN Profile p on p.user.userId = u.userId + LEFT JOIN FETCH m.gifticonPayment payment + LEFT JOIN FETCH payment.product + WHERE m.room.id = :roomId + AND m.id < :firstMessageId + ORDER BY m.id DESC + """) List findChatMessagesBefore(@Param("roomId") Long roomId, @Param("firstMessageId") Long firstMessageId, Pageable pageable); diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/currency/entity/TingTransaction.java b/manabom/src/main/java/mannabom_server/manabom/domain/currency/entity/TingTransaction.java new file mode 100644 index 0000000..0236673 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/currency/entity/TingTransaction.java @@ -0,0 +1,117 @@ +package mannabom_server.manabom.domain.currency.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.PrePersist; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.currency.enums.TingBalanceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionReferenceType; +import mannabom_server.manabom.domain.currency.enums.TingTransactionType; + +import java.time.Instant; + +@Getter +@Entity +@Table(name = "ting_transaction") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TingTransaction { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "ting_transaction_id") + private Long tingTransactionId; + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + @Enumerated(EnumType.STRING) + @Column(name = "balance_type", nullable = false, updatable = false, length = 20) + private TingBalanceType balanceType; + + @Enumerated(EnumType.STRING) + @Column(name = "transaction_type", nullable = false, updatable = false, length = 50) + private TingTransactionType transactionType; + + @Column(name = "amount_delta", nullable = false, updatable = false) + private int amountDelta; + + @Column(name = "balance_after", nullable = false, updatable = false) + private int balanceAfter; + + @Enumerated(EnumType.STRING) + @Column(name = "reference_type", updatable = false, length = 50) + private TingTransactionReferenceType referenceType; + + @Column(name = "reference_id", updatable = false, length = 100) + private String referenceId; + + @Column(name = "idempotency_key", updatable = false, length = 150) + private String idempotencyKey; + + @Column(name = "description", updatable = false, length = 500) + private String description; + + @Column(name = "created_at", nullable = false, updatable = false) + private Instant createdAt; + + public TingTransaction( + Long userId, + TingBalanceType balanceType, + TingTransactionType transactionType, + int amountDelta, + int balanceAfter, + TingTransactionReferenceType referenceType, + String referenceId, + String idempotencyKey, + String description + ) { + if (userId == null) { + throw new IllegalArgumentException("팅 거래 사용자 ID는 필수입니다."); + } + if (balanceType == null || transactionType == null) { + throw new IllegalArgumentException("팅 거래 잔액 유형과 거래 유형은 필수입니다."); + } + if (amountDelta == 0) { + throw new IllegalArgumentException("잔액 변화가 없는 팅 거래는 기록할 수 없습니다."); + } + if (balanceAfter < 0) { + throw new IllegalArgumentException("거래 후 팅 잔액은 음수일 수 없습니다."); + } + + this.userId = userId; + this.balanceType = balanceType; + this.transactionType = transactionType; + this.amountDelta = amountDelta; + this.balanceAfter = balanceAfter; + this.referenceType = referenceType; + this.referenceId = normalize(referenceId, 100, "referenceId"); + this.idempotencyKey = normalize(idempotencyKey, 150, "idempotencyKey"); + this.description = normalize(description, 500, "description"); + } + + @PrePersist + void initializeCreatedAt() { + if (createdAt == null) { + createdAt = Instant.now(); + } + } + + private String normalize(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + return null; + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new IllegalArgumentException(fieldName + "는 " + maxLength + "자를 초과할 수 없습니다."); + } + return trimmed; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingBalanceType.java b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingBalanceType.java new file mode 100644 index 0000000..df4ff4e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingBalanceType.java @@ -0,0 +1,6 @@ +package mannabom_server.manabom.domain.currency.enums; + +public enum TingBalanceType { + PAID, + EVENT +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionReferenceType.java b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionReferenceType.java new file mode 100644 index 0000000..d94ded3 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionReferenceType.java @@ -0,0 +1,11 @@ +package mannabom_server.manabom.domain.currency.enums; + +public enum TingTransactionReferenceType { + USER, + ADMIN, + REPORT, + LIKE_REQUEST, + MESSAGE_REQUEST, + PROFILE, + PROFILE_PHOTO +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionType.java b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionType.java new file mode 100644 index 0000000..df7f64a --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/currency/enums/TingTransactionType.java @@ -0,0 +1,12 @@ +package mannabom_server.manabom.domain.currency.enums; + +public enum TingTransactionType { + SIGNUP_BONUS, + ADMIN_ADJUSTMENT, + REPORT_COMPENSATION, + LIKE_REQUEST, + MESSAGE_REQUEST, + EXTRA_PROFILE_PURCHASE, + EXTRA_PHOTO_UNLOCK, + PROFILE_SCORE_UNLOCK +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/currency/repository/TingTransactionRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/currency/repository/TingTransactionRepository.java new file mode 100644 index 0000000..1627e7c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/currency/repository/TingTransactionRepository.java @@ -0,0 +1,10 @@ +package mannabom_server.manabom.domain.currency.repository; + +import mannabom_server.manabom.domain.currency.entity.TingTransaction; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + +public interface TingTransactionRepository extends JpaRepository { + Optional findByIdempotencyKey(String idempotencyKey); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonOrder.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonOrder.java new file mode 100644 index 0000000..9fa64ed --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonOrder.java @@ -0,0 +1,162 @@ +package mannabom_server.manabom.domain.gifticon.entity; + +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.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.common.BaseTimeEntity; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.messageRequest.entity.MessageRequest; + +import java.time.Instant; + +@Getter +@Entity +@Table(name = "gifticon_order") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GifticonOrder extends BaseTimeEntity { + + private static final int MAX_FAILURE_REASON_LENGTH = 1000; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "gifticon_order_id") + private Long gifticonOrderId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_request_id", unique = true) + private MessageRequest messageRequest; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "gifticon_payment_id", unique = true) + private GifticonPayment payment; + + @Column(name = "sender_nickname", nullable = false) + private String senderNickname; + + @Column(name = "receiver_phone", nullable = false, length = 30) + private String receiverPhone; + + @Column(name = "receiver_name", nullable = false, length = 100) + private String receiverName; + + @Column(name = "external_key", nullable = false, unique = true, length = 70) + private String externalKey; + + @Column(name = "external_order_id", nullable = false, unique = true, length = 70) + private String externalOrderId; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private GifticonOrderStatus status; + + @Column(name = "attempt_count", nullable = false) + private int attemptCount; + + @Column(name = "last_attempt_at") + private Instant lastAttemptAt; + + @Column(name = "requested_at") + private Instant requestedAt; + + @Column(name = "failure_reason", length = MAX_FAILURE_REASON_LENGTH) + private String failureReason; + + public GifticonOrder( + GifticonPayment payment, + MessageRequest messageRequest, + String senderNickname, + String receiverPhone, + String receiverName, + String externalKey, + String externalOrderId + ) { + if (payment == null || payment.getProduct() == null) { + throw new IllegalArgumentException("결제가 완료된 기프티콘 정보가 필요합니다."); + } + if (payment.getPurpose() == GifticonPaymentPurpose.MESSAGE_REQUEST + && (messageRequest == null || messageRequest.getGifticonProduct() == null)) { + throw new IllegalArgumentException("메시지 요청용 기프티콘 주문에는 메시지 요청이 필요합니다."); + } + if (senderNickname == null || senderNickname.isBlank()) { + throw new IllegalArgumentException("기프티콘 발신자 닉네임은 필수입니다."); + } + if (receiverPhone == null || receiverPhone.isBlank()) { + throw new IllegalArgumentException("수신자 휴대폰 번호는 필수입니다."); + } + if (receiverName == null || receiverName.isBlank()) { + throw new IllegalArgumentException("수신자 이름은 필수입니다."); + } + this.payment = payment; + this.messageRequest = messageRequest; + this.senderNickname = senderNickname.trim(); + this.receiverPhone = receiverPhone; + this.receiverName = receiverName; + this.externalKey = requireExternalId(externalKey, "externalKey"); + this.externalOrderId = requireExternalId(externalOrderId, "externalOrderId"); + this.status = GifticonOrderStatus.PENDING; + } + + public boolean canStartAttempt(int maxAttempts, Instant processingStaleBefore) { + if (attemptCount >= maxAttempts || status == GifticonOrderStatus.REQUESTED) { + return false; + } + return status != GifticonOrderStatus.PROCESSING + || lastAttemptAt == null + || lastAttemptAt.isBefore(processingStaleBefore); + } + + public void markProcessing(Instant now) { + attemptCount++; + lastAttemptAt = now; + failureReason = null; + status = GifticonOrderStatus.PROCESSING; + } + + public void markRequested(Instant now) { + requestedAt = now; + failureReason = null; + status = GifticonOrderStatus.REQUESTED; + } + + public void markFailed(Instant now, String reason) { + lastAttemptAt = now; + failureReason = abbreviate(reason); + status = GifticonOrderStatus.FAILED; + } + + public boolean hasExhaustedAttempts(int maxAttempts) { + return attemptCount >= maxAttempts; + } + + private static String requireExternalId(String value, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + "는 필수입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() > 70) { + throw new IllegalArgumentException(fieldName + "는 70자를 초과할 수 없습니다."); + } + return trimmed; + } + + private static String abbreviate(String value) { + if (value == null || value.isBlank()) { + return "알 수 없는 발송 요청 오류"; + } + return value.length() <= MAX_FAILURE_REASON_LENGTH + ? value + : value.substring(0, MAX_FAILURE_REASON_LENGTH); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonPayment.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonPayment.java new file mode 100644 index 0000000..a1f0870 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonPayment.java @@ -0,0 +1,401 @@ +package mannabom_server.manabom.domain.gifticon.entity; + +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.OneToOne; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.common.BaseTimeEntity; +import mannabom_server.manabom.domain.chat.entity.ChatMessage; +import mannabom_server.manabom.domain.chat.entity.ChatRoom; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import mannabom_server.manabom.domain.messageRequest.entity.MessageRequest; +import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; + +import java.time.Instant; + +@Getter +@Entity +@Table(name = "gifticon_payment") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GifticonPayment extends BaseTimeEntity { + + private static final int MAX_FAILURE_REASON_LENGTH = 1000; + private static final int MAX_MESSAGE_LENGTH = 200; + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "gifticon_payment_id") + private Long gifticonPaymentId; + + @Column(name = "user_id", nullable = false, updatable = false) + private Long userId; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "gifticon_product_id", nullable = false, updatable = false) + private GifticonProduct product; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "message_request_id", unique = true) + private MessageRequest messageRequest; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_room_id") + private ChatRoom chatRoom; + + @Column(name = "receiver_user_id") + private Long receiverUserId; + + @OneToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "chat_message_id", unique = true) + private ChatMessage chatMessage; + + @OneToOne(mappedBy = "payment", fetch = FetchType.LAZY) + private GifticonOrder gifticonOrder; + + @Column(name = "order_id", nullable = false, unique = true, updatable = false, length = 64) + private String orderId; + + @Column(name = "customer_key", nullable = false, updatable = false, length = 64) + private String customerKey; + + @Column(name = "payment_key", unique = true, length = 200) + private String paymentKey; + + @Column(name = "amount", nullable = false, updatable = false) + private int amount; + + @Enumerated(EnumType.STRING) + @Column(name = "purpose", nullable = false, updatable = false, length = 30) + private GifticonPaymentPurpose purpose; + + @Column(name = "target_profile_id", nullable = false, updatable = false) + private Long targetProfileId; + + @Column(name = "message", length = MAX_MESSAGE_LENGTH, updatable = false) + private String message; + + @Enumerated(EnumType.STRING) + @Column(name = "message_source", nullable = false, updatable = false, length = 30) + private MessageSource messageSource; + + @Enumerated(EnumType.STRING) + @Column(name = "status", nullable = false, length = 30) + private GifticonPaymentStatus status; + + @Column(name = "confirmation_started_at") + private Instant confirmationStartedAt; + + @Column(name = "approved_at") + private Instant approvedAt; + + @Column(name = "refund_attempt_count", nullable = false) + private int refundAttemptCount; + + @Column(name = "last_refund_attempt_at") + private Instant lastRefundAttemptAt; + + @Column(name = "refunded_at") + private Instant refundedAt; + + @Column(name = "failure_reason", length = MAX_FAILURE_REASON_LENGTH) + private String failureReason; + + @Enumerated(EnumType.STRING) + @Column(name = "message_creation_status", nullable = false, length = 30) + private GifticonMessageCreationStatus messageCreationStatus; + + @Column(name = "message_creation_attempt_count", nullable = false) + private int messageCreationAttemptCount; + + @Column(name = "last_message_creation_attempt_at") + private Instant lastMessageCreationAttemptAt; + + @Column(name = "message_creation_failure_reason", length = MAX_FAILURE_REASON_LENGTH) + private String messageCreationFailureReason; + + public GifticonPayment( + Long userId, + GifticonProduct product, + String orderId, + String customerKey, + Long targetProfileId, + String message, + MessageSource messageSource + ) { + if (userId == null) { + throw new IllegalArgumentException("결제 사용자 ID는 필수입니다."); + } + if (product == null || product.getSalePrice() <= 0) { + throw new IllegalArgumentException("결제 가능한 기프티콘 상품이 필요합니다."); + } + if (orderId == null || !orderId.matches("[A-Za-z0-9_-]{6,64}")) { + throw new IllegalArgumentException("토스 주문번호 형식이 올바르지 않습니다."); + } + if (customerKey == null || !customerKey.matches("[A-Za-z0-9_=-]{2,64}")) { + throw new IllegalArgumentException("토스 고객 키 형식이 올바르지 않습니다."); + } + if (targetProfileId == null) { + throw new IllegalArgumentException("메시지 대상 프로필 ID는 필수입니다."); + } + if (message != null && message.length() > MAX_MESSAGE_LENGTH) { + throw new IllegalArgumentException("메시지는 200자를 초과할 수 없습니다."); + } + if (messageSource == null) { + throw new IllegalArgumentException("메시지 출처는 필수입니다."); + } + this.userId = userId; + this.product = product; + this.orderId = orderId; + this.customerKey = customerKey; + this.amount = product.getSalePrice(); + this.purpose = GifticonPaymentPurpose.MESSAGE_REQUEST; + this.targetProfileId = targetProfileId; + this.message = message; + this.messageSource = messageSource; + this.status = GifticonPaymentStatus.READY; + this.messageCreationStatus = GifticonMessageCreationStatus.PENDING; + } + + public static GifticonPayment createForChat( + Long userId, + GifticonProduct product, + String orderId, + String customerKey, + ChatRoom chatRoom, + Long receiverUserId + ) { + if (userId == null) { + throw new IllegalArgumentException("결제 사용자 ID는 필수입니다."); + } + if (product == null || product.getSalePrice() <= 0) { + throw new IllegalArgumentException("결제 가능한 기프티콘 상품이 필요합니다."); + } + if (orderId == null || !orderId.matches("[A-Za-z0-9_-]{6,64}")) { + throw new IllegalArgumentException("토스 주문번호 형식이 올바르지 않습니다."); + } + if (customerKey == null || !customerKey.matches("[A-Za-z0-9_=-]{2,64}")) { + throw new IllegalArgumentException("토스 고객 키 형식이 올바르지 않습니다."); + } + if (chatRoom == null || receiverUserId == null || userId.equals(receiverUserId)) { + throw new IllegalArgumentException("1대1 채팅방과 상대 사용자 정보가 필요합니다."); + } + + GifticonPayment payment = new GifticonPayment(); + payment.userId = userId; + payment.product = product; + payment.orderId = orderId; + payment.customerKey = customerKey; + payment.amount = product.getSalePrice(); + payment.purpose = GifticonPaymentPurpose.CHAT; + payment.chatRoom = chatRoom; + payment.receiverUserId = receiverUserId; + payment.status = GifticonPaymentStatus.READY; + payment.messageCreationStatus = GifticonMessageCreationStatus.PENDING; + return payment; + } + + public void startConfirmation(String paymentKey, Instant now, Instant staleBefore) { + if (status == GifticonPaymentStatus.PAID) { + if (!this.paymentKey.equals(paymentKey)) { + throw new IllegalStateException("이미 다른 결제 키로 승인된 주문입니다."); + } + return; + } + if (status == GifticonPaymentStatus.CONFIRMING + && confirmationStartedAt != null + && !confirmationStartedAt.isBefore(staleBefore)) { + throw new IllegalStateException("결제 승인 처리가 진행 중입니다."); + } + if (status != GifticonPaymentStatus.READY + && status != GifticonPaymentStatus.CONFIRMING) { + throw new IllegalStateException("승인할 수 없는 기프티콘 결제 상태입니다."); + } + if (this.paymentKey != null && !this.paymentKey.equals(paymentKey)) { + throw new IllegalStateException("결제 키가 기존 승인 시도와 일치하지 않습니다."); + } + this.paymentKey = requireText(paymentKey, 200, "paymentKey"); + this.confirmationStartedAt = now; + this.failureReason = null; + this.status = GifticonPaymentStatus.CONFIRMING; + } + + public void markPaid(Instant now) { + if (status != GifticonPaymentStatus.CONFIRMING + && status != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("승인 처리 중인 결제가 아닙니다."); + } + this.approvedAt = approvedAt == null ? now : approvedAt; + this.failureReason = null; + this.status = GifticonPaymentStatus.PAID; + } + + public void markConfirmationFailed(String reason) { + if (status == GifticonPaymentStatus.CONFIRMING) { + this.failureReason = abbreviate(reason); + this.status = GifticonPaymentStatus.READY; + } + } + + public void attachTo(MessageRequest request) { + if (purpose != GifticonPaymentPurpose.MESSAGE_REQUEST) { + throw new IllegalStateException("메시지 요청용 결제만 메시지 요청에 연결할 수 있습니다."); + } + if (status != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제가 완료된 기프티콘만 메시지에 첨부할 수 있습니다."); + } + if (messageRequest != null) { + throw new IllegalStateException("이미 메시지 요청에 사용된 기프티콘 결제입니다."); + } + if (request == null || request.getGifticonProduct() != product) { + throw new IllegalArgumentException("결제 상품과 메시지 기프티콘 상품이 일치하지 않습니다."); + } + this.messageRequest = request; + request.attachGifticonPayment(this); + this.messageCreationFailureReason = null; + this.messageCreationStatus = GifticonMessageCreationStatus.CREATED; + } + + public void attachToChatMessage(ChatMessage chatMessage) { + if (purpose != GifticonPaymentPurpose.CHAT || status != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제가 완료된 채팅 기프티콘만 공개할 수 있습니다."); + } + if (this.chatMessage != null) { + return; + } + if (chatMessage == null || chatMessage.getRoom() != chatRoom) { + throw new IllegalArgumentException("결제한 채팅방의 메시지만 연결할 수 있습니다."); + } + this.chatMessage = chatMessage; + this.messageCreationFailureReason = null; + this.messageCreationStatus = GifticonMessageCreationStatus.CREATED; + } + + public void markChatDeliveryFailed(String reason) { + if (purpose != GifticonPaymentPurpose.CHAT || chatMessage != null) { + return; + } + this.messageCreationFailureReason = abbreviate(reason); + this.messageCreationStatus = GifticonMessageCreationStatus.FAILED; + } + + public boolean canStartMessageCreation( + int maxAttempts, + Instant processingStaleBefore + ) { + if (purpose != GifticonPaymentPurpose.MESSAGE_REQUEST + || status != GifticonPaymentStatus.PAID + || messageRequest != null + || messageCreationStatus == GifticonMessageCreationStatus.CREATED + || messageCreationStatus == GifticonMessageCreationStatus.FAILED + || messageCreationAttemptCount >= maxAttempts) { + return false; + } + return messageCreationStatus != GifticonMessageCreationStatus.PROCESSING + || lastMessageCreationAttemptAt == null + || lastMessageCreationAttemptAt.isBefore(processingStaleBefore); + } + + public void startMessageCreation(Instant now) { + if (purpose != GifticonPaymentPurpose.MESSAGE_REQUEST + || status != GifticonPaymentStatus.PAID + || messageRequest != null) { + throw new IllegalStateException("메시지를 생성할 수 없는 기프티콘 결제 상태입니다."); + } + messageCreationAttemptCount++; + lastMessageCreationAttemptAt = now; + messageCreationFailureReason = null; + messageCreationStatus = GifticonMessageCreationStatus.PROCESSING; + } + + public void markMessageCreationFailed(String reason, boolean retryable, int maxAttempts) { + if (messageCreationStatus != GifticonMessageCreationStatus.PROCESSING) { + return; + } + messageCreationFailureReason = abbreviate(reason); + messageCreationStatus = retryable && messageCreationAttemptCount < maxAttempts + ? GifticonMessageCreationStatus.RETRY_PENDING + : GifticonMessageCreationStatus.FAILED; + } + + public void requestRefund() { + if (status == GifticonPaymentStatus.REFUNDED + || status == GifticonPaymentStatus.REFUND_PENDING + || status == GifticonPaymentStatus.REFUND_PROCESSING + || status == GifticonPaymentStatus.REFUND_FAILED) { + return; + } + if (status != GifticonPaymentStatus.PAID) { + throw new IllegalStateException("결제 완료 상태에서만 환불할 수 있습니다."); + } + this.failureReason = null; + this.status = GifticonPaymentStatus.REFUND_PENDING; + } + + public boolean canStartRefund(int maxAttempts, Instant staleBefore) { + if (status == GifticonPaymentStatus.REFUNDED || refundAttemptCount >= maxAttempts) { + return false; + } + if (status == GifticonPaymentStatus.REFUND_PROCESSING) { + return lastRefundAttemptAt == null || lastRefundAttemptAt.isBefore(staleBefore); + } + return status == GifticonPaymentStatus.REFUND_PENDING + || status == GifticonPaymentStatus.REFUND_FAILED; + } + + public void startRefund(Instant now) { + refundAttemptCount++; + lastRefundAttemptAt = now; + failureReason = null; + status = GifticonPaymentStatus.REFUND_PROCESSING; + } + + public void markRefunded(Instant now) { + if (status != GifticonPaymentStatus.REFUND_PROCESSING + && status != GifticonPaymentStatus.REFUNDED) { + throw new IllegalStateException("환불 처리 중인 결제가 아닙니다."); + } + refundedAt = refundedAt == null ? now : refundedAt; + failureReason = null; + status = GifticonPaymentStatus.REFUNDED; + } + + public void markRefundFailed(String reason) { + if (status == GifticonPaymentStatus.REFUND_PROCESSING) { + failureReason = abbreviate(reason); + status = GifticonPaymentStatus.REFUND_FAILED; + } + } + + private String requireText(String value, int maxLength, String fieldName) { + if (value == null || value.isBlank()) { + throw new IllegalArgumentException(fieldName + "는 필수입니다."); + } + String trimmed = value.trim(); + if (trimmed.length() > maxLength) { + throw new IllegalArgumentException(fieldName + "는 " + maxLength + "자를 초과할 수 없습니다."); + } + return trimmed; + } + + private String abbreviate(String value) { + if (value == null || value.isBlank()) { + return "알 수 없는 결제 처리 오류"; + } + return value.length() <= MAX_FAILURE_REASON_LENGTH + ? value + : value.substring(0, MAX_FAILURE_REASON_LENGTH); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java new file mode 100644 index 0000000..0636a95 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java @@ -0,0 +1,163 @@ +package mannabom_server.manabom.domain.gifticon.entity; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; +import lombok.AccessLevel; +import lombok.Getter; +import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.common.BaseTimeEntity; + +import java.time.Instant; +import java.time.LocalDateTime; + +@Getter +@Entity +@Table(name = "gifticon_product") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class GifticonProduct extends BaseTimeEntity { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "gifticon_product_id") + private Long gifticonProductId; + + @Column(name = "template_trace_id", nullable = false, unique = true) + private Long templateTraceId; + + @Column(name = "encrypted_template_token", length = 1024) + private String encryptedTemplateToken; + + @Column(name = "template_name", nullable = false, length = 200) + private String templateName; + + @Column(name = "start_at") + private LocalDateTime startAt; + + @Column(name = "end_at") + private LocalDateTime endAt; + + @Column(name = "order_template_status", nullable = false, length = 30) + private String orderTemplateStatus; + + @Column(name = "budget_type", length = 30) + private String budgetType; + + @Column(name = "gift_sent_count", nullable = false) + private long giftSentCount; + + @Column(name = "bm_sender_name", length = 100) + private String businessMessageSenderName; + + @Column(name = "mc_image_url", length = 2048) + private String messageCardImageUrl; + + @Column(name = "mc_text", columnDefinition = "TEXT") + private String messageCardText; + + @Column(name = "item_type", nullable = false, length = 30) + private String itemType; + + @Column(name = "product_name", nullable = false, length = 200) + private String productName; + + @Column(name = "brand_name", nullable = false, length = 100) + private String brandName; + + @Column(name = "product_image_url", length = 2048) + private String productImageUrl; + + @Column(name = "product_thumb_image_url", length = 2048) + private String productThumbnailImageUrl; + + @Column(name = "brand_image_url", length = 2048) + private String brandImageUrl; + + @Column(name = "product_price", nullable = false) + private int productPrice; + + @Column(name = "sale_price", nullable = false) + private int salePrice; + + @Column(name = "available", nullable = false) + private boolean available; + + @Column(name = "last_synced_at", nullable = false) + private Instant lastSyncedAt; + + public GifticonProduct(Long templateTraceId) { + this.templateTraceId = templateTraceId; + } + + public void synchronize( + String templateName, + LocalDateTime startAt, + LocalDateTime endAt, + String orderTemplateStatus, + String budgetType, + long giftSentCount, + String businessMessageSenderName, + String messageCardImageUrl, + String messageCardText, + String itemType, + String productName, + String brandName, + String productImageUrl, + String productThumbnailImageUrl, + String brandImageUrl, + int productPrice, + int calculatedSalePrice, + Instant syncedAt + ) { + this.templateName = templateName; + this.startAt = startAt; + this.endAt = endAt; + this.orderTemplateStatus = orderTemplateStatus; + this.budgetType = budgetType; + this.giftSentCount = giftSentCount; + this.businessMessageSenderName = businessMessageSenderName; + this.messageCardImageUrl = messageCardImageUrl; + this.messageCardText = messageCardText; + this.itemType = itemType; + this.productName = productName; + this.brandName = brandName; + this.productImageUrl = productImageUrl; + this.productThumbnailImageUrl = productThumbnailImageUrl; + this.brandImageUrl = brandImageUrl; + this.productPrice = productPrice; + this.salePrice = calculatedSalePrice; + this.available = "ALIVE".equals(orderTemplateStatus); + this.lastSyncedAt = syncedAt; + } + + public boolean isAvailableAt(LocalDateTime now) { + return available + && (startAt == null || !startAt.isAfter(now)) + && (endAt == null || endAt.isAfter(now)); + } + + public boolean isOrderableAt(LocalDateTime now) { + return salePrice > 0 && hasTemplateToken() && isAvailableAt(now); + } + + public boolean hasTemplateToken() { + return encryptedTemplateToken != null && !encryptedTemplateToken.isBlank(); + } + + public void updateSalePrice(int calculatedSalePrice) { + if (calculatedSalePrice < 0) { + throw new IllegalArgumentException("기프티콘 판매가는 0 이상이어야 합니다."); + } + this.salePrice = calculatedSalePrice; + } + + public void configureEncryptedTemplateToken(String encryptedTemplateToken) { + if (encryptedTemplateToken == null || encryptedTemplateToken.isBlank()) { + throw new IllegalArgumentException("암호화된 템플릿 토큰은 비어있을 수 없습니다."); + } + this.encryptedTemplateToken = encryptedTemplateToken.trim(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonMessageCreationStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonMessageCreationStatus.java new file mode 100644 index 0000000..85be2c2 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonMessageCreationStatus.java @@ -0,0 +1,19 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonMessageCreationStatus { + PENDING("결제 승인 후 메시지 생성을 기다리는 상태"), + PROCESSING("백엔드가 메시지를 생성하고 있는 상태"), + RETRY_PENDING("일시적인 오류로 메시지 생성 재시도를 기다리는 상태"), + CREATED("메시지가 생성되어 결제와 연결된 상태"), + FAILED("메시지를 생성할 수 없어 결제 환불이 필요한 상태"); + + private final String description; + + GifticonMessageCreationStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java new file mode 100644 index 0000000..c337178 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java @@ -0,0 +1,18 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonOrderStatus { + PENDING("결제 및 발송 조건을 충족하여 외부 기프티콘 발송 요청을 기다리는 상태"), + PROCESSING("외부 기프티콘 공급사에 발송을 요청하고 있는 상태"), + REQUESTED("외부 기프티콘 공급사가 발송 요청을 정상 접수한 상태"), + FAILED("기프티콘 발송 요청이 실패하여 재시도 또는 운영 확인이 필요한 상태"); + + private final String description; + + GifticonOrderStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentPurpose.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentPurpose.java new file mode 100644 index 0000000..7524eee --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentPurpose.java @@ -0,0 +1,16 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonPaymentPurpose { + MESSAGE_REQUEST("첫 메시지 요청에 첨부하는 기프티콘 결제"), + CHAT("이미 열린 1대1 채팅방에서 보내는 기프티콘 결제"); + + private final String description; + + GifticonPaymentPurpose(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java new file mode 100644 index 0000000..ca98d8c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java @@ -0,0 +1,21 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonPaymentStatus { + READY("결제 요청이 생성되어 구매자 인증을 기다리는 상태"), + CONFIRMING("토스페이먼츠에 결제 승인을 요청하고 있는 상태"), + PAID("결제 승인이 완료되어 메시지 요청에 사용할 수 있는 상태"), + REFUND_PENDING("환불 요청이 등록되어 처리를 기다리는 상태"), + REFUND_PROCESSING("토스페이먼츠에 결제 취소를 요청하고 있는 상태"), + REFUNDED("결제 취소와 환불이 완료된 상태"), + REFUND_FAILED("환불 시도가 실패하여 재시도 또는 운영 확인이 필요한 상태"); + + private final String description; + + GifticonPaymentStatus(String description) { + this.description = description; + } + + public String getDescription() { + return description; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonOrderRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonOrderRepository.java new file mode 100644 index 0000000..15b2f3c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonOrderRepository.java @@ -0,0 +1,47 @@ +package mannabom_server.manabom.domain.gifticon.repository; + +import jakarta.persistence.LockModeType; +import mannabom_server.manabom.domain.gifticon.entity.GifticonOrder; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; +import java.time.Instant; + +public interface GifticonOrderRepository extends JpaRepository { + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query(""" + select gifticonOrder + from GifticonOrder gifticonOrder + where gifticonOrder.gifticonOrderId = :gifticonOrderId + """) + Optional findByIdForUpdate( + @Param("gifticonOrderId") Long gifticonOrderId + ); + + Optional findByPayment_GifticonPaymentId(Long paymentId); + + @Query(""" + select gifticonOrder.gifticonOrderId + from GifticonOrder gifticonOrder + where gifticonOrder.status in :statuses + and gifticonOrder.attemptCount < :maxAttempts + and (gifticonOrder.status <> :processingStatus + or gifticonOrder.lastAttemptAt < :processingStaleBefore) + order by gifticonOrder.createdAt asc + """) + List findRetryableOrderIds( + @Param("statuses") Collection statuses, + @Param("maxAttempts") int maxAttempts, + @Param("processingStatus") GifticonOrderStatus processingStatus, + @Param("processingStaleBefore") Instant processingStaleBefore, + Pageable pageable + ); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonPaymentRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonPaymentRepository.java new file mode 100644 index 0000000..2030db4 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonPaymentRepository.java @@ -0,0 +1,146 @@ +package mannabom_server.manabom.domain.gifticon.repository; + +import jakarta.persistence.LockModeType; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentPurpose; +import org.springframework.data.domain.Pageable; +import org.springframework.data.domain.Page; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.EntityGraph; +import org.springframework.data.jpa.repository.Lock; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface GifticonPaymentRepository extends JpaRepository { + + @EntityGraph(attributePaths = { + "messageRequest", + "chatRoom", + "chatMessage", + "gifticonOrder", + "product" + }) + @Query("select p from GifticonPayment p where p.gifticonPaymentId = :paymentId") + Optional findByIdWithMessageRequest(@Param("paymentId") Long paymentId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from GifticonPayment p where p.gifticonPaymentId = :paymentId") + Optional findByIdForUpdate(@Param("paymentId") Long paymentId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from GifticonPayment p where p.orderId = :orderId") + Optional findByOrderIdForUpdate(@Param("orderId") String orderId); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select p from GifticonPayment p where p.messageRequest.id = :messageRequestId") + Optional findByMessageRequestIdForUpdate( + @Param("messageRequestId") Long messageRequestId + ); + + @Query(""" + select p.gifticonPaymentId + from GifticonPayment p + where p.status in :statuses + and p.refundAttemptCount < :maxAttempts + and (p.status <> :processingStatus + or p.lastRefundAttemptAt < :processingStaleBefore) + order by p.createdAt asc + """) + List findRefundRetryIds( + @Param("statuses") Collection statuses, + @Param("maxAttempts") int maxAttempts, + @Param("processingStatus") GifticonPaymentStatus processingStatus, + @Param("processingStaleBefore") Instant processingStaleBefore, + Pageable pageable + ); + + @Query(""" + select p.gifticonPaymentId + from GifticonPayment p + where p.status = :paidStatus + and p.purpose = :purpose + and p.messageRequest is null + and p.messageCreationStatus in :messageStatuses + and p.messageCreationAttemptCount < :maxAttempts + and (p.messageCreationStatus <> :processingStatus + or p.lastMessageCreationAttemptAt < :processingStaleBefore) + order by p.createdAt asc + """) + List findMessageCreationRetryIds( + @Param("paidStatus") GifticonPaymentStatus paidStatus, + @Param("purpose") GifticonPaymentPurpose purpose, + @Param("messageStatuses") Collection messageStatuses, + @Param("maxAttempts") int maxAttempts, + @Param("processingStatus") GifticonMessageCreationStatus processingStatus, + @Param("processingStaleBefore") Instant processingStaleBefore, + Pageable pageable + ); + + @Query(""" + select p.gifticonPaymentId + from GifticonPayment p + where p.status = :status + and p.purpose = :purpose + and p.messageRequest is null + and p.approvedAt <= :approvedBefore + order by p.approvedAt asc + """) + List findUnusedPaidPaymentIds( + @Param("status") GifticonPaymentStatus status, + @Param("purpose") GifticonPaymentPurpose purpose, + @Param("approvedBefore") Instant approvedBefore, + Pageable pageable + ); + + @Query(""" + select p + from GifticonPayment p + left join p.messageRequest messageRequest + where (:paymentStatus is null or p.status = :paymentStatus) + and (:messageCreationStatus is null + or p.messageCreationStatus = :messageCreationStatus) + and (:messageRequestStatus is null + or messageRequest.status = :messageRequestStatus) + and (:userId is null or p.userId = :userId) + and (:orderId = '' or lower(p.orderId) like lower(concat('%', :orderId, '%'))) + and ( + :attentionOnly = false + or p.status = :refundFailedStatus + or (p.status = :refundProcessingStatus + and (p.lastRefundAttemptAt is null + or p.lastRefundAttemptAt < :refundStaleBefore)) + or p.refundAttemptCount >= :maxRefundAttempts + or (p.status = :paidStatus + and p.messageRequest is null + and p.chatMessage is null + and p.approvedAt is not null + and p.approvedAt < :messageStaleBefore) + or (p.messageCreationStatus = :messageFailedStatus + and p.status = :paidStatus) + ) + order by p.createdAt desc + """) + Page findForAdmin( + @Param("paymentStatus") GifticonPaymentStatus paymentStatus, + @Param("messageCreationStatus") GifticonMessageCreationStatus messageCreationStatus, + @Param("messageRequestStatus") mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus messageRequestStatus, + @Param("userId") Long userId, + @Param("orderId") String orderId, + @Param("attentionOnly") boolean attentionOnly, + @Param("refundFailedStatus") GifticonPaymentStatus refundFailedStatus, + @Param("refundProcessingStatus") GifticonPaymentStatus refundProcessingStatus, + @Param("paidStatus") GifticonPaymentStatus paidStatus, + @Param("messageFailedStatus") GifticonMessageCreationStatus messageFailedStatus, + @Param("refundStaleBefore") Instant refundStaleBefore, + @Param("messageStaleBefore") Instant messageStaleBefore, + @Param("maxRefundAttempts") int maxRefundAttempts, + Pageable pageable + ); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonProductRepository.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonProductRepository.java new file mode 100644 index 0000000..a390cbb --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonProductRepository.java @@ -0,0 +1,69 @@ +package mannabom_server.manabom.domain.gifticon.repository; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.Collection; +import java.util.List; + +public interface GifticonProductRepository extends JpaRepository { + + List findAllByTemplateTraceIdIn(Collection templateTraceIds); + + @Modifying(clearAutomatically = true, flushAutomatically = true) + @Query(""" + update GifticonProduct product + set product.available = false + where product.available = true + and product.lastSyncedAt < :syncedAt + """) + int markProductsNotSeenSinceUnavailable(@Param("syncedAt") Instant syncedAt); + + @Query(""" + select product + from GifticonProduct product + where product.available = true + and product.encryptedTemplateToken is not null + and (product.startAt is null or product.startAt <= :now) + and (product.endAt is null or product.endAt > :now) + and product.gifticonProductId > :cursor + and (:category is null or product.brandName = :category) + order by product.gifticonProductId asc + """) + List findAvailableProductsAfter( + @Param("now") LocalDateTime now, + @Param("category") String category, + @Param("cursor") Long cursor, + Pageable pageable + ); + + @Query(""" + select product + from GifticonProduct product + where product.gifticonProductId > :cursor + and ( + :tokenConfigured is null + or (:tokenConfigured = true and product.encryptedTemplateToken is not null) + or (:tokenConfigured = false and product.encryptedTemplateToken is null) + ) + and ( + :keyword = '' + or lower(product.templateName) like lower(concat('%', :keyword, '%')) + or lower(product.productName) like lower(concat('%', :keyword, '%')) + or lower(product.brandName) like lower(concat('%', :keyword, '%')) + ) + order by product.gifticonProductId asc + """) + List findProductsForAdminAfter( + @Param("cursor") Long cursor, + @Param("tokenConfigured") Boolean tokenConfigured, + @Param("keyword") String keyword, + Pageable pageable + ); +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculator.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculator.java new file mode 100644 index 0000000..8b12d11 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculator.java @@ -0,0 +1,47 @@ +package mannabom_server.manabom.domain.gifticon.service; + +import java.math.BigDecimal; +import java.math.RoundingMode; + +public class GifticonPriceCalculator { + + private static final BigDecimal ONE_HUNDRED = BigDecimal.valueOf(100); + + private final BigDecimal markupPercent; + private final int roundUnit; + + public GifticonPriceCalculator(BigDecimal markupPercent, int roundUnit) { + if (markupPercent == null || markupPercent.signum() < 0) { + throw new IllegalArgumentException("markup-percent는 0 이상이어야 합니다."); + } + if (roundUnit <= 0) { + throw new IllegalArgumentException("round-unit은 1 이상이어야 합니다."); + } + this.markupPercent = markupPercent; + this.roundUnit = roundUnit; + } + + public int calculateSalePrice(int productPrice) { + return calculateSalePrice(productPrice, markupPercent); + } + + public int calculateSalePrice(int productPrice, BigDecimal appliedMarkupPercent) { + if (productPrice < 0) { + throw new IllegalArgumentException("기프티콘 원화 가격은 0 이상이어야 합니다."); + } + if (appliedMarkupPercent == null || appliedMarkupPercent.signum() < 0) { + throw new IllegalArgumentException("markup-percent는 0 이상이어야 합니다."); + } + + BigDecimal markupMultiplier = BigDecimal.ONE.add( + appliedMarkupPercent.divide(ONE_HUNDRED, 10, RoundingMode.HALF_UP) + ); + BigDecimal rawSalePrice = BigDecimal.valueOf(productPrice) + .multiply(markupMultiplier) + .setScale(0, RoundingMode.CEILING); + return rawSalePrice + .divide(BigDecimal.valueOf(roundUnit), 0, RoundingMode.CEILING) + .multiply(BigDecimal.valueOf(roundUnit)) + .intValueExact(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/vo/GifticonTemplateSnapshot.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/vo/GifticonTemplateSnapshot.java new file mode 100644 index 0000000..5295d18 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/vo/GifticonTemplateSnapshot.java @@ -0,0 +1,24 @@ +package mannabom_server.manabom.domain.gifticon.vo; + +import java.time.LocalDateTime; + +public record GifticonTemplateSnapshot( + Long templateTraceId, + String templateName, + LocalDateTime startAt, + LocalDateTime endAt, + String status, + String budgetType, + Long sentCount, + String senderName, + String messageCardImageUrl, + String messageCardText, + String itemType, + String productName, + String brandName, + String productImageUrl, + String productThumbnailImageUrl, + String brandImageUrl, + Integer productPrice +) { +} diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java index 202e2b5..4ee966b 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/meeting/enums/SseEventName.java @@ -15,7 +15,8 @@ public enum SseEventName { NEW_CHAT_MESSAGE("NEW_CHAT_MESSAGE"), PHOTO_REQUEST_RECEIVED("PHOTO_REQUEST_RECEIVED"), PHOTO_REQUEST_ACCEPTED("PHOTO_REQUEST_ACCEPTED"), - PHOTO_REQUEST_REJECTED("PHOTO_REQUEST_REJECTED"); + PHOTO_REQUEST_REJECTED("PHOTO_REQUEST_REJECTED"), + GIFTICON_DELIVERY_FAILED("GIFTICON_DELIVERY_FAILED"); private final String description; diff --git a/manabom/src/main/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequest.java b/manabom/src/main/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequest.java index 7e95c12..dcb15e2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequest.java +++ b/manabom/src/main/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequest.java @@ -3,6 +3,9 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.entity.GifticonPayment; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; @@ -53,11 +56,34 @@ public class MessageRequest { @Column(name="responded_at") private LocalDateTime respondedAt; - public MessageRequest(Long fromUserId, Long toUserId, String message, MessageSource source) { + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "gifticon_product_id") + private GifticonProduct gifticonProduct; + + @OneToOne(mappedBy = "messageRequest", fetch = FetchType.LAZY) + private GifticonPayment gifticonPayment; + + public MessageRequest( + Long fromUserId, + Long toUserId, + String message, + MessageSource source + ) { + this(fromUserId, toUserId, message, source, null); + } + + public MessageRequest( + Long fromUserId, + Long toUserId, + String message, + MessageSource source, + GifticonProduct gifticonProduct + ) { this.fromUserId = fromUserId; this.toUserId = toUserId; this.message = message; this.source = source; + this.gifticonProduct = gifticonProduct; this.status = MessageRequestStatus.PENDING; this.createdAt = LocalDateTime.now(); } @@ -82,4 +108,19 @@ public void reject(String reason) { this.rejectReason = reason; this.respondedAt = LocalDateTime.now(); } + + public void attachGifticonPayment(GifticonPayment payment) { + if (gifticonProduct == null || payment == null || payment.getProduct() != gifticonProduct) { + throw new IllegalArgumentException("메시지의 기프티콘 상품과 결제가 일치하지 않습니다."); + } + if (gifticonPayment != null) { + throw new IllegalStateException("이미 기프티콘 결제가 연결된 메시지 요청입니다."); + } + this.gifticonPayment = payment; + } + + public boolean hasPaidGifticonPayment() { + return gifticonPayment != null + && gifticonPayment.getStatus() == GifticonPaymentStatus.PAID; + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java new file mode 100644 index 0000000..b5a5a2a --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java @@ -0,0 +1,22 @@ +package mannabom_server.manabom.infrastructure.config; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.domain.gifticon.service.GifticonPriceCalculator; +import mannabom_server.manabom.policy.config.GifticonPricingProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +@RequiredArgsConstructor +public class GifticonPricingConfiguration { + + private final GifticonPricingProperties properties; + + @Bean + public GifticonPriceCalculator gifticonPriceCalculator() { + return new GifticonPriceCalculator( + properties.getMarkupPercent(), + properties.getRoundUnit() + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizClient.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizClient.java new file mode 100644 index 0000000..58430cb --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizClient.java @@ -0,0 +1,115 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz; + +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.port.GifticonTemplateProvider; +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config.GiftbizProperties; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizTemplatePage; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizTemplatePage.KakaoGiftbizTemplate; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.ArrayList; +import java.util.List; + +@Slf4j +@Component +public class KakaoGiftbizClient implements GifticonTemplateProvider { + + private static final String TEMPLATE_PATH = "/openapi/giftbiz/v1/template"; + private static final String ALIVE_STATUS = "ALIVE"; + + private final GiftbizProperties properties; + private final KakaoGiftbizTemplateMapper templateMapper; + private final WebClient webClient; + + public KakaoGiftbizClient( + GiftbizProperties properties, + KakaoGiftbizTemplateMapper templateMapper + ) { + this.properties = properties; + this.templateMapper = templateMapper; + this.webClient = WebClient.builder() + .baseUrl(properties.getBaseUrl()) + .build(); + } + + @Override + public List findAliveTemplates() { + if (!StringUtils.hasText(properties.getAuthorization())) { + throw new IllegalStateException("KAKAO_GIFTBIZ_AUTHORIZATION 설정이 필요합니다."); + } + + int maxPages = properties.getSync().getMaxPages(); + if (maxPages <= 0) { + throw new IllegalStateException("Gift Biz 최대 조회 페이지 수는 1 이상이어야 합니다."); + } + + List templates = new ArrayList<>(); + for (int page = 0; page < maxPages; page++) { + KakaoGiftbizTemplatePage response = fetchPage(page); + List contents = + response.contents() == null ? List.of() : response.contents(); + templates.addAll(contents); + + if (Boolean.TRUE.equals(response.last())) { + log.info("[Gift Biz] 활성 템플릿 조회 완료: {}개", templates.size()); + return mapValidTemplates(templates); + } + if (contents.isEmpty()) { + throw new IllegalStateException( + "Gift Biz 템플릿 API가 last=false 상태에서 빈 페이지를 반환했습니다: " + page + ); + } + } + + throw new IllegalStateException( + "Gift Biz 템플릿 조회가 최대 페이지 수를 초과했습니다: " + maxPages + ); + } + + private List mapValidTemplates( + List templates + ) { + List snapshots = new ArrayList<>(); + for (KakaoGiftbizTemplate template : templates) { + try { + snapshots.add(templateMapper.toSnapshot(template)); + } catch (IllegalArgumentException e) { + log.warn( + "[Gift Biz] 필수 상품 정보가 누락된 템플릿을 건너뜁니다. templateTraceId={}", + template == null ? null : template.templateTraceId(), + e + ); + } + } + return snapshots; + } + + private KakaoGiftbizTemplatePage fetchPage(int page) { + KakaoGiftbizTemplatePage response = webClient.get() + .uri(uriBuilder -> uriBuilder + .path(TEMPLATE_PATH) + .queryParam("status", ALIVE_STATUS) + .queryParam("page", page) + .build()) + .header(HttpHeaders.AUTHORIZATION, properties.getAuthorization()) + .accept(MediaType.APPLICATION_JSON) + .retrieve() + .onStatus( + status -> status.isError(), + clientResponse -> clientResponse.createException() + ) + .bodyToMono(KakaoGiftbizTemplatePage.class) + .block(Duration.ofSeconds(properties.getRequestTimeoutSeconds())); + + if (response == null) { + throw new IllegalStateException("Gift Biz 템플릿 API가 빈 응답을 반환했습니다."); + } + return response; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizOrderClient.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizOrderClient.java new file mode 100644 index 0000000..c9f41f1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizOrderClient.java @@ -0,0 +1,89 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz; + +import mannabom_server.manabom.application.gifticon.port.GifticonOrderRequester; +import mannabom_server.manabom.application.gifticon.port.command.GifticonOrderCommand; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config.GiftbizProperties; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizOrderRequest; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.List; + +@Component +public class KakaoGiftbizOrderClient implements GifticonOrderRequester { + + private static final String ORDER_PATH = "/openapi/giftbiz/v1/template/order"; + private static final String RECEIVER_TYPE_PHONE = "PHONE"; + + private final GiftbizProperties properties; + private final WebClient webClient; + + public KakaoGiftbizOrderClient(GiftbizProperties properties) { + this.properties = properties; + this.webClient = WebClient.builder() + .baseUrl(properties.getBaseUrl()) + .build(); + } + + @Override + public void requestGift(GifticonOrderCommand command) { + validateConfiguration(); + GiftbizProperties.Order orderProperties = properties.getOrder(); + KakaoGiftbizOrderRequest request = new KakaoGiftbizOrderRequest( + command.templateToken(), + RECEIVER_TYPE_PHONE, + List.of(new KakaoGiftbizOrderRequest.Receiver( + command.receiverPhone(), + command.receiverName(), + command.externalKey(), + formatSenderName(command.senderNickname()), + properties.getText() + )), + blankToNull(orderProperties.getSuccessCallbackUrl()), + blankToNull(orderProperties.getFailCallbackUrl()), + blankToNull(orderProperties.getGiftCallbackUrl()), + command.externalOrderId() + ); + + webClient.post() + .uri(ORDER_PATH) + .header(HttpHeaders.AUTHORIZATION, properties.getAuthorization()) + .contentType(MediaType.APPLICATION_JSON) + .accept(MediaType.APPLICATION_JSON) + .bodyValue(request) + .retrieve() + .onStatus( + status -> status.isError(), + response -> response.createException() + ) + .toBodilessEntity() + .block(Duration.ofSeconds(properties.getRequestTimeoutSeconds())); + } + + private void validateConfiguration() { + if (!StringUtils.hasText(properties.getAuthorization())) { + throw new IllegalStateException("KAKAO_GIFTBIZ_AUTHORIZATION 설정이 필요합니다."); + } + if (!StringUtils.hasText(properties.getSenderName())) { + throw new IllegalStateException("KAKAO_GIFTBIZ_SENDER_NAME 설정이 필요합니다."); + } + if (properties.getRequestTimeoutSeconds() <= 0) { + throw new IllegalStateException("Gift Biz 요청 제한 시간은 1초 이상이어야 합니다."); + } + } + + String formatSenderName(String senderNickname) { + if (!StringUtils.hasText(senderNickname)) { + throw new IllegalArgumentException("기프티콘 발신자 닉네임은 필수입니다."); + } + return properties.getSenderName().trim() + " - " + senderNickname.trim(); + } + + private String blankToNull(String value) { + return StringUtils.hasText(value) ? value.trim() : null; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java new file mode 100644 index 0000000..acd5893 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java @@ -0,0 +1,61 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz; + +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizTemplatePage.KakaoGiftbizTemplate; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizTemplatePage.Product; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.time.format.DateTimeParseException; + +@Component +public class KakaoGiftbizTemplateMapper { + + private static final DateTimeFormatter KAKAO_DATE_TIME = + DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + + public GifticonTemplateSnapshot toSnapshot(KakaoGiftbizTemplate template) { + if (template == null) { + throw new IllegalArgumentException("Gift Biz 템플릿은 null일 수 없습니다."); + } + Product product = template.product(); + if (product == null || product.productPrice() == null) { + throw new IllegalArgumentException("Gift Biz 템플릿의 상품 가격이 누락되었습니다."); + } + return new GifticonTemplateSnapshot( + template.templateTraceId(), + template.templateName(), + parseOptionalDateTime(template.startAt(), "start_at"), + parseOptionalDateTime(template.endAt(), "end_at"), + template.orderTemplateStatus(), + template.budgetType(), + template.giftSentCount(), + template.businessMessageSenderName(), + template.messageCardImageUrl(), + template.messageCardText(), + product.itemType(), + product.productName(), + product.brandName(), + product.productImageUrl(), + product.productThumbnailImageUrl(), + product.brandImageUrl(), + product.productPrice() + ); + } + + private LocalDateTime parseOptionalDateTime(String value, String fieldName) { + if (!StringUtils.hasText(value)) { + return null; + } + try { + return LocalDateTime.parse(value, KAKAO_DATE_TIME); + } catch (DateTimeParseException e) { + throw new IllegalStateException( + "Gift Biz 템플릿의 " + fieldName + " 형식이 올바르지 않습니다: " + value, + e + ); + } + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java new file mode 100644 index 0000000..e62325b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java @@ -0,0 +1,49 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.kakao.giftbiz") +public class GiftbizProperties { + + private String baseUrl = "https://gateway-giftbiz.kakao.com"; + private String authorization; + private String senderName = "만나봄"; + private String text = ""; + private int requestTimeoutSeconds = 10; + private Sync sync = new Sync(); + private Order order = new Order(); + + @Getter + @Setter + public static class Sync { + private boolean enabled = false; + private long initialDelay = 10_000L; + private long fixedDelay = 1_800_000L; + private int maxPages = 100; + } + + @Getter + @Setter + public static class Order { + private String successCallbackUrl; + private String failCallbackUrl; + private String giftCallbackUrl; + private Retry retry = new Retry(); + } + + @Getter + @Setter + public static class Retry { + private boolean enabled = true; + private long initialDelay = 60_000L; + private long fixedDelay = 60_000L; + private int maxAttempts = 5; + private int batchSize = 50; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequest.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequest.java new file mode 100644 index 0000000..9498cd5 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequest.java @@ -0,0 +1,28 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonInclude(JsonInclude.Include.NON_NULL) +public record KakaoGiftbizOrderRequest( + @JsonProperty("template_token") String templateToken, + @JsonProperty("receiver_type") String receiverType, + List receivers, + @JsonProperty("success_callback_url") String successCallbackUrl, + @JsonProperty("fail_callback_url") String failCallbackUrl, + @JsonProperty("gift_callback_url") String giftCallbackUrl, + @JsonProperty("external_order_id") String externalOrderId +) { + + @JsonInclude(JsonInclude.Include.NON_NULL) + public record Receiver( + @JsonProperty("receiver_id") String receiverId, + String name, + @JsonProperty("external_key") String externalKey, + @JsonProperty("sender_name") String senderName, + @JsonProperty("mc_text") String messageCardText + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePage.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePage.java new file mode 100644 index 0000000..3416122 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePage.java @@ -0,0 +1,42 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import com.fasterxml.jackson.annotation.JsonProperty; + +import java.util.List; + +@JsonIgnoreProperties(ignoreUnknown = true) +public record KakaoGiftbizTemplatePage( + List contents, + Boolean last, + @JsonProperty("totalCount") Long totalCount +) { + + @JsonIgnoreProperties(ignoreUnknown = true) + public record KakaoGiftbizTemplate( + @JsonProperty("template_name") String templateName, + @JsonProperty("template_trace_id") Long templateTraceId, + @JsonProperty("start_at") String startAt, + @JsonProperty("end_at") String endAt, + @JsonProperty("order_template_status") String orderTemplateStatus, + @JsonProperty("budget_type") String budgetType, + @JsonProperty("gift_sent_count") Long giftSentCount, + @JsonProperty("bm_sender_name") String businessMessageSenderName, + @JsonProperty("mc_image_url") String messageCardImageUrl, + @JsonProperty("mc_text") String messageCardText, + Product product + ) { + } + + @JsonIgnoreProperties(ignoreUnknown = true) + public record Product( + @JsonProperty("item_type") String itemType, + @JsonProperty("product_name") String productName, + @JsonProperty("brand_name") String brandName, + @JsonProperty("product_image_url") String productImageUrl, + @JsonProperty("product_thumb_image_url") String productThumbnailImageUrl, + @JsonProperty("brand_image_url") String brandImageUrl, + @JsonProperty("product_price") Integer productPrice + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/TossPaymentClient.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/TossPaymentClient.java new file mode 100644 index 0000000..20f57d3 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/TossPaymentClient.java @@ -0,0 +1,117 @@ +package mannabom_server.manabom.infrastructure.external.toss; + +import com.fasterxml.jackson.annotation.JsonIgnoreProperties; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway; +import mannabom_server.manabom.application.gifticon.port.GifticonPaymentGateway.PaymentLookup; +import mannabom_server.manabom.infrastructure.external.toss.config.TossPaymentsProperties; +import org.springframework.http.HttpHeaders; +import org.springframework.http.MediaType; +import org.springframework.stereotype.Component; +import org.springframework.util.StringUtils; +import org.springframework.web.reactive.function.client.WebClient; + +import java.time.Duration; +import java.util.Map; + +@Component +public class TossPaymentClient implements GifticonPaymentGateway { + + private static final String IDEMPOTENCY_KEY = "Idempotency-Key"; + + private final TossPaymentsProperties properties; + private final WebClient webClient; + + public TossPaymentClient(TossPaymentsProperties properties) { + this.properties = properties; + this.webClient = WebClient.builder() + .baseUrl(properties.getBaseUrl()) + .build(); + } + + @Override + public PaymentResult confirm(ConfirmPaymentCommand command) { + TossPaymentResponse response = webClient.post() + .uri("/v1/payments/confirm") + .headers(this::setAuthorization) + .header(IDEMPOTENCY_KEY, command.idempotencyKey()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of( + "paymentKey", command.paymentKey(), + "orderId", command.orderId(), + "amount", command.amount() + )) + .retrieve() + .onStatus(status -> status.isError(), client -> client.createException()) + .bodyToMono(TossPaymentResponse.class) + .block(Duration.ofSeconds(properties.getRequestTimeoutSeconds())); + return toResult(response); + } + + @Override + public PaymentResult cancel(CancelPaymentCommand command) { + TossPaymentResponse response = webClient.post() + .uri("/v1/payments/{paymentKey}/cancel", command.paymentKey()) + .headers(this::setAuthorization) + .header(IDEMPOTENCY_KEY, command.idempotencyKey()) + .contentType(MediaType.APPLICATION_JSON) + .bodyValue(Map.of("cancelReason", command.cancelReason())) + .retrieve() + .onStatus(status -> status.isError(), client -> client.createException()) + .bodyToMono(TossPaymentResponse.class) + .block(Duration.ofSeconds(properties.getRequestTimeoutSeconds())); + return toResult(response); + } + + @Override + public PaymentResult getPayment(PaymentLookup query) { + if (StringUtils.hasText(query.paymentKey())) { + return get("/v1/payments/{identifier}", query.paymentKey()); + } + if (StringUtils.hasText(query.orderId())) { + return get("/v1/payments/orders/{identifier}", query.orderId()); + } + throw new IllegalArgumentException("토스 결제 조회 식별자가 필요합니다."); + } + + private PaymentResult get(String uri, String identifier) { + TossPaymentResponse response = webClient.get() + .uri(uri, identifier) + .headers(this::setAuthorization) + .retrieve() + .onStatus(status -> status.isError(), client -> client.createException()) + .bodyToMono(TossPaymentResponse.class) + .block(Duration.ofSeconds(properties.getRequestTimeoutSeconds())); + return toResult(response); + } + + private void setAuthorization(HttpHeaders headers) { + if (!StringUtils.hasText(properties.getSecretKey())) { + throw new IllegalStateException("TOSS_PAYMENTS_SECRET_KEY 설정이 필요합니다."); + } + if (properties.getRequestTimeoutSeconds() <= 0) { + throw new IllegalStateException("토스페이먼츠 요청 제한 시간은 1초 이상이어야 합니다."); + } + headers.setBasicAuth(properties.getSecretKey().trim(), ""); + } + + private PaymentResult toResult(TossPaymentResponse response) { + if (response == null) { + throw new IllegalStateException("토스페이먼츠가 빈 응답을 반환했습니다."); + } + return new PaymentResult( + response.paymentKey(), + response.orderId(), + response.totalAmount(), + response.status() + ); + } + + @JsonIgnoreProperties(ignoreUnknown = true) + private record TossPaymentResponse( + String paymentKey, + String orderId, + int totalAmount, + String status + ) { + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/config/TossPaymentsProperties.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/config/TossPaymentsProperties.java new file mode 100644 index 0000000..77b79c1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/toss/config/TossPaymentsProperties.java @@ -0,0 +1,51 @@ +package mannabom_server.manabom.infrastructure.external.toss.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.stereotype.Component; + +@Getter +@Setter +@Component +@ConfigurationProperties(prefix = "app.toss-payments") +public class TossPaymentsProperties { + + private String baseUrl = "https://api.tosspayments.com"; + private String clientKey; + private String secretKey; + private int requestTimeoutSeconds = 10; + private MessageCreationRetry messageCreationRetry = new MessageCreationRetry(); + private RefundRetry refundRetry = new RefundRetry(); + private UnusedPaymentRefund unusedPaymentRefund = new UnusedPaymentRefund(); + + @Getter + @Setter + public static class RefundRetry { + private boolean enabled = true; + private long initialDelay = 60_000L; + private long fixedDelay = 60_000L; + private int maxAttempts = 10; + private int batchSize = 50; + } + + @Getter + @Setter + public static class MessageCreationRetry { + private boolean enabled = true; + private long initialDelay = 60_000L; + private long fixedDelay = 60_000L; + private int maxAttempts = 10; + private int batchSize = 50; + } + + @Getter + @Setter + public static class UnusedPaymentRefund { + private boolean enabled = true; + private long gracePeriodMinutes = 30L; + private long initialDelay = 60_000L; + private long fixedDelay = 60_000L; + private int batchSize = 50; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipher.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipher.java new file mode 100644 index 0000000..4f35a2d --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipher.java @@ -0,0 +1,113 @@ +package mannabom_server.manabom.infrastructure.security.crypto; + +import mannabom_server.manabom.application.gifticon.port.GifticonTokenCipher; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +import javax.crypto.Cipher; +import javax.crypto.SecretKey; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import java.nio.ByteBuffer; +import java.nio.charset.StandardCharsets; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; + +@Component +public class AesGcmGifticonTokenCipher implements GifticonTokenCipher { + + private static final String VERSION_PREFIX = "v1:"; + private static final int AES_256_KEY_BYTES = 32; + private static final int GCM_IV_BYTES = 12; + private static final int GCM_TAG_BITS = 128; + + private static final String KEY_SETTING_NAME = + "APP_KAKAO_GIFTBIZ_TOKEN_ENCRYPTION_KEY"; + + private final SecretKey secretKey; + private final SecureRandom secureRandom = new SecureRandom(); + + public AesGcmGifticonTokenCipher( + @Value("${app.kakao.giftbiz.token-encryption-key:}") String encodedKey + ) { + this.secretKey = decodeOptionalSecretKey(encodedKey); + } + + @Override + public String encrypt(String plainToken) { + if (plainToken == null || plainToken.isBlank()) { + throw new IllegalArgumentException("암호화할 템플릿 토큰은 비어있을 수 없습니다."); + } + try { + byte[] iv = new byte[GCM_IV_BYTES]; + secureRandom.nextBytes(iv); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.ENCRYPT_MODE, requiredSecretKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + byte[] encrypted = cipher.doFinal(plainToken.getBytes(StandardCharsets.UTF_8)); + byte[] payload = ByteBuffer.allocate(iv.length + encrypted.length) + .put(iv) + .put(encrypted) + .array(); + return VERSION_PREFIX + Base64.getEncoder().encodeToString(payload); + } catch (GeneralSecurityException e) { + throw new IllegalStateException("기프티콘 템플릿 토큰 암호화에 실패했습니다.", e); + } + } + + @Override + public String decrypt(String encryptedToken) { + if (encryptedToken == null || !encryptedToken.startsWith(VERSION_PREFIX)) { + throw new IllegalStateException("지원하지 않는 기프티콘 템플릿 토큰 암호문입니다."); + } + try { + byte[] payload = Base64.getDecoder().decode( + encryptedToken.substring(VERSION_PREFIX.length()) + ); + if (payload.length <= GCM_IV_BYTES) { + throw new IllegalStateException("기프티콘 템플릿 토큰 암호문이 손상되었습니다."); + } + + byte[] iv = new byte[GCM_IV_BYTES]; + byte[] ciphertext = new byte[payload.length - GCM_IV_BYTES]; + ByteBuffer.wrap(payload) + .get(iv) + .get(ciphertext); + + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(Cipher.DECRYPT_MODE, requiredSecretKey(), new GCMParameterSpec(GCM_TAG_BITS, iv)); + return new String(cipher.doFinal(ciphertext), StandardCharsets.UTF_8); + } catch (IllegalArgumentException | GeneralSecurityException e) { + throw new IllegalStateException("기프티콘 템플릿 토큰 복호화에 실패했습니다.", e); + } + } + + private SecretKey decodeOptionalSecretKey(String encodedKey) { + if (encodedKey == null || encodedKey.isBlank()) { + return null; + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(encodedKey.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + KEY_SETTING_NAME + "는 Base64 형식이어야 합니다.", + e + ); + } + if (decoded.length != AES_256_KEY_BYTES) { + throw new IllegalStateException( + KEY_SETTING_NAME + "는 Base64로 인코딩한 32바이트 키여야 합니다." + ); + } + return new SecretKeySpec(decoded, "AES"); + } + + private SecretKey requiredSecretKey() { + if (secretKey == null) { + throw new IllegalStateException(KEY_SETTING_NAME + " 설정이 필요합니다."); + } + return secretKey; + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/policy/config/ConfigRegister.java b/manabom/src/main/java/mannabom_server/manabom/policy/config/ConfigRegister.java index 36b1e5b..e6a51c1 100644 --- a/manabom/src/main/java/mannabom_server/manabom/policy/config/ConfigRegister.java +++ b/manabom/src/main/java/mannabom_server/manabom/policy/config/ConfigRegister.java @@ -10,7 +10,8 @@ @EnableConfigurationProperties({ MatchPolicyProperties.class, BenefitPolicyProperties.class, - TingPolicyProperties.class + TingPolicyProperties.class, + GifticonPricingProperties.class }) public class ConfigRegister { } diff --git a/manabom/src/main/java/mannabom_server/manabom/policy/config/GifticonPricingProperties.java b/manabom/src/main/java/mannabom_server/manabom/policy/config/GifticonPricingProperties.java new file mode 100644 index 0000000..dc8b85e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/policy/config/GifticonPricingProperties.java @@ -0,0 +1,16 @@ +package mannabom_server.manabom.policy.config; + +import lombok.Getter; +import lombok.Setter; +import org.springframework.boot.context.properties.ConfigurationProperties; + +import java.math.BigDecimal; + +@Getter +@Setter +@ConfigurationProperties(prefix = "gifticon.pricing") +public class GifticonPricingProperties { + + private BigDecimal markupPercent = BigDecimal.TEN; + private int roundUnit = 100; +} diff --git a/manabom/src/main/java/mannabom_server/manabom/policy/entity/PolicyConfig.java b/manabom/src/main/java/mannabom_server/manabom/policy/entity/PolicyConfig.java index d3a501b..a6adf34 100644 --- a/manabom/src/main/java/mannabom_server/manabom/policy/entity/PolicyConfig.java +++ b/manabom/src/main/java/mannabom_server/manabom/policy/entity/PolicyConfig.java @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; +import java.math.BigDecimal; import java.time.LocalDateTime; @Entity @@ -53,6 +54,10 @@ public class PolicyConfig { @Column(name="ting_cost_view_high_score_profile") private Integer tingCostViewHighScoreProfile; + // gifticon.pricing.* + @Column(name = "gifticon_markup_percent", precision = 7, scale = 3) + private BigDecimal gifticonMarkupPercent; + // benefit.policy.* @Column(name = "benefit_basic_daily_profile") private Integer benefitBasicDailyProfile; @@ -107,6 +112,8 @@ private void touch() { public void updateTingCostViewLikedMeProfile(Integer v) { this.tingCostViewLikedMeProfile = v; touch(); } public void updateTingCostViewHighScoreProfile(Integer v) { this.tingCostViewHighScoreProfile = v; touch(); } + public void updateGifticonMarkupPercent(BigDecimal v) { this.gifticonMarkupPercent = v; touch(); } + public void updateBenefitMembershipCycleExtraProfiles(Integer v) { this.benefitMembershipCycleExtraProfiles = v; touch(); } public void updateBenefitMembershipCycleFreeMessages(Integer v) { this.benefitMembershipCycleFreeMessages = v; touch(); } public void updateBenefitMembershipCycleFreeLikes(Integer v) { this.benefitMembershipCycleFreeLikes = v; touch(); } @@ -132,6 +139,8 @@ private void touch() { public void resetTingCostViewLikedMeProfile() { this.tingCostViewLikedMeProfile = null; touch(); } public void resetTingCostViewHighScoreProfile() { this.tingCostViewHighScoreProfile = null; touch(); } + public void resetGifticonMarkupPercent() { this.gifticonMarkupPercent = null; touch(); } + public void resetBenefitMembershipCycleExtraProfiles() { this.benefitMembershipCycleExtraProfiles = null; touch(); } public void resetBenefitMembershipCycleFreeMessages() { this.benefitMembershipCycleFreeMessages = null; touch(); } public void resetBenefitMembershipCycleFreeLikes() { this.benefitMembershipCycleFreeLikes = null; touch(); } diff --git a/manabom/src/main/java/mannabom_server/manabom/policy/model/RuntimePolicySnapshot.java b/manabom/src/main/java/mannabom_server/manabom/policy/model/RuntimePolicySnapshot.java index 7f8e725..663a552 100644 --- a/manabom/src/main/java/mannabom_server/manabom/policy/model/RuntimePolicySnapshot.java +++ b/manabom/src/main/java/mannabom_server/manabom/policy/model/RuntimePolicySnapshot.java @@ -7,6 +7,8 @@ import lombok.Getter; import lombok.Setter; +import java.math.BigDecimal; + @Getter @Builder public class RuntimePolicySnapshot { @@ -14,6 +16,7 @@ public class RuntimePolicySnapshot { private final Match match; private final Ting ting; private final Benefit benefit; + private final Gifticon gifticon; @Getter @Builder @@ -73,4 +76,17 @@ public static class Vip { private final int dailyFreeLikes; } } + + @Getter + @Builder + public static class Gifticon { + private final Pricing pricing; + + @Getter + @Builder + public static class Pricing { + private final BigDecimal markupPercent; + private final int roundUnit; + } + } } diff --git a/manabom/src/main/java/mannabom_server/manabom/policy/service/RuntimePolicyService.java b/manabom/src/main/java/mannabom_server/manabom/policy/service/RuntimePolicyService.java index bba48a6..b50ebad 100644 --- a/manabom/src/main/java/mannabom_server/manabom/policy/service/RuntimePolicyService.java +++ b/manabom/src/main/java/mannabom_server/manabom/policy/service/RuntimePolicyService.java @@ -3,6 +3,7 @@ import jakarta.annotation.PostConstruct; import lombok.RequiredArgsConstructor; import mannabom_server.manabom.policy.config.BenefitPolicyProperties; +import mannabom_server.manabom.policy.config.GifticonPricingProperties; import mannabom_server.manabom.policy.config.MatchPolicyProperties; import mannabom_server.manabom.policy.config.TingPolicyProperties; import mannabom_server.manabom.policy.entity.PolicyConfig; @@ -11,6 +12,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.math.BigDecimal; import java.util.concurrent.atomic.AtomicReference; @Service @@ -22,6 +24,7 @@ public class RuntimePolicyService { private final MatchPolicyProperties matchDefaults; private final TingPolicyProperties tingDefaults; private final BenefitPolicyProperties benefitDefaults; + private final GifticonPricingProperties gifticonDefaults; private final PolicyConfigRepository policyConfigRepository; @@ -91,6 +94,12 @@ private RuntimePolicySnapshot buildDefaults() { .dailyLoveView(benefitDefaults.getBasic().getDailyLoveView()) .build()) .build()) + .gifticon(RuntimePolicySnapshot.Gifticon.builder() + .pricing(RuntimePolicySnapshot.Gifticon.Pricing.builder() + .markupPercent(gifticonDefaults.getMarkupPercent()) + .roundUnit(gifticonDefaults.getRoundUnit()) + .build()) + .build()) .build(); } @@ -128,6 +137,10 @@ private RuntimePolicySnapshot merge(RuntimePolicySnapshot base, PolicyConfig row base.getBenefit().getBasic().getDailyProfile()); int basicDailyLoveView = nvl(row.getBenefitBasicDailyLoveView(), base.getBenefit().getBasic().getDailyLoveView()); + BigDecimal gifticonMarkupPercent = nvl( + row.getGifticonMarkupPercent(), + base.getGifticon().getPricing().getMarkupPercent() + ); return RuntimePolicySnapshot.builder() .match(RuntimePolicySnapshot.Match.builder() @@ -164,6 +177,12 @@ private RuntimePolicySnapshot merge(RuntimePolicySnapshot base, PolicyConfig row .dailyLoveView(basicDailyLoveView) .build()) .build()) + .gifticon(RuntimePolicySnapshot.Gifticon.builder() + .pricing(RuntimePolicySnapshot.Gifticon.Pricing.builder() + .markupPercent(gifticonMarkupPercent) + .roundUnit(base.getGifticon().getPricing().getRoundUnit()) + .build()) + .build()) .build(); } @@ -171,6 +190,10 @@ private int nvl(Integer override, int base) { return override != null ? override : base; } + private BigDecimal nvl(BigDecimal override, BigDecimal base) { + return override != null ? override : base; + } + // ------------------------------ // 관리자 업데이트용 (필드 하나만 수정) // ------------------------------ @@ -255,6 +278,27 @@ public void resetTingVipThresholdToDefault() { reload(); } + // --------- gifticon.pricing --------- + + @Transactional + public void updateGifticonMarkupPercent(BigDecimal markupPercent) { + if (markupPercent == null || markupPercent.signum() < 0) { + throw new IllegalArgumentException("gifticon.pricing.markupPercent는 0 이상이어야 합니다."); + } + PolicyConfig row = ensureRow(); + row.updateGifticonMarkupPercent(markupPercent); + policyConfigRepository.save(row); + reload(); + } + + @Transactional + public void resetGifticonMarkupPercentToDefault() { + PolicyConfig row = ensureRow(); + row.resetGifticonMarkupPercent(); + policyConfigRepository.save(row); + reload(); + } + @Transactional public void updateTingCostExtraProfile(int cost) { if (cost < 0) throw new IllegalArgumentException("ting.cost.extraProfile는 0 이상이어야 합니다."); diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java new file mode 100644 index 0000000..add97ca --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java @@ -0,0 +1,84 @@ +package mannabom_server.manabom.presentation.admin.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.request.AdminConfigureGifticonTokenRequest; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonProductResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonProductSliceResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonSyncResponse; +import mannabom_server.manabom.application.admin.service.AdminGifticonService; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.PutMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/gifticons") +@RequiredArgsConstructor +public class AdminGifticonController { + + private final AdminGifticonService adminGifticonService; + + @GetMapping + public ResponseEntity getProducts( + @AuthenticationPrincipal AdminPrincipal admin, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Boolean tokenConfigured, + @RequestParam(required = false) String keyword + ) { + return ResponseEntity.ok( + adminGifticonService.getProducts( + admin, + cursor, + size, + tokenConfigured, + keyword + ) + ); + } + + @PostMapping("/synchronize") + public ResponseEntity synchronize( + @AuthenticationPrincipal AdminPrincipal admin, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok( + adminGifticonService.synchronizeCatalog( + admin, + clientIp(httpRequest) + ) + ); + } + + @PutMapping("/{gifticonProductId}/template-token") + public ResponseEntity configureTemplateToken( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long gifticonProductId, + @RequestBody @Valid AdminConfigureGifticonTokenRequest request, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok(adminGifticonService.configureTemplateToken( + admin, + gifticonProductId, + request, + clientIp(httpRequest) + )); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonPaymentController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonPaymentController.java new file mode 100644 index 0000000..cb4a9a1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonPaymentController.java @@ -0,0 +1,101 @@ +package mannabom_server.manabom.presentation.admin.controller; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.admin.dto.request.AdminGifticonPaymentActionRequest; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonPaymentPageResponse; +import mannabom_server.manabom.application.admin.dto.response.AdminGifticonPaymentResponse; +import mannabom_server.manabom.application.admin.service.AdminGifticonPaymentService; +import mannabom_server.manabom.domain.gifticon.enums.GifticonMessageCreationStatus; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; +import mannabom_server.manabom.infrastructure.security.admin.AdminPrincipal; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +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.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/admin/gifticon-payments") +@RequiredArgsConstructor +public class AdminGifticonPaymentController { + + private final AdminGifticonPaymentService paymentService; + + @GetMapping + public ResponseEntity getPayments( + @AuthenticationPrincipal AdminPrincipal admin, + @RequestParam(defaultValue = "0") int page, + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) GifticonPaymentStatus paymentStatus, + @RequestParam(required = false) GifticonMessageCreationStatus messageCreationStatus, + @RequestParam(required = false) MessageRequestStatus messageRequestStatus, + @RequestParam(required = false) Long userId, + @RequestParam(required = false) String orderId, + @RequestParam(defaultValue = "false") boolean attentionOnly + ) { + return ResponseEntity.ok(paymentService.getPayments( + admin, + page, + size, + paymentStatus, + messageCreationStatus, + messageRequestStatus, + userId, + orderId, + attentionOnly + )); + } + + @GetMapping("/{paymentId}") + public ResponseEntity getPayment( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long paymentId + ) { + return ResponseEntity.ok(paymentService.getPayment(admin, paymentId)); + } + + @PostMapping("/{paymentId}/retry-message") + public ResponseEntity retryMessage( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long paymentId, + @Valid @RequestBody AdminGifticonPaymentActionRequest request, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok(paymentService.retryMessage( + admin, + paymentId, + request.reason().trim(), + clientIp(httpRequest) + )); + } + + @PostMapping("/{paymentId}/refund") + public ResponseEntity forceRefund( + @AuthenticationPrincipal AdminPrincipal admin, + @PathVariable Long paymentId, + @Valid @RequestBody AdminGifticonPaymentActionRequest request, + HttpServletRequest httpRequest + ) { + return ResponseEntity.ok(paymentService.forceRefund( + admin, + paymentId, + request.reason().trim(), + clientIp(httpRequest) + )); + } + + private String clientIp(HttpServletRequest request) { + String forwarded = request.getHeader("X-Forwarded-For"); + if (forwarded != null && !forwarded.isBlank()) { + return forwarded.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonController.java new file mode 100644 index 0000000..56655e9 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonController.java @@ -0,0 +1,29 @@ +package mannabom_server.manabom.presentation.gifticon.controller; + +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonProductSliceResponse; +import mannabom_server.manabom.application.gifticon.service.GifticonCatalogService; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/gifticons") +public class GifticonController { + + private final GifticonCatalogService gifticonCatalogService; + + @GetMapping + public ResponseEntity getGifticons( + @RequestParam(required = false) String category, + @RequestParam(required = false) Long cursor, + @RequestParam(defaultValue = "20") int size + ) { + return ResponseEntity.ok( + gifticonCatalogService.getAvailableProducts(category, cursor, size) + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonPaymentController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonPaymentController.java new file mode 100644 index 0000000..981954c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonPaymentController.java @@ -0,0 +1,70 @@ +package mannabom_server.manabom.presentation.gifticon.controller; + +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import mannabom_server.manabom.application.gifticon.dto.request.ConfirmGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.request.PrepareChatGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.request.PrepareGifticonPaymentRequest; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonPaymentPrepareResponse; +import mannabom_server.manabom.application.gifticon.dto.response.GifticonPaymentResponse; +import mannabom_server.manabom.application.gifticon.service.GifticonPaymentService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/gifticons/payments") +public class GifticonPaymentController { + + private final GifticonPaymentService paymentService; + + @PostMapping("/prepare") + public ResponseEntity prepare( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PrepareGifticonPaymentRequest request + ) { + return ResponseEntity.ok( + paymentService.prepare(userId, request) + ); + } + + @PostMapping("/chat/prepare") + public ResponseEntity prepareChat( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody PrepareChatGifticonPaymentRequest request + ) { + return ResponseEntity.ok(paymentService.prepareChat(userId, request)); + } + + @PostMapping("/confirm") + public ResponseEntity confirm( + @AuthenticationPrincipal Long userId, + @Valid @RequestBody ConfirmGifticonPaymentRequest request + ) { + return ResponseEntity.ok(paymentService.confirm(userId, request)); + } + + @GetMapping("/{paymentId}") + public ResponseEntity getPayment( + @AuthenticationPrincipal Long userId, + @PathVariable Long paymentId + ) { + return ResponseEntity.ok(paymentService.getPayment(userId, paymentId)); + } + + @PostMapping("/{paymentId}/cancel") + public ResponseEntity cancelUnusedPayment( + @AuthenticationPrincipal Long userId, + @PathVariable Long paymentId + ) { + return ResponseEntity.ok( + paymentService.cancelUnusedPayment(userId, paymentId) + ); + } +} diff --git a/manabom/src/main/java/mannabom_server/manabom/presentation/messageRequest/controller/MessageRequestController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/messageRequest/controller/MessageRequestController.java index 98d94c5..db6f6a2 100644 --- a/manabom/src/main/java/mannabom_server/manabom/presentation/messageRequest/controller/MessageRequestController.java +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/messageRequest/controller/MessageRequestController.java @@ -26,7 +26,12 @@ public ResponseEntity requestMessage( @RequestBody @Valid SendMessageRequestDto request ){ return ResponseEntity.ok( - messageRequestService.sendMessageRequest(userId, request.getTargetProfileId(), request.getMessage(), request.getSource()) + messageRequestService.sendMessageRequest( + userId, + request.getTargetProfileId(), + request.getMessage(), + request.getSource() + ) ); } diff --git a/manabom/src/main/resources/application.yml b/manabom/src/main/resources/application.yml index b339ab9..f353b18 100644 --- a/manabom/src/main/resources/application.yml +++ b/manabom/src/main/resources/application.yml @@ -90,6 +90,52 @@ app: # 카카오 OAuth 설정 kakao: app-id: ${KAKAO_APP_ID:0} + giftbiz: + base-url: ${KAKAO_GIFTBIZ_BASE_URL:https://gateway-giftbiz.kakao.com} + authorization: ${KAKAO_GIFTBIZ_AUTHORIZATION:} + token-encryption-key: ${APP_KAKAO_GIFTBIZ_TOKEN_ENCRYPTION_KEY:${GIFTICON_TOKEN_ENCRYPTION_KEY:}} + sender-name: ${KAKAO_GIFTBIZ_SENDER_NAME:만나봄} + text: ${KAKAO_GIFTBIZ_TEXT:마음이 도착했어요} + request-timeout-seconds: ${KAKAO_GIFTBIZ_REQUEST_TIMEOUT_SECONDS:10} + sync: + enabled: ${KAKAO_GIFTBIZ_SYNC_ENABLED:false} + initial-delay: ${KAKAO_GIFTBIZ_SYNC_INITIAL_DELAY:10000} + fixed-delay: ${KAKAO_GIFTBIZ_SYNC_FIXED_DELAY:86400000} + max-pages: ${KAKAO_GIFTBIZ_SYNC_MAX_PAGES:100} + order: + success-callback-url: ${KAKAO_GIFTBIZ_SUCCESS_CALLBACK_URL:} + fail-callback-url: ${KAKAO_GIFTBIZ_FAIL_CALLBACK_URL:} + gift-callback-url: ${KAKAO_GIFTBIZ_GIFT_CALLBACK_URL:} + retry: + enabled: ${KAKAO_GIFTBIZ_ORDER_RETRY_ENABLED:true} + initial-delay: ${KAKAO_GIFTBIZ_ORDER_RETRY_INITIAL_DELAY:60000} + fixed-delay: ${KAKAO_GIFTBIZ_ORDER_RETRY_FIXED_DELAY:60000} + max-attempts: ${KAKAO_GIFTBIZ_ORDER_RETRY_MAX_ATTEMPTS:5} + batch-size: ${KAKAO_GIFTBIZ_ORDER_RETRY_BATCH_SIZE:50} + + toss-payments: + base-url: ${TOSS_PAYMENTS_BASE_URL:https://api.tosspayments.com} + client-key: ${TOSS_PAYMENTS_CLIENT_KEY:} + secret-key: ${TOSS_PAYMENTS_SECRET_KEY:} + request-timeout-seconds: ${TOSS_PAYMENTS_REQUEST_TIMEOUT_SECONDS:10} + message-creation-retry: + enabled: ${GIFTICON_MESSAGE_CREATION_RETRY_ENABLED:true} + initial-delay: ${GIFTICON_MESSAGE_CREATION_RETRY_INITIAL_DELAY:60000} + fixed-delay: ${GIFTICON_MESSAGE_CREATION_RETRY_FIXED_DELAY:60000} + max-attempts: ${GIFTICON_MESSAGE_CREATION_RETRY_MAX_ATTEMPTS:10} + batch-size: ${GIFTICON_MESSAGE_CREATION_RETRY_BATCH_SIZE:50} + refund-retry: + enabled: ${TOSS_PAYMENTS_REFUND_RETRY_ENABLED:true} + initial-delay: ${TOSS_PAYMENTS_REFUND_RETRY_INITIAL_DELAY:60000} + fixed-delay: ${TOSS_PAYMENTS_REFUND_RETRY_FIXED_DELAY:60000} + max-attempts: ${TOSS_PAYMENTS_REFUND_RETRY_MAX_ATTEMPTS:10} + batch-size: ${TOSS_PAYMENTS_REFUND_RETRY_BATCH_SIZE:50} + unused-payment-refund: + enabled: ${TOSS_PAYMENTS_UNUSED_REFUND_ENABLED:true} + grace-period-minutes: ${TOSS_PAYMENTS_UNUSED_REFUND_GRACE_MINUTES:30} + initial-delay: ${TOSS_PAYMENTS_UNUSED_REFUND_INITIAL_DELAY:60000} + fixed-delay: ${TOSS_PAYMENTS_UNUSED_REFUND_FIXED_DELAY:60000} + batch-size: ${TOSS_PAYMENTS_UNUSED_REFUND_BATCH_SIZE:50} # 파일 저장 설정 (로컬 개발용) file: diff --git a/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql b/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql new file mode 100644 index 0000000..37059fc --- /dev/null +++ b/manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql @@ -0,0 +1,85 @@ +CREATE TABLE meeting_cancellation_requests +( + id BIGSERIAL PRIMARY KEY, + meeting_id BIGINT NOT NULL, + initiator_user_id BIGINT NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + expires_at TIMESTAMPTZ NOT NULL, + completed_at TIMESTAMPTZ NULL, + version BIGINT NOT NULL DEFAULT 0, + + CONSTRAINT fk_cancellation_request_meeting + FOREIGN KEY (meeting_id) + REFERENCES meeting (id), + + CONSTRAINT fk_cancellation_request_initiator + FOREIGN KEY (initiator_user_id) + REFERENCES users (user_id), + + CONSTRAINT chk_cancellation_request_status + CHECK (status IN ( + 'PENDING', + 'APPROVED', + 'REJECTED', + 'EXPIRED', + 'WITHDRAWN' + )), + + CONSTRAINT chk_cancellation_request_expiration + CHECK (expires_at > requested_at) +); + +-- 한 미팅에서는 진행 중인 전체 취소 요청을 하나만 허용 +CREATE UNIQUE INDEX uk_meeting_cancellation_pending + ON meeting_cancellation_requests (meeting_id) + WHERE status = 'PENDING'; + +CREATE INDEX idx_cancellation_request_meeting_status + ON meeting_cancellation_requests (meeting_id, status); + +-- 만료된 요청을 스케줄러에서 조회할 때 사용 +CREATE INDEX idx_cancellation_request_status_expires + ON meeting_cancellation_requests (status, expires_at); + + +CREATE TABLE meeting_cancellation_votes +( + id BIGSERIAL PRIMARY KEY, + request_id BIGINT NOT NULL, + user_id BIGINT NOT NULL, + decision VARCHAR(20) NOT NULL DEFAULT 'PENDING', + decided_at TIMESTAMPTZ NULL, + + CONSTRAINT fk_cancellation_vote_request + FOREIGN KEY (request_id) + REFERENCES meeting_cancellation_requests (id) + ON DELETE CASCADE, + + CONSTRAINT fk_cancellation_vote_user + FOREIGN KEY (user_id) + REFERENCES users (user_id), + + CONSTRAINT uk_cancellation_vote_request_user + UNIQUE (request_id, user_id), + + CONSTRAINT chk_cancellation_vote_decision + CHECK (decision IN ( + 'PENDING', + 'AGREE', + 'REJECT' + )), + + CONSTRAINT chk_cancellation_vote_decided_at + CHECK ( + (decision = 'PENDING' AND decided_at IS NULL) + OR + (decision IN ('AGREE', 'REJECT') AND decided_at IS NOT NULL) + ) +); + +CREATE INDEX idx_cancellation_vote_request + ON meeting_cancellation_votes (request_id); + +CREATE INDEX idx_cancellation_vote_user + ON meeting_cancellation_votes (user_id); \ No newline at end of file diff --git a/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql b/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql new file mode 100644 index 0000000..0357654 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql @@ -0,0 +1,25 @@ +ALTER TABLE meeting_cancellation_requests + ADD COLUMN meeting_match_id BIGINT; + +ALTER TABLE meeting_cancellation_requests + ALTER COLUMN meeting_id DROP NOT NULL; + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT fk_cancellation_request_meeting_match + FOREIGN KEY (meeting_match_id) + REFERENCES meeting_matches (id); + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT chk_cancellation_request_target + CHECK ( + (meeting_id IS NOT NULL AND meeting_match_id IS NULL) + OR + (meeting_id IS NULL AND meeting_match_id IS NOT NULL) + ); + +CREATE UNIQUE INDEX uk_meeting_match_cancellation_pending + ON meeting_cancellation_requests (meeting_match_id) + WHERE status = 'PENDING' AND meeting_match_id IS NOT NULL; + +CREATE INDEX idx_cancellation_request_match_status + ON meeting_cancellation_requests (meeting_match_id, status); diff --git a/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql b/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql new file mode 100644 index 0000000..13218b3 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql @@ -0,0 +1,11 @@ +ALTER TABLE meeting_cancellation_requests + DROP CONSTRAINT chk_cancellation_request_status; + +ALTER TABLE meeting_cancellation_requests + ADD CONSTRAINT chk_cancellation_request_status + CHECK (status IN ( + 'PENDING', + 'APPROVED', + 'REJECTED', + 'EXPIRED' + )); diff --git a/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql new file mode 100644 index 0000000..33aed3a --- /dev/null +++ b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql @@ -0,0 +1,28 @@ +-- 과거 DB 기본값 오타 보정 +UPDATE chat_members +SET status = 'ACTIVATE' +WHERE status = 'ACTIVE'; + +-- 변환 후 발생할 수 있는 활성 중복 정리 +WITH ranked_active_members AS ( + SELECT id, + ROW_NUMBER() OVER ( + PARTITION BY room_id, user_id + ORDER BY id + ) AS row_number + FROM chat_members + WHERE status = 'ACTIVATE' +) +UPDATE chat_members AS cm +SET status = 'DEACTIVATED' +FROM ranked_active_members AS ranked +WHERE cm.id = ranked.id + AND ranked.row_number > 1; + +-- 이후 기본값도 Java enum과 통일 +ALTER TABLE chat_members + ALTER COLUMN status SET DEFAULT 'ACTIVATE'; + +CREATE UNIQUE INDEX uk_chat_members_active_room_user + ON chat_members (room_id, user_id) + WHERE status = 'ACTIVATE'; diff --git a/manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql b/manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql new file mode 100644 index 0000000..e86d4f9 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql @@ -0,0 +1,6 @@ +ALTER TABLE meeting_verification + ADD COLUMN IF NOT EXISTS failure_notified_at TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_meeting_verification_failure_notification + ON meeting_verification (expires_at) + WHERE is_verified = FALSE AND failure_notified_at IS NULL; diff --git a/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql b/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql new file mode 100644 index 0000000..d352df7 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql @@ -0,0 +1,22 @@ +ALTER TABLE chat_messages + ADD COLUMN system_event_type VARCHAR(64), + ADD COLUMN system_title VARCHAR(255), + ADD COLUMN actor_user_id BIGINT, + ADD COLUMN actor_nickname VARCHAR(255), + ADD COLUMN system_data JSONB; + +ALTER TABLE chat_messages + ADD CONSTRAINT fk_chat_message_actor + FOREIGN KEY (actor_user_id) REFERENCES users(user_id); + +CREATE INDEX idx_chat_messages_system_event_type + ON chat_messages(system_event_type) + WHERE system_event_type IS NOT NULL; + +ALTER TABLE meeting_verification + ADD COLUMN started_by_user_id BIGINT, + ADD COLUMN participant_count INTEGER NOT NULL DEFAULT 0; + +ALTER TABLE meeting_verification + ADD CONSTRAINT fk_meeting_verification_started_by + FOREIGN KEY (started_by_user_id) REFERENCES users(user_id); diff --git a/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql b/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql new file mode 100644 index 0000000..3a38d0b --- /dev/null +++ b/manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql @@ -0,0 +1,6 @@ +ALTER TABLE meeting_verification + ADD COLUMN verified_participant_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN final_latitude DOUBLE PRECISION, + ADD COLUMN final_longitude DOUBLE PRECISION, + ADD COLUMN verified_has_male BOOLEAN NOT NULL DEFAULT FALSE, + ADD COLUMN verified_has_female BOOLEAN NOT NULL DEFAULT FALSE; diff --git a/manabom/src/main/resources/db/migration/V28__create_gifticon_product.sql b/manabom/src/main/resources/db/migration/V28__create_gifticon_product.sql new file mode 100644 index 0000000..30521a4 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V28__create_gifticon_product.sql @@ -0,0 +1,35 @@ +CREATE TABLE gifticon_product ( + gifticon_product_id BIGSERIAL PRIMARY KEY, + template_trace_id BIGINT NOT NULL UNIQUE, + template_name VARCHAR(200) NOT NULL, + start_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + end_at TIMESTAMP WITHOUT TIME ZONE NOT NULL, + order_template_status VARCHAR(30) NOT NULL, + budget_type VARCHAR(30), + gift_sent_count BIGINT NOT NULL DEFAULT 0, + bm_sender_name VARCHAR(100), + mc_image_url VARCHAR(2048), + mc_text TEXT, + item_type VARCHAR(30) NOT NULL, + product_name VARCHAR(200) NOT NULL, + brand_name VARCHAR(100) NOT NULL, + product_image_url VARCHAR(2048), + product_thumb_image_url VARCHAR(2048), + brand_image_url VARCHAR(2048), + product_price INTEGER NOT NULL CHECK (product_price >= 0), + ting_price INTEGER NOT NULL CHECK (ting_price >= 0), + ting_price_manually_set BOOLEAN NOT NULL DEFAULT FALSE, + available BOOLEAN NOT NULL DEFAULT FALSE, + last_synced_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX idx_gifticon_product_available_price + ON gifticon_product (available, product_price, gifticon_product_id); + +CREATE INDEX idx_gifticon_product_available_period + ON gifticon_product (available, start_at, end_at); + +CREATE INDEX idx_gifticon_product_available_brand_cursor + ON gifticon_product (available, brand_name, gifticon_product_id); diff --git a/manabom/src/main/resources/db/migration/V29__allow_null_gifticon_sales_period.sql b/manabom/src/main/resources/db/migration/V29__allow_null_gifticon_sales_period.sql new file mode 100644 index 0000000..8905e95 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V29__allow_null_gifticon_sales_period.sql @@ -0,0 +1,3 @@ +ALTER TABLE gifticon_product + ALTER COLUMN start_at DROP NOT NULL, + ALTER COLUMN end_at DROP NOT NULL; diff --git a/manabom/src/main/resources/db/migration/V30__link_gifticon_to_message_request.sql b/manabom/src/main/resources/db/migration/V30__link_gifticon_to_message_request.sql new file mode 100644 index 0000000..6ed264f --- /dev/null +++ b/manabom/src/main/resources/db/migration/V30__link_gifticon_to_message_request.sql @@ -0,0 +1,10 @@ +ALTER TABLE message_request + ADD COLUMN gifticon_product_id BIGINT; + +ALTER TABLE message_request + ADD CONSTRAINT fk_message_request_gifticon_product + FOREIGN KEY (gifticon_product_id) + REFERENCES gifticon_product (gifticon_product_id); + +CREATE INDEX idx_message_request_gifticon_product + ON message_request (gifticon_product_id); diff --git a/manabom/src/main/resources/db/migration/V31__add_gifticon_template_token.sql b/manabom/src/main/resources/db/migration/V31__add_gifticon_template_token.sql new file mode 100644 index 0000000..1fd6fcf --- /dev/null +++ b/manabom/src/main/resources/db/migration/V31__add_gifticon_template_token.sql @@ -0,0 +1,6 @@ +ALTER TABLE gifticon_product + ADD COLUMN template_token VARCHAR(512); + +ALTER TABLE gifticon_product + ADD CONSTRAINT uk_gifticon_product_template_token + UNIQUE (template_token); diff --git a/manabom/src/main/resources/db/migration/V32__encrypt_gifticon_template_token.sql b/manabom/src/main/resources/db/migration/V32__encrypt_gifticon_template_token.sql new file mode 100644 index 0000000..000c6f7 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V32__encrypt_gifticon_template_token.sql @@ -0,0 +1,8 @@ +ALTER TABLE gifticon_product + DROP CONSTRAINT uk_gifticon_product_template_token; + +ALTER TABLE gifticon_product + RENAME COLUMN template_token TO encrypted_template_token; + +ALTER TABLE gifticon_product + ALTER COLUMN encrypted_template_token TYPE VARCHAR(1024); diff --git a/manabom/src/main/resources/db/migration/V33__create_gifticon_order.sql b/manabom/src/main/resources/db/migration/V33__create_gifticon_order.sql new file mode 100644 index 0000000..4008855 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V33__create_gifticon_order.sql @@ -0,0 +1,24 @@ +CREATE TABLE gifticon_order ( + gifticon_order_id BIGSERIAL PRIMARY KEY, + message_request_id BIGINT NOT NULL UNIQUE, + encrypted_template_token VARCHAR(1024) NOT NULL, + receiver_phone VARCHAR(30) NOT NULL, + receiver_name VARCHAR(100) NOT NULL, + external_key VARCHAR(70) NOT NULL UNIQUE, + external_order_id VARCHAR(70) NOT NULL UNIQUE, + status VARCHAR(30) NOT NULL, + attempt_count INTEGER NOT NULL DEFAULT 0, + last_attempt_at TIMESTAMPTZ, + requested_at TIMESTAMPTZ, + failure_reason VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT fk_gifticon_order_message_request + FOREIGN KEY (message_request_id) + REFERENCES message_request (id), + CONSTRAINT ck_gifticon_order_attempt_count + CHECK (attempt_count >= 0) +); + +CREATE INDEX idx_gifticon_order_retry + ON gifticon_order (status, attempt_count, created_at); diff --git a/manabom/src/main/resources/db/migration/V34__hold_paid_ting_for_gifticon.sql b/manabom/src/main/resources/db/migration/V34__hold_paid_ting_for_gifticon.sql new file mode 100644 index 0000000..8e34caf --- /dev/null +++ b/manabom/src/main/resources/db/migration/V34__hold_paid_ting_for_gifticon.sql @@ -0,0 +1,17 @@ +ALTER TABLE message_request + ADD COLUMN held_gift_ting INTEGER NOT NULL DEFAULT 0, + ADD COLUMN gift_payment_status VARCHAR(30); + +ALTER TABLE message_request + ADD CONSTRAINT ck_message_request_held_gift_ting + CHECK (held_gift_ting >= 0), + ADD CONSTRAINT ck_message_request_gift_payment_status + CHECK ( + (gifticon_product_id IS NULL + AND held_gift_ting = 0 + AND gift_payment_status IS NULL) + OR + (gifticon_product_id IS NOT NULL + AND held_gift_ting > 0 + AND gift_payment_status IN ('HELD', 'CAPTURED', 'RELEASED')) + ); diff --git a/manabom/src/main/resources/db/migration/V35__create_ting_transaction.sql b/manabom/src/main/resources/db/migration/V35__create_ting_transaction.sql new file mode 100644 index 0000000..98aea68 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V35__create_ting_transaction.sql @@ -0,0 +1,32 @@ +CREATE TABLE ting_transaction ( + ting_transaction_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + balance_type VARCHAR(20) NOT NULL, + transaction_type VARCHAR(50) NOT NULL, + amount_delta INTEGER NOT NULL, + balance_after INTEGER NOT NULL, + reference_type VARCHAR(50), + reference_id VARCHAR(100), + idempotency_key VARCHAR(150), + description VARCHAR(500), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT ck_ting_transaction_balance_type + CHECK (balance_type IN ('PAID', 'EVENT')), + CONSTRAINT ck_ting_transaction_balance_after + CHECK (balance_after >= 0), + CONSTRAINT ck_ting_transaction_amount + CHECK ( + amount_delta <> 0 + OR transaction_type = 'GIFTICON_CAPTURE' + ) +); + +CREATE INDEX idx_ting_transaction_user_created_at + ON ting_transaction (user_id, created_at DESC); + +CREATE INDEX idx_ting_transaction_reference + ON ting_transaction (reference_type, reference_id); + +CREATE UNIQUE INDEX uk_ting_transaction_idempotency_key + ON ting_transaction (idempotency_key) + WHERE idempotency_key IS NOT NULL; diff --git a/manabom/src/main/resources/db/migration/V36__remove_gifticon_order_template_token.sql b/manabom/src/main/resources/db/migration/V36__remove_gifticon_order_template_token.sql new file mode 100644 index 0000000..75b8ce7 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V36__remove_gifticon_order_template_token.sql @@ -0,0 +1,2 @@ +ALTER TABLE gifticon_order + DROP COLUMN encrypted_template_token; diff --git a/manabom/src/main/resources/db/migration/V37__add_gifticon_order_sender_nickname.sql b/manabom/src/main/resources/db/migration/V37__add_gifticon_order_sender_nickname.sql new file mode 100644 index 0000000..c4515eb --- /dev/null +++ b/manabom/src/main/resources/db/migration/V37__add_gifticon_order_sender_nickname.sql @@ -0,0 +1,17 @@ +ALTER TABLE gifticon_order + ADD COLUMN sender_nickname VARCHAR(255); + +UPDATE gifticon_order gift_order + SET sender_nickname = profile.nick_name + FROM message_request message_request + JOIN profile profile + ON profile.user_id = message_request.from_user_id + WHERE message_request.id = gift_order.message_request_id; + +UPDATE gifticon_order + SET sender_nickname = '만나봄 회원' + WHERE sender_nickname IS NULL + OR BTRIM(sender_nickname) = ''; + +ALTER TABLE gifticon_order + ALTER COLUMN sender_nickname SET NOT NULL; diff --git a/manabom/src/main/resources/db/migration/V38__replace_gifticon_ting_payment_with_toss.sql b/manabom/src/main/resources/db/migration/V38__replace_gifticon_ting_payment_with_toss.sql new file mode 100644 index 0000000..c345698 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V38__replace_gifticon_ting_payment_with_toss.sql @@ -0,0 +1,89 @@ +ALTER TABLE gifticon_product + ADD COLUMN sale_price INTEGER; + +UPDATE gifticon_product +SET sale_price = ( + CEIL((product_price::NUMERIC * 1.10) / 100) * 100 +)::INTEGER +WHERE sale_price IS NULL; + +ALTER TABLE gifticon_product + ALTER COLUMN sale_price SET NOT NULL, + ADD CONSTRAINT ck_gifticon_product_sale_price + CHECK (sale_price >= 0); + +ALTER TABLE gifticon_product + DROP COLUMN IF EXISTS ting_price, + DROP COLUMN IF EXISTS ting_price_manually_set; + +-- V32 이전의 평문 토큰은 애플리케이션이 시작되기 전에 제거한다. +-- 관리자가 토큰을 다시 등록하면 AES-GCM 암호문으로 저장된다. +UPDATE gifticon_product +SET encrypted_template_token = NULL +WHERE encrypted_template_token IS NOT NULL + AND encrypted_template_token NOT LIKE 'v1:%'; + +-- 배포 전에 HELD 상태의 기존 요청은 없으므로 팅 결제 컬럼을 제거한다. +ALTER TABLE message_request + DROP CONSTRAINT IF EXISTS ck_message_request_held_gift_ting, + DROP CONSTRAINT IF EXISTS ck_message_request_gift_payment_status, + DROP COLUMN IF EXISTS held_gift_ting, + DROP COLUMN IF EXISTS gift_payment_status; + +-- 0원 거래는 잔액 변화가 없는 과거 원장이므로 새 제약을 추가하기 전에 제거한다. +DELETE FROM ting_transaction +WHERE amount_delta = 0; + +ALTER TABLE ting_transaction + DROP CONSTRAINT IF EXISTS ck_ting_transaction_amount, + ADD CONSTRAINT ck_ting_transaction_amount + CHECK (amount_delta <> 0); + +CREATE TABLE gifticon_payment ( + gifticon_payment_id BIGSERIAL PRIMARY KEY, + user_id BIGINT NOT NULL, + gifticon_product_id BIGINT NOT NULL, + message_request_id BIGINT, + order_id VARCHAR(64) NOT NULL, + customer_key VARCHAR(64) NOT NULL, + payment_key VARCHAR(200), + amount INTEGER NOT NULL, + status VARCHAR(30) NOT NULL, + confirmation_started_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, + refund_attempt_count INTEGER NOT NULL DEFAULT 0, + last_refund_attempt_at TIMESTAMPTZ, + refunded_at TIMESTAMPTZ, + failure_reason VARCHAR(1000), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + CONSTRAINT uk_gifticon_payment_order_id UNIQUE (order_id), + CONSTRAINT uk_gifticon_payment_payment_key UNIQUE (payment_key), + CONSTRAINT uk_gifticon_payment_message_request UNIQUE (message_request_id), + CONSTRAINT fk_gifticon_payment_user + FOREIGN KEY (user_id) REFERENCES users (user_id), + CONSTRAINT fk_gifticon_payment_product + FOREIGN KEY (gifticon_product_id) + REFERENCES gifticon_product (gifticon_product_id), + CONSTRAINT fk_gifticon_payment_message_request + FOREIGN KEY (message_request_id) REFERENCES message_request (id), + CONSTRAINT ck_gifticon_payment_amount CHECK (amount > 0), + CONSTRAINT ck_gifticon_payment_refund_attempt_count + CHECK (refund_attempt_count >= 0), + CONSTRAINT ck_gifticon_payment_status CHECK (status IN ( + 'READY', + 'CONFIRMING', + 'PAID', + 'REFUND_PENDING', + 'REFUND_PROCESSING', + 'REFUNDED', + 'REFUND_FAILED' + )) +); + +CREATE INDEX idx_gifticon_payment_refund_retry + ON gifticon_payment (status, refund_attempt_count, last_refund_attempt_at); + +CREATE INDEX idx_gifticon_payment_unused_paid + ON gifticon_payment (approved_at, gifticon_payment_id) + WHERE status = 'PAID' AND message_request_id IS NULL; diff --git a/manabom/src/main/resources/db/migration/V39__add_gifticon_pricing_policy.sql b/manabom/src/main/resources/db/migration/V39__add_gifticon_pricing_policy.sql new file mode 100644 index 0000000..db60f49 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V39__add_gifticon_pricing_policy.sql @@ -0,0 +1,4 @@ +ALTER TABLE policy_config + ADD COLUMN gifticon_markup_percent NUMERIC(7, 3), + ADD CONSTRAINT ck_policy_config_gifticon_markup_percent + CHECK (gifticon_markup_percent IS NULL OR gifticon_markup_percent >= 0); diff --git a/manabom/src/main/resources/db/migration/V40__store_gifticon_message_intent.sql b/manabom/src/main/resources/db/migration/V40__store_gifticon_message_intent.sql new file mode 100644 index 0000000..20a97b6 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V40__store_gifticon_message_intent.sql @@ -0,0 +1,25 @@ +ALTER TABLE gifticon_payment + ADD COLUMN target_profile_id BIGINT NOT NULL, + ADD COLUMN message VARCHAR(200), + ADD COLUMN message_source VARCHAR(30) NOT NULL, + ADD COLUMN message_creation_status VARCHAR(30) NOT NULL DEFAULT 'PENDING', + ADD COLUMN message_creation_attempt_count INTEGER NOT NULL DEFAULT 0, + ADD COLUMN last_message_creation_attempt_at TIMESTAMPTZ, + ADD COLUMN message_creation_failure_reason VARCHAR(1000), + ADD CONSTRAINT fk_gifticon_payment_target_profile + FOREIGN KEY (target_profile_id) REFERENCES profile (profile_id), + ADD CONSTRAINT ck_gifticon_payment_message_source + CHECK (message_source IN ('PROFILE_MATCH', 'LOVE_VIEW_MATCH')), + ADD CONSTRAINT ck_gifticon_payment_message_creation_status + CHECK (message_creation_status IN ( + 'PENDING', + 'PROCESSING', + 'RETRY_PENDING', + 'CREATED', + 'FAILED' + )), + ADD CONSTRAINT ck_gifticon_payment_message_creation_attempt_count + CHECK (message_creation_attempt_count >= 0); + +CREATE INDEX idx_gifticon_payment_target_profile + ON gifticon_payment (target_profile_id); diff --git a/manabom/src/main/resources/db/migration/V41__support_chat_gifticon_payment.sql b/manabom/src/main/resources/db/migration/V41__support_chat_gifticon_payment.sql new file mode 100644 index 0000000..81624e7 --- /dev/null +++ b/manabom/src/main/resources/db/migration/V41__support_chat_gifticon_payment.sql @@ -0,0 +1,57 @@ +ALTER TABLE gifticon_payment + ADD COLUMN purpose VARCHAR(30) NOT NULL DEFAULT 'MESSAGE_REQUEST', + ADD COLUMN chat_room_id BIGINT, + ADD COLUMN receiver_user_id BIGINT, + ADD COLUMN chat_message_id BIGINT; + +ALTER TABLE gifticon_payment + ALTER COLUMN target_profile_id DROP NOT NULL, + ALTER COLUMN message_source DROP NOT NULL, + DROP CONSTRAINT ck_gifticon_payment_message_source, + ADD CONSTRAINT ck_gifticon_payment_message_source + CHECK (message_source IS NULL OR message_source IN ( + 'PROFILE_MATCH', + 'LOVE_VIEW_MATCH' + )), + ADD CONSTRAINT ck_gifticon_payment_purpose + CHECK (purpose IN ('MESSAGE_REQUEST', 'CHAT')), + ADD CONSTRAINT ck_gifticon_payment_intent + CHECK ( + (purpose = 'MESSAGE_REQUEST' + AND target_profile_id IS NOT NULL + AND message_source IS NOT NULL + AND chat_room_id IS NULL + AND receiver_user_id IS NULL) + OR + (purpose = 'CHAT' + AND target_profile_id IS NULL + AND message_source IS NULL + AND chat_room_id IS NOT NULL + AND receiver_user_id IS NOT NULL) + ), + ADD CONSTRAINT fk_gifticon_payment_chat_room + FOREIGN KEY (chat_room_id) REFERENCES chat_rooms (id), + ADD CONSTRAINT fk_gifticon_payment_receiver_user + FOREIGN KEY (receiver_user_id) REFERENCES users (user_id), + ADD CONSTRAINT fk_gifticon_payment_chat_message + FOREIGN KEY (chat_message_id) REFERENCES chat_messages (id), + ADD CONSTRAINT uk_gifticon_payment_chat_message UNIQUE (chat_message_id); + +CREATE INDEX idx_gifticon_payment_chat_room + ON gifticon_payment (chat_room_id, created_at DESC) + WHERE purpose = 'CHAT'; + +ALTER TABLE gifticon_order + ADD COLUMN gifticon_payment_id BIGINT; + +UPDATE gifticon_order gift_order +SET gifticon_payment_id = payment.gifticon_payment_id +FROM gifticon_payment payment +WHERE payment.message_request_id = gift_order.message_request_id; + +ALTER TABLE gifticon_order + ALTER COLUMN message_request_id DROP NOT NULL, + ADD CONSTRAINT fk_gifticon_order_payment + FOREIGN KEY (gifticon_payment_id) + REFERENCES gifticon_payment (gifticon_payment_id), + ADD CONSTRAINT uk_gifticon_order_payment UNIQUE (gifticon_payment_id); diff --git a/manabom/src/main/resources/static/admin/app.js b/manabom/src/main/resources/static/admin/app.js index c7d129e..a431d05 100644 --- a/manabom/src/main/resources/static/admin/app.js +++ b/manabom/src/main/resources/static/admin/app.js @@ -17,7 +17,30 @@ const state = { selectedReportId: null, auditPage: 0, auditSize: 30, - isActivatingMembership: false + isActivatingMembership: false, + selectedGifticonId: null, + gifticonCursor: null, + gifticonCursorHistory: [], + gifticonNextCursor: null, + gifticonPage: 0, + gifticonSize: 20, + gifticonKeyword: "", + gifticonTokenConfigured: null, + gifticonItems: [], + isSavingGifticonToken: false, + isSynchronizingGifticons: false, + paymentPage: 0, + paymentSize: 20, + paymentStatus: "", + messageCreationStatus: "", + messageRequestStatus: "", + paymentUserId: "", + paymentOrderId: "", + paymentAttentionOnly: true, + paymentItems: [], + selectedPaymentId: null, + selectedPayment: null, + isProcessingPayment: false }; const policyKeys = [ @@ -40,7 +63,8 @@ const policyKeys = [ "benefit.membership.cycleFreeLikes", "benefit.vip.dailyExtraProfiles", "benefit.vip.dailyFreeMessages", - "benefit.vip.dailyFreeLikes" + "benefit.vip.dailyFreeLikes", + "gifticon.pricing.markupPercent" ]; const roleOptions = [ @@ -71,7 +95,8 @@ const policyLabels = { "benefit.membership.cycleFreeLikes": "멤버십 주기별 무료 호감", "benefit.vip.dailyExtraProfiles": "VIP 일일 추가 프로필", "benefit.vip.dailyFreeMessages": "VIP 일일 무료 메시지", - "benefit.vip.dailyFreeLikes": "VIP 일일 무료 호감" + "benefit.vip.dailyFreeLikes": "VIP 일일 무료 호감", + "gifticon.pricing.markupPercent": "기프티콘 판매 마진율(%)" }; const $ = (id) => document.getElementById(id); @@ -160,6 +185,67 @@ function bindEvents() { state.auditPage += 1; loadAudits(); }); + $("gifticonTokenForm").addEventListener("submit", saveGifticonToken); + $("synchronizeGifticonsButton").addEventListener("click", synchronizeGifticons); + $("gifticonSearchButton").addEventListener("click", applyGifticonFilters); + $("gifticonKeyword").addEventListener("keydown", (event) => { + if (event.key === "Enter") { + applyGifticonFilters(); + } + }); + $("gifticonTokenFilter").addEventListener("change", applyGifticonFilters); + $("gifticonPageSize").addEventListener("change", () => { + state.gifticonSize = numberOrZero($("gifticonPageSize").value) || 20; + resetGifticonPagination(); + loadGifticons(); + }); + $("clearGifticonFilterButton").addEventListener("click", clearGifticonFilters); + $("prevGifticonPageButton").addEventListener("click", () => { + if (state.gifticonCursorHistory.length === 0) { + return; + } + state.gifticonCursor = state.gifticonCursorHistory.pop(); + state.gifticonPage = Math.max(0, state.gifticonPage - 1); + loadGifticons(); + }); + $("nextGifticonPageButton").addEventListener("click", () => { + if (state.gifticonNextCursor === null) { + return; + } + state.gifticonCursorHistory.push(state.gifticonCursor); + state.gifticonCursor = state.gifticonNextCursor; + state.gifticonPage += 1; + loadGifticons(); + }); + $("paymentSearchButton").addEventListener("click", applyPaymentFilters); + $("paymentOrderIdFilter").addEventListener("keydown", (event) => { + if (event.key === "Enter") applyPaymentFilters(); + }); + $("paymentUserIdFilter").addEventListener("keydown", (event) => { + if (event.key === "Enter") applyPaymentFilters(); + }); + $("paymentStatusFilter").addEventListener("change", applyPaymentFilters); + $("messageCreationStatusFilter").addEventListener("change", applyPaymentFilters); + $("messageRequestStatusFilter").addEventListener("change", applyPaymentFilters); + $("paymentAttentionOnly").addEventListener("change", applyPaymentFilters); + $("paymentPageSize").addEventListener("change", () => { + state.paymentSize = numberOrZero($("paymentPageSize").value) || 20; + state.paymentPage = 0; + loadGifticonPayments(); + }); + $("clearPaymentFilterButton").addEventListener("click", clearPaymentFilters); + $("prevPaymentPageButton").addEventListener("click", () => { + if (state.paymentPage > 0) { + state.paymentPage -= 1; + loadGifticonPayments(); + } + }); + $("nextPaymentPageButton").addEventListener("click", () => { + state.paymentPage += 1; + loadGifticonPayments(); + }); + $("retryPaymentMessageButton").addEventListener("click", retryPaymentMessage); + $("forceRefundPaymentButton").addEventListener("click", forceRefundPayment); renderRoleCards([]); document.querySelectorAll(".nav-item").forEach((button) => { button.addEventListener("click", () => switchView(button.dataset.view)); @@ -206,6 +292,12 @@ async function showAdmin() { document.querySelectorAll(".super-only").forEach((element) => { element.classList.toggle("hidden", !(state.admin.roles || []).includes("SUPER_ADMIN")); }); + document.querySelectorAll(".payment-read").forEach((element) => { + element.classList.toggle( + "hidden", + !hasAnyAdminRole("SUPER_ADMIN", "FINANCE", "SUPPORT") + ); + }); $("loginView").classList.add("hidden"); $("adminView").classList.remove("hidden"); await loadUsers(); @@ -240,6 +332,8 @@ function switchView(viewId) { }); const titles = { usersView: "회원 관리", + gifticonsView: "기프티콘 관리", + gifticonPaymentsView: "기프티콘 결제 관리", policiesView: "운영 정책", pushView: "푸시 알림", reportsView: "신고/CS 처리", @@ -252,7 +346,14 @@ function switchView(viewId) { } function refreshCurrentView() { - if (!$("auditsView").classList.contains("hidden")) { + if (!$("gifticonPaymentsView").classList.contains("hidden")) { + loadGifticonPayments(); + if (state.selectedPaymentId) { + loadGifticonPaymentDetail(state.selectedPaymentId); + } + } else if (!$("gifticonsView").classList.contains("hidden")) { + loadGifticons(); + } else if (!$("auditsView").classList.contains("hidden")) { loadAudits(); } else if (!$("reportsView").classList.contains("hidden")) { loadReports(); @@ -275,6 +376,493 @@ function refreshCurrentView() { } } +function applyGifticonFilters() { + state.gifticonKeyword = $("gifticonKeyword").value.trim(); + const tokenFilter = $("gifticonTokenFilter").value; + state.gifticonTokenConfigured = tokenFilter === "" ? null : tokenFilter === "true"; + resetGifticonPagination(); + loadGifticons(); +} + +function clearGifticonFilters() { + $("gifticonKeyword").value = ""; + $("gifticonTokenFilter").value = ""; + $("gifticonPageSize").value = "20"; + state.gifticonKeyword = ""; + state.gifticonTokenConfigured = null; + state.gifticonSize = 20; + resetGifticonPagination(); + loadGifticons(); +} + +function resetGifticonPagination() { + state.gifticonCursor = null; + state.gifticonCursorHistory = []; + state.gifticonNextCursor = null; + state.gifticonPage = 0; +} + +async function synchronizeGifticons() { + if (state.isSynchronizingGifticons) { + return; + } + + state.isSynchronizingGifticons = true; + const button = $("synchronizeGifticonsButton"); + const resultElement = $("gifticonSyncResult"); + button.disabled = true; + button.textContent = "동기화 중..."; + resultElement.textContent = "카카오 Gift Biz에서 활성 템플릿을 조회하고 있습니다."; + resultElement.classList.remove("error-text"); + + try { + const result = await request("/api/admin/gifticons/synchronize", { + method: "POST" + }); + resultElement.textContent = + `${formatNumber(result.synchronizedCount)}개 동기화 완료 · ${formatDate(result.synchronizedAt)}`; + resetGifticonPagination(); + await loadGifticons(); + } catch (error) { + resultElement.textContent = `동기화 실패: ${error.message}`; + resultElement.classList.add("error-text"); + } finally { + state.isSynchronizingGifticons = false; + button.disabled = false; + button.textContent = "카카오에서 지금 동기화"; + } +} + +async function loadGifticons() { + if (!(state.admin?.roles || []).includes("SUPER_ADMIN")) { + return; + } + + const query = new URLSearchParams({ + size: String(state.gifticonSize) + }); + if (state.gifticonCursor !== null) { + query.set("cursor", String(state.gifticonCursor)); + } + if (state.gifticonTokenConfigured !== null) { + query.set("tokenConfigured", String(state.gifticonTokenConfigured)); + } + if (state.gifticonKeyword) { + query.set("keyword", state.gifticonKeyword); + } + + try { + const data = await request(`/api/admin/gifticons?${query.toString()}`); + state.gifticonItems = data.contents || []; + state.gifticonNextCursor = data.hasNext ? data.nextCursor : null; + renderGifticons(data); + } catch (error) { + $("gifticonListInfo").textContent = `목록 조회 실패: ${error.message}`; + $("gifticonListInfo").classList.add("error-text"); + } +} + +function renderGifticons(data) { + const items = data.contents || []; + $("gifticonListInfo").classList.remove("error-text"); + $("gifticonListInfo").textContent = + `${items.length}개 표시 · ${state.gifticonPage + 1}페이지`; + $("gifticonPageInfo").textContent = `${state.gifticonPage + 1} 페이지`; + $("prevGifticonPageButton").disabled = state.gifticonCursorHistory.length === 0; + $("nextGifticonPageButton").disabled = !data.hasNext; + + $("gifticonsTable").innerHTML = items.length > 0 + ? items.map((product) => ` + + +
+ ${gifticonThumbnail(product)} +
+ ${escapeHtml(product.productName || "-")} + ${escapeHtml(product.templateName || "-")} +
+
+ + ${escapeHtml(product.brandName || "-")} + ${formatNumber(product.productPrice)}원 + ${formatNumber(product.salePrice)}원 + ${product.available ? statusBadge("ALIVE") : statusBadge("INACTIVE")} + ${gifticonTokenBadge(product.templateTokenConfigured)} + + `).join("") + : `조건에 맞는 기프티콘이 없습니다.`; + + document.querySelectorAll("tr[data-gifticon-id]").forEach((row) => { + row.addEventListener("click", () => { + const product = state.gifticonItems.find( + (item) => String(item.gifticonProductId) === row.dataset.gifticonId + ); + if (product) { + selectGifticon(product); + } + }); + }); +} + +function selectGifticon(product) { + state.selectedGifticonId = product.gifticonProductId; + document.querySelectorAll("tr[data-gifticon-id]").forEach((row) => { + row.classList.toggle( + "selected", + row.dataset.gifticonId === String(product.gifticonProductId) + ); + }); + renderGifticonDetail(product); +} + +function renderGifticonDetail(product) { + $("selectedGifticonLabel").textContent = `상품 ID ${product.gifticonProductId}`; + $("gifticonDetail").className = "detail-body"; + $("gifticonDetail").innerHTML = ` +
+ ${gifticonThumbnail(product, true)} +
+ ${escapeHtml(product.productName || "-")} + ${escapeHtml(product.brandName || "-")} +
+
+
+ ${kv("템플릿명", product.templateName || "-")} + ${kv("내부 상품 ID", product.gifticonProductId)} + ${kv("Trace ID", product.templateTraceId || "-")} + ${kv("상품 가격", `${formatNumber(product.productPrice)}원`)} + ${kv("판매 가격", `${formatNumber(product.salePrice)}원`)} + ${kvHtml("판매 상태", product.available ? statusBadge("ALIVE") : statusBadge("INACTIVE"))} + ${kvHtml("토큰 상태", gifticonTokenBadge(product.templateTokenConfigured))} + ${kv("마지막 동기화", formatDate(product.lastSyncedAt))} +
+ `; + + $("gifticonIdInput").value = product.gifticonProductId; + $("gifticonTokenInput").value = ""; + $("gifticonTokenReason").value = ""; + $("gifticonTokenResult").textContent = ""; + $("gifticonTokenResult").classList.remove("error-text"); + $("gifticonTokenSaveButton").textContent = + product.templateTokenConfigured ? "토큰 교체" : "암호화하여 저장"; + $("gifticonTokenForm").classList.remove("hidden"); +} + +async function saveGifticonToken(event) { + event.preventDefault(); + if (!state.selectedGifticonId || state.isSavingGifticonToken) { + return; + } + + const selected = state.gifticonItems.find( + (item) => String(item.gifticonProductId) === String(state.selectedGifticonId) + ); + if (selected?.templateTokenConfigured + && !confirm("이미 등록된 템플릿 토큰을 새 값으로 교체할까요?")) { + return; + } + + const token = $("gifticonTokenInput").value.trim(); + const reason = $("gifticonTokenReason").value.trim(); + if (!token || !reason) { + showGifticonTokenResult("토큰과 등록/변경 사유를 모두 입력하세요.", true); + return; + } + + state.isSavingGifticonToken = true; + $("gifticonTokenSaveButton").disabled = true; + showGifticonTokenResult(""); + try { + const updated = await request( + `/api/admin/gifticons/${state.selectedGifticonId}/template-token`, + { + method: "PUT", + body: { + templateToken: token, + reason + } + } + ); + $("gifticonTokenInput").value = ""; + $("gifticonTokenReason").value = ""; + renderGifticonDetail(updated); + showGifticonTokenResult("템플릿 토큰을 암호화하여 저장했습니다."); + await loadGifticons(); + } catch (error) { + showGifticonTokenResult(`저장 실패: ${error.message}`, true); + } finally { + state.isSavingGifticonToken = false; + $("gifticonTokenSaveButton").disabled = false; + } +} + +function showGifticonTokenResult(message, error = false) { + $("gifticonTokenResult").textContent = message; + $("gifticonTokenResult").classList.toggle("error-text", error); +} + +function gifticonThumbnail(product, large = false) { + if (!product.productThumbnailImageUrl) { + return `선물`; + } + return ` + + `; +} + +function gifticonTokenBadge(configured) { + return configured + ? `등록 완료` + : `미등록`; +} + +function applyPaymentFilters() { + state.paymentStatus = $("paymentStatusFilter").value; + state.messageCreationStatus = $("messageCreationStatusFilter").value; + state.messageRequestStatus = $("messageRequestStatusFilter").value; + state.paymentUserId = $("paymentUserIdFilter").value.trim(); + state.paymentOrderId = $("paymentOrderIdFilter").value.trim(); + state.paymentAttentionOnly = $("paymentAttentionOnly").checked; + state.paymentPage = 0; + loadGifticonPayments(); +} + +function clearPaymentFilters() { + $("paymentStatusFilter").value = ""; + $("messageCreationStatusFilter").value = ""; + $("messageRequestStatusFilter").value = ""; + $("paymentUserIdFilter").value = ""; + $("paymentOrderIdFilter").value = ""; + $("paymentAttentionOnly").checked = true; + $("paymentPageSize").value = "20"; + state.paymentStatus = ""; + state.messageCreationStatus = ""; + state.messageRequestStatus = ""; + state.paymentUserId = ""; + state.paymentOrderId = ""; + state.paymentAttentionOnly = true; + state.paymentSize = 20; + state.paymentPage = 0; + loadGifticonPayments(); +} + +async function loadGifticonPayments() { + const query = new URLSearchParams({ + page: String(state.paymentPage), + size: String(state.paymentSize), + attentionOnly: String(state.paymentAttentionOnly) + }); + if (state.paymentStatus) query.set("paymentStatus", state.paymentStatus); + if (state.messageCreationStatus) { + query.set("messageCreationStatus", state.messageCreationStatus); + } + if (state.messageRequestStatus) { + query.set("messageRequestStatus", state.messageRequestStatus); + } + if (state.paymentUserId) query.set("userId", state.paymentUserId); + if (state.paymentOrderId) query.set("orderId", state.paymentOrderId); + + try { + const data = await request(`/api/admin/gifticon-payments?${query.toString()}`); + state.paymentItems = data.contents || []; + renderGifticonPayments(data); + } catch (error) { + $("paymentListInfo").textContent = `목록 조회 실패: ${error.message}`; + $("paymentListInfo").classList.add("error-text"); + } +} + +function renderGifticonPayments(data) { + const items = data.contents || []; + $("paymentListInfo").classList.remove("error-text"); + $("paymentListInfo").textContent = `${formatNumber(data.totalCount || 0)}건`; + $("paymentPageInfo").textContent = + `${data.page + 1} / ${Math.max(data.totalPages || 1, 1)} 페이지`; + $("prevPaymentPageButton").disabled = data.page <= 0; + $("nextPaymentPageButton").disabled = data.page + 1 >= data.totalPages; + $("paymentsTable").innerHTML = items.length + ? items.map((payment) => ` + + + #${escapeHtml(payment.gifticonPaymentId)}
+ ${escapeHtml(payment.orderId || "-")} + + ${escapeHtml(payment.userId)} + ${formatNumber(payment.amount)}원 + ${statusBadge(payment.paymentStatus)} + ${statusBadge(payment.messageCreationStatus)} + ${paymentAttentionHtml(payment.attentionReasons)} + + `).join("") + : `조건에 맞는 결제가 없습니다.`; + + document.querySelectorAll("tr[data-payment-id]").forEach((row) => { + row.addEventListener("click", () => loadGifticonPaymentDetail(row.dataset.paymentId)); + }); +} + +async function loadGifticonPaymentDetail(paymentId) { + state.selectedPaymentId = paymentId; + $("selectedPaymentLabel").textContent = `결제 ID ${paymentId}`; + $("paymentDetail").className = "detail-empty"; + $("paymentDetail").textContent = "토스 결제 상태를 확인하는 중입니다."; + try { + const payment = await request(`/api/admin/gifticon-payments/${paymentId}`); + state.selectedPayment = payment; + renderGifticonPaymentDetail(payment); + } catch (error) { + $("paymentDetail").className = "detail-empty error-text"; + $("paymentDetail").textContent = `상세 조회 실패: ${error.message}`; + $("paymentActions").classList.add("hidden"); + } +} + +function renderGifticonPaymentDetail(payment) { + const mismatch = payment.tossStatusMismatch === true; + const tossLabel = payment.tossVerificationError + ? `조회 실패: ${payment.tossVerificationError}` + : payment.tossPaymentStatus || "확인 불가"; + $("paymentDetail").className = "detail-body"; + $("paymentDetail").innerHTML = ` +
+

결제

+ ${kv("결제 ID", payment.gifticonPaymentId)} + ${kv("Order ID", payment.orderId)} + ${kv("Payment Key", payment.maskedPaymentKey || "-")} + ${kv("구매자 userId", payment.userId)} + ${kv("대상 profileId", payment.targetProfileId)} + ${kv("상품", `${payment.productName || "-"} (#${payment.gifticonProductId})`)} + ${kv("결제 금액", `${formatNumber(payment.amount)}원`)} + ${kvHtml("서버 결제 상태", statusBadge(payment.paymentStatus))} + ${kv("상태 설명", payment.paymentStatusDescription || "-")} + ${kv("승인 시각", formatDate(payment.approvedAt))} + ${kv("환불 시각", formatDate(payment.refundedAt))} +
+
+

메시지 생성

+ ${kvHtml("생성 상태", statusBadge(payment.messageCreationStatus))} + ${kv("상태 설명", payment.messageCreationStatusDescription || "-")} + ${kv("시도 횟수", payment.messageCreationAttemptCount)} + ${kv("마지막 시도", formatDate(payment.lastMessageCreationAttemptAt))} + ${kv("실패 사유", payment.messageCreationFailureReason || "-")} + ${kv("메시지 요청 ID", payment.messageRequestId || "-")} + ${kvHtml("메시지 요청 상태", payment.messageRequestStatus ? statusBadge(payment.messageRequestStatus) : "-")} +
+
+

환불 · 처리 필요

+ ${kv("환불 시도 횟수", payment.refundAttemptCount)} + ${kv("마지막 환불 시도", formatDate(payment.lastRefundAttemptAt))} + ${kv("결제 처리 실패 사유", payment.paymentFailureReason || "-")} + ${kvHtml("처리 필요", paymentAttentionHtml(payment.attentionReasons))} +
+
+

토스페이먼츠 실시간 대조

+ ${kv("토스 상태", tossLabel)} + ${kvHtml("일치 여부", payment.tossStatusMismatch == null + ? `확인 실패` + : mismatch + ? `불일치 · 처리 필요` + : `일치`)} +
+ `; + renderPaymentActions(payment); +} + +function renderPaymentActions(payment) { + const canRetry = hasAnyAdminRole("SUPER_ADMIN", "OPERATOR") + && payment.paymentStatus === "PAID" + && !payment.messageRequestId; + const refundableStatuses = new Set([ + "PAID", + "REFUND_PENDING", + "REFUND_PROCESSING", + "REFUND_FAILED" + ]); + const canRefund = hasAnyAdminRole("SUPER_ADMIN", "FINANCE") + && !payment.messageRequestId + && refundableStatuses.has(payment.paymentStatus); + $("paymentActions").classList.toggle("hidden", !canRetry && !canRefund); + $("retryPaymentMessageButton").classList.toggle("hidden", !canRetry); + $("forceRefundPaymentButton").classList.toggle("hidden", !canRefund); + $("paymentActionResult").textContent = ""; +} + +async function retryPaymentMessage() { + await executePaymentAction( + "retry-message", + "메시지 생성을 다시 시도할까요?", + "메시지 생성 재시도를 요청했습니다." + ); +} + +async function forceRefundPayment() { + await executePaymentAction( + "refund", + "토스 결제를 강제로 환불할까요? 이 작업은 되돌릴 수 없습니다.", + "강제 환불을 요청했습니다." + ); +} + +async function executePaymentAction(path, confirmation, successMessage) { + if (!state.selectedPaymentId || state.isProcessingPayment) return; + const reason = $("paymentActionReason").value.trim(); + if (!reason) { + showPaymentActionResult("관리자 처리 사유를 입력하세요.", true); + return; + } + if (!confirm(confirmation)) return; + + state.isProcessingPayment = true; + $("retryPaymentMessageButton").disabled = true; + $("forceRefundPaymentButton").disabled = true; + try { + const payment = await request( + `/api/admin/gifticon-payments/${state.selectedPaymentId}/${path}`, + { method: "POST", body: { reason } } + ); + state.selectedPayment = payment; + renderGifticonPaymentDetail(payment); + $("paymentActionReason").value = ""; + showPaymentActionResult(successMessage); + await loadGifticonPayments(); + } catch (error) { + await loadGifticonPaymentDetail(state.selectedPaymentId); + showPaymentActionResult(`처리 실패: ${error.message}`, true); + } finally { + state.isProcessingPayment = false; + $("retryPaymentMessageButton").disabled = false; + $("forceRefundPaymentButton").disabled = false; + } +} + +function showPaymentActionResult(message, error = false) { + $("paymentActionResult").textContent = message; + $("paymentActionResult").classList.toggle("error-text", error); +} + +function paymentAttentionHtml(reasons) { + if (!reasons?.length) return `없음`; + return `
${reasons.map((reason) => + `${escapeHtml(reason.code)}` + ).join("")}
`; +} + +function hasAnyAdminRole(...roles) { + const current = new Set(state.admin?.roles || []); + return roles.some((role) => current.has(role)); +} + async function loadUsers() { const query = new URLSearchParams({ page: String(state.page), @@ -524,7 +1112,7 @@ function renderPolicies(items) { ${escapeHtml(policyLabels[key] || key)} ${escapeHtml(key)} - + ${reasonInline("policy", ["정책 조정", "이벤트 대응", "운영 테스트", "기타"])}
@@ -1238,8 +1826,12 @@ function auditActionLabel(actionType) { WALLET_ADJUST: "팅 지갑 조정", MEMBERSHIP_ACTIVATE: "멤버십 활성화", POLICY_UPDATE: "운영 정책 변경", + GIFTICON_CATALOG_SYNC: "기프티콘 상품 동기화", PUSH_SEND: "푸시 발송", - REPORT_PROCESS: "신고 처리" + REPORT_PROCESS: "신고 처리", + GIFTICON_TEMPLATE_TOKEN_UPDATE: "기프티콘 토큰 등록/변경", + GIFTICON_PAYMENT_MESSAGE_RETRY: "기프티콘 메시지 생성 재시도", + GIFTICON_PAYMENT_FORCE_REFUND: "기프티콘 결제 강제 환불" }; return labels[actionType] || actionType || "-"; } @@ -1251,7 +1843,9 @@ function auditTargetLabel(targetType) { TING_WALLET: "팅 지갑", POLICY: "운영 정책", PUSH: "푸시", - REPORT: "신고" + REPORT: "신고", + GIFTICON_PRODUCT: "기프티콘 상품", + GIFTICON_PAYMENT: "기프티콘 결제" }; return labels[targetType] || targetType || "-"; } @@ -1287,6 +1881,11 @@ function formatDate(value) { return new Date(value).toLocaleString("ko-KR"); } +function formatNumber(value) { + const number = Number(value); + return Number.isFinite(number) ? number.toLocaleString("ko-KR") : "-"; +} + function escapeHtml(value) { return String(value ?? "") .replaceAll("&", "&") diff --git a/manabom/src/main/resources/static/admin/index.html b/manabom/src/main/resources/static/admin/index.html index 7c7b655..9b09d06 100644 --- a/manabom/src/main/resources/static/admin/index.html +++ b/manabom/src/main/resources/static/admin/index.html @@ -36,6 +36,8 @@

관리자 로그인