From f4aa92f9c54157b3abb20f7b68b333947adc969c Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 23:31:45 +0900 Subject: [PATCH 01/48] =?UTF-8?q?chore:=20=EB=8D=B0=EC=9D=B4=ED=84=B0?= =?UTF-8?q?=EB=B2=A0=EC=9D=B4=EC=8A=A4=20=EB=A7=88=EC=9D=B4=EA=B7=B8?= =?UTF-8?q?=EB=A0=88=EC=9D=B4=EC=85=98=20V21~V27=20=EB=B3=B5=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../V21__add_meeting_cancellation_tables.sql | 85 +++++++++++++++++++ ...22__move_meeting_cancellation_to_match.sql | 25 ++++++ ...__remove_withdrawn_cancellation_status.sql | 11 +++ .../V24__add_unique_active_chat_member.sql | 3 + ...ting_verification_failure_notification.sql | 6 ++ ...__add_structured_system_message_fields.sql | 22 +++++ ...7__persist_meeting_verification_result.sql | 6 ++ 7 files changed, 158 insertions(+) create mode 100644 manabom/src/main/resources/db/migration/V21__add_meeting_cancellation_tables.sql create mode 100644 manabom/src/main/resources/db/migration/V22__move_meeting_cancellation_to_match.sql create mode 100644 manabom/src/main/resources/db/migration/V23__remove_withdrawn_cancellation_status.sql create mode 100644 manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql create mode 100644 manabom/src/main/resources/db/migration/V25__add_meeting_verification_failure_notification.sql create mode 100644 manabom/src/main/resources/db/migration/V26__add_structured_system_message_fields.sql create mode 100644 manabom/src/main/resources/db/migration/V27__persist_meeting_verification_result.sql 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..4c4d39d --- /dev/null +++ b/manabom/src/main/resources/db/migration/V24__add_unique_active_chat_member.sql @@ -0,0 +1,3 @@ +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; From 4d96c4cd3a23be7812337a9bfacbbb1586de26b4 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 23:32:24 +0900 Subject: [PATCH 02/48] =?UTF-8?q?feat:=20Gift=20Biz=20=EC=83=81=ED=92=88?= =?UTF-8?q?=20=EB=8F=99=EA=B8=B0=ED=99=94=20=EB=B0=8F=20=EB=AA=A9=EB=A1=9D?= =?UTF-8?q?=20=EC=A1=B0=ED=9A=8C=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/GifticonProductResponse.java | 39 ++++ .../GifticonProductSliceResponse.java | 10 + .../port/GifticonTemplateProvider.java | 10 + .../GifticonCatalogSyncScheduler.java | 42 +++++ .../service/GifticonCatalogService.java | 164 ++++++++++++++++ .../gifticon/entity/GifticonProduct.java | 153 +++++++++++++++ .../repository/GifticonProductRepository.java | 44 +++++ .../service/GifticonPriceCalculator.java | 51 +++++ .../gifticon/vo/GifticonTemplateSnapshot.java | 24 +++ .../config/GifticonPricingConfiguration.java | 23 +++ .../kakao/giftbiz/KakaoGiftbizClient.java | 99 ++++++++++ .../giftbiz/KakaoGiftbizTemplateMapper.java | 55 ++++++ .../giftbiz/config/GiftbizProperties.java | 29 +++ .../giftbiz/dto/KakaoGiftbizTemplatePage.java | 42 +++++ .../manabom/policy/config/ConfigRegister.java | 3 +- .../config/GifticonPricingProperties.java | 17 ++ .../controller/GifticonController.java | 29 +++ manabom/src/main/resources/application.yml | 17 ++ .../V28__create_gifticon_product.sql | 35 ++++ .../V29__allow_null_gifticon_sales_period.sql | 3 + .../service/GifticonCatalogServiceTest.java | 178 ++++++++++++++++++ .../service/GifticonPriceCalculatorTest.java | 23 +++ .../KakaoGiftbizTemplateMapperTest.java | 54 ++++++ .../dto/KakaoGiftbizTemplatePageTest.java | 54 ++++++ 24 files changed, 1197 insertions(+), 1 deletion(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/dto/response/GifticonProductSliceResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTemplateProvider.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogService.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonProductRepository.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculator.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/vo/GifticonTemplateSnapshot.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizClient.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePage.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/policy/config/GifticonPricingProperties.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/presentation/gifticon/controller/GifticonController.java create mode 100644 manabom/src/main/resources/db/migration/V28__create_gifticon_product.sql create mode 100644 manabom/src/main/resources/db/migration/V29__allow_null_gifticon_sales_period.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogServiceTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculatorTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapperTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePageTest.java 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..3516824 --- /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 tingPrice, + 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.getTingPrice(), + 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/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/scheduler/GifticonCatalogSyncScheduler.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java new file mode 100644 index 0000000..5a93188 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonCatalogSyncScheduler.java @@ -0,0 +1,42 @@ +package mannabom_server.manabom.application.gifticon.scheduler; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.port.GifticonTemplateProvider; +import mannabom_server.manabom.application.gifticon.service.GifticonCatalogService; +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.stereotype.Component; + +import java.util.List; + +@Slf4j +@Component +@RequiredArgsConstructor +@ConditionalOnProperty( + prefix = "app.kakao.giftbiz.sync", + name = "enabled", + havingValue = "true" +) +public class GifticonCatalogSyncScheduler { + + private final GifticonTemplateProvider gifticonTemplateProvider; + private final GifticonCatalogService gifticonCatalogService; + + @Scheduled( + initialDelayString = "${app.kakao.giftbiz.sync.initial-delay:10000}", + fixedDelayString = "${app.kakao.giftbiz.sync.fixed-delay:1800000}" + ) + public void synchronize() { + try { + List templates = + gifticonTemplateProvider.findAliveTemplates(); + int synchronizedCount = + gifticonCatalogService.synchronizeAliveTemplates(templates); + log.info("[Gift Biz] 템플릿 DB 동기화 완료: {}개", synchronizedCount); + } catch (Exception e) { + log.error("[Gift Biz] 템플릿 동기화 실패. 기존 상품 DB를 유지합니다.", e); + } + } +} 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..786551e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogService.java @@ -0,0 +1,164 @@ +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 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 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.calculateTingPrice(template.productPrice()), + 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/domain/gifticon/entity/GifticonProduct.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java new file mode 100644 index 0000000..70a9c7b --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java @@ -0,0 +1,153 @@ +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 = "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 = "ting_price", nullable = false) + private int tingPrice; + + @Column(name = "ting_price_manually_set", nullable = false) + private boolean tingPriceManuallySet; + + @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 calculatedTingPrice, + 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; + if (!this.tingPriceManuallySet) { + this.tingPrice = calculatedTingPrice; + } + this.available = "ALIVE".equals(orderTemplateStatus); + this.lastSyncedAt = syncedAt; + } + + public void changeTingPrice(int tingPrice) { + if (tingPrice <= 0) { + throw new IllegalArgumentException("팅 판매가는 0보다 커야 합니다."); + } + this.tingPrice = tingPrice; + this.tingPriceManuallySet = true; + } + + public void useCalculatedTingPrice(int calculatedTingPrice) { + if (calculatedTingPrice < 0) { + throw new IllegalArgumentException("계산된 팅 판매가는 0 이상이어야 합니다."); + } + this.tingPrice = calculatedTingPrice; + this.tingPriceManuallySet = false; + } +} 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..8b890bc --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonProductRepository.java @@ -0,0 +1,44 @@ +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.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 + ); +} 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..8cd68ef --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculator.java @@ -0,0 +1,51 @@ +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 wonPerTing; + private final BigDecimal markupPercent; + private final int roundUnit; + + public GifticonPriceCalculator( + BigDecimal wonPerTing, + BigDecimal markupPercent, + int roundUnit + ) { + if (wonPerTing == null || wonPerTing.signum() <= 0) { + throw new IllegalArgumentException("won-per-ting은 0보다 커야 합니다."); + } + if (markupPercent == null || markupPercent.signum() < 0) { + throw new IllegalArgumentException("markup-percent는 0 이상이어야 합니다."); + } + if (roundUnit <= 0) { + throw new IllegalArgumentException("round-unit은 1 이상이어야 합니다."); + } + this.wonPerTing = wonPerTing; + this.markupPercent = markupPercent; + this.roundUnit = roundUnit; + } + + public int calculateTingPrice(int productPrice) { + if (productPrice < 0) { + throw new IllegalArgumentException("기프티콘 원화 가격은 0 이상이어야 합니다."); + } + + BigDecimal markupMultiplier = BigDecimal.ONE.add( + markupPercent.divide(ONE_HUNDRED, 10, RoundingMode.HALF_UP) + ); + BigDecimal rawTingPrice = BigDecimal.valueOf(productPrice) + .multiply(markupMultiplier) + .divide(wonPerTing, 10, RoundingMode.CEILING); + + BigDecimal roundedUnits = rawTingPrice + .divide(BigDecimal.valueOf(roundUnit), 0, RoundingMode.CEILING); + return roundedUnits + .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/infrastructure/config/GifticonPricingConfiguration.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java new file mode 100644 index 0000000..eb109c1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/config/GifticonPricingConfiguration.java @@ -0,0 +1,23 @@ +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.getWonPerTing(), + 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..10f2798 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizClient.java @@ -0,0 +1,99 @@ +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 templates.stream() + .map(templateMapper::toSnapshot) + .toList(); + } + if (contents.isEmpty()) { + throw new IllegalStateException( + "Gift Biz 템플릿 API가 last=false 상태에서 빈 페이지를 반환했습니다: " + page + ); + } + } + + throw new IllegalStateException( + "Gift Biz 템플릿 조회가 최대 페이지 수를 초과했습니다: " + maxPages + ); + } + + 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/KakaoGiftbizTemplateMapper.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java new file mode 100644 index 0000000..589b4cf --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapper.java @@ -0,0 +1,55 @@ +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) { + Product product = template.product(); + 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 == null ? null : product.itemType(), + product == null ? null : product.productName(), + product == null ? null : product.brandName(), + product == null ? null : product.productImageUrl(), + product == null ? null : product.productThumbnailImageUrl(), + product == null ? null : product.brandImageUrl(), + product == null ? null : 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..11bda8f --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java @@ -0,0 +1,29 @@ +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(); + + @Getter + @Setter + public static class Sync { + private boolean enabled = true; + private long initialDelay = 10_000L; + private long fixedDelay = 1_800_000L; + private int maxPages = 100; + } +} 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/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..2001c7c --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/policy/config/GifticonPricingProperties.java @@ -0,0 +1,17 @@ +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 wonPerTing = BigDecimal.valueOf(100); + private BigDecimal markupPercent = BigDecimal.ZERO; + private int roundUnit = 1; +} 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/resources/application.yml b/manabom/src/main/resources/application.yml index f658dfe..1c3ff47 100644 --- a/manabom/src/main/resources/application.yml +++ b/manabom/src/main/resources/application.yml @@ -91,6 +91,17 @@ app: kakao: client-id: ${KAKAO_CLIENT_ID:your-kakao-client-id} redirect-uri: http://localhost:8080/api/auth/login/kakao # 임시로 카카오 디벨로퍼에 등록한 URI + giftbiz: + base-url: ${KAKAO_GIFTBIZ_BASE_URL:https://gateway-giftbiz.kakao.com} + authorization: ${KAKAO_GIFTBIZ_AUTHORIZATION:} + 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:true} + initial-delay: ${KAKAO_GIFTBIZ_SYNC_INITIAL_DELAY:10000} + fixed-delay: ${KAKAO_GIFTBIZ_SYNC_FIXED_DELAY:86400000} + max-pages: ${KAKAO_GIFTBIZ_SYNC_MAX_PAGES:100} # 운영환경에서는 실제 도메인으로 변경 필요 # redirect-uri: https://company-domain.com/api/auth/login/kakao @@ -172,3 +183,9 @@ benefit: basic: # 기본 지급 daily-profile: 10 daily-love-view: 10 + +gifticon: + pricing: + won-per-ting: ${GIFTICON_WON_PER_TING:100} + markup-percent: ${GIFTICON_MARKUP_PERCENT:0} + round-unit: ${GIFTICON_TING_ROUND_UNIT:1} 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/test/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogServiceTest.java b/manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogServiceTest.java new file mode 100644 index 0000000..9717769 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonCatalogServiceTest.java @@ -0,0 +1,178 @@ +package mannabom_server.manabom.application.gifticon.service; + +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 org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.data.domain.Pageable; +import org.springframework.test.util.ReflectionTestUtils; + +import java.math.BigDecimal; +import java.time.Instant; +import java.time.LocalDateTime; +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.anyCollection; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.ArgumentMatchers.isNull; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.verifyNoInteractions; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GifticonCatalogServiceTest { + + @Mock + private GifticonProductRepository gifticonProductRepository; + + @Test + void getAvailableProductsSupportsCategoryCursorPagination() { + GifticonCatalogService service = service(); + when(gifticonProductRepository.findAvailableProductsAfter( + any(), + eq("GS25"), + eq(10L), + any(Pageable.class) + )).thenReturn(List.of( + product(11L, "GS25"), + product(12L, "GS25"), + product(13L, "GS25") + )); + + GifticonProductSliceResponse result = + service.getAvailableProducts(" GS25 ", 10L, 2); + + assertThat(result.contents()).hasSize(2); + assertThat(result.hasNext()).isTrue(); + assertThat(result.nextCursor()).isEqualTo(12L); + verify(gifticonProductRepository).findAvailableProductsAfter( + any(), + eq("GS25"), + eq(10L), + argThat(pageable -> pageable.getPageSize() == 3) + ); + } + + @Test + void getAvailableProductsQueriesAllBrandsWhenCategoryIsNull() { + GifticonCatalogService service = service(); + when(gifticonProductRepository.findAvailableProductsAfter( + any(), + isNull(), + eq(0L), + any(Pageable.class) + )).thenReturn(List.of(product(1L, "GS25"))); + + GifticonProductSliceResponse result = + service.getAvailableProducts(null, null, 20); + + assertThat(result.contents()).hasSize(1); + assertThat(result.hasNext()).isFalse(); + assertThat(result.nextCursor()).isNull(); + } + + @Test + void synchronizeAliveTemplatesCreatesProductFromKakaoResponse() { + GifticonCatalogService service = service(); + GifticonTemplateSnapshot template = template(); + when(gifticonProductRepository.findAllByTemplateTraceIdIn(anyCollection())) + .thenReturn(List.of()); + + int synchronizedCount = service.synchronizeAliveTemplates(List.of(template)); + + assertThat(synchronizedCount).isEqualTo(1); + + @SuppressWarnings("unchecked") + ArgumentCaptor> captor = ArgumentCaptor.forClass(List.class); + verify(gifticonProductRepository).saveAllAndFlush(captor.capture()); + + GifticonProduct product = captor.getValue().get(0); + assertThat(product.getTemplateTraceId()).isEqualTo(2026072304113962800L); + assertThat(product.getProductName()).isEqualTo("퍼페티)츄파춥스"); + assertThat(product.getBrandName()).isEqualTo("GS25"); + assertThat(product.getProductPrice()).isEqualTo(300); + assertThat(product.getTingPrice()).isEqualTo(3); + assertThat(product.getStartAt()).isEqualTo(LocalDateTime.of(2026, 7, 23, 0, 0)); + assertThat(product.isAvailable()).isTrue(); + verify(gifticonProductRepository) + .markProductsNotSeenSinceUnavailable(product.getLastSyncedAt()); + } + + @Test + void synchronizeAliveTemplatesStopsBeforeDatabaseWritesWhenResponseIsEmpty() { + GifticonCatalogService service = service(); + + assertThatThrownBy(() -> service.synchronizeAliveTemplates(List.of())) + .isInstanceOf(IllegalStateException.class) + .hasMessage("활성 템플릿이 0개이므로 동기화를 중단합니다."); + + verifyNoInteractions(gifticonProductRepository); + } + + private GifticonCatalogService service() { + GifticonPriceCalculator priceCalculator = new GifticonPriceCalculator( + BigDecimal.valueOf(100), + BigDecimal.ZERO, + 1 + ); + return new GifticonCatalogService(gifticonProductRepository, priceCalculator); + } + + private GifticonProduct product(Long id, String brandName) { + GifticonProduct product = new GifticonProduct(id); + product.synchronize( + "템플릿-" + id, + LocalDateTime.of(2026, 1, 1, 0, 0), + LocalDateTime.of(2027, 1, 1, 0, 0), + "ALIVE", + "UNLIMITED", + 0L, + "만나봄", + null, + "테스트", + "VOUCHER", + "상품-" + id, + brandName, + null, + null, + null, + 300, + 3, + Instant.parse("2026-07-23T00:00:00Z") + ); + ReflectionTestUtils.setField(product, "gifticonProductId", id); + return product; + } + + private GifticonTemplateSnapshot template() { + return new GifticonTemplateSnapshot( + 2026072304113962800L, + "편의점-츄파츕스", + LocalDateTime.of(2026, 7, 23, 0, 0), + LocalDateTime.of(2026, 8, 1, 0, 0), + "ALIVE", + "UNLIMITED", + 0L, + "만나봄", + "https://example.com/card.jpg", + "테스트", + "VOUCHER", + "퍼페티)츄파춥스", + "GS25", + "https://example.com/product.png", + "https://example.com/thumb.png", + "https://example.com/brand.png", + 300 + ); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculatorTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculatorTest.java new file mode 100644 index 0000000..bd31d94 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/gifticon/service/GifticonPriceCalculatorTest.java @@ -0,0 +1,23 @@ +package mannabom_server.manabom.domain.gifticon.service; + +import org.junit.jupiter.api.Test; + +import java.math.BigDecimal; + +import static org.assertj.core.api.Assertions.assertThat; + +class GifticonPriceCalculatorTest { + + @Test + void calculateTingPriceAppliesConversionMarkupAndCeilingUnit() { + GifticonPriceCalculator calculator = new GifticonPriceCalculator( + BigDecimal.TEN, + BigDecimal.valueOf(5), + 10 + ); + + int tingPrice = calculator.calculateTingPrice(300); + + assertThat(tingPrice).isEqualTo(40); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapperTest.java b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapperTest.java new file mode 100644 index 0000000..14cf45f --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizTemplateMapperTest.java @@ -0,0 +1,54 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz; + +import com.fasterxml.jackson.databind.ObjectMapper; +import mannabom_server.manabom.domain.gifticon.vo.GifticonTemplateSnapshot; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto.KakaoGiftbizTemplatePage; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KakaoGiftbizTemplateMapperTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + private final KakaoGiftbizTemplateMapper mapper = new KakaoGiftbizTemplateMapper(); + + @Test + void mapTemplateWithoutOptionalSalesPeriod() throws Exception { + String json = """ + { + "contents": [ + { + "template_name": "스타벅스-상품권", + "template_trace_id": 2026072321334436400, + "order_template_status": "ALIVE", + "budget_type": "UNLIMITED", + "gift_sent_count": 0, + "bm_sender_name": "만나봄", + "mc_image_url": "https://example.com/card.jpg", + "mc_text": "마음이 도착했어요", + "product": { + "item_type": "VOUCHER", + "product_name": "e카드 1만원 교환권", + "brand_name": "스타벅스", + "product_image_url": "https://example.com/product.jpg", + "product_thumb_image_url": "https://example.com/thumb.jpg", + "brand_image_url": "https://example.com/brand.jpg", + "product_price": 10000 + } + } + ], + "last": true, + "totalCount": 1 + } + """; + KakaoGiftbizTemplatePage response = + objectMapper.readValue(json, KakaoGiftbizTemplatePage.class); + + GifticonTemplateSnapshot result = mapper.toSnapshot(response.contents().get(0)); + + assertThat(result.startAt()).isNull(); + assertThat(result.endAt()).isNull(); + assertThat(result.brandName()).isEqualTo("스타벅스"); + assertThat(result.productPrice()).isEqualTo(10_000); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePageTest.java b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePageTest.java new file mode 100644 index 0000000..d46a8b7 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizTemplatePageTest.java @@ -0,0 +1,54 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto; + +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class KakaoGiftbizTemplatePageTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void deserializeTemplateListResponse() throws Exception { + String json = """ + { + "contents": [ + { + "template_name": "편의점-츄파츕스", + "template_trace_id": 2026072304113962800, + "start_at": "20260723000000", + "end_at": "20260801000000", + "order_template_status": "ALIVE", + "budget_type": "UNLIMITED", + "gift_sent_count": 0, + "bm_sender_name": "만나봄", + "mc_image_url": "https://example.com/card.jpg", + "mc_text": "테스트", + "product": { + "item_type": "VOUCHER", + "product_name": "퍼페티)츄파춥스", + "brand_name": "GS25", + "product_image_url": "https://example.com/product.png", + "product_thumb_image_url": "https://example.com/thumb.png", + "brand_image_url": "https://example.com/brand.png", + "product_price": 300 + } + } + ], + "last": true, + "totalCount": 1 + } + """; + + KakaoGiftbizTemplatePage result = + objectMapper.readValue(json, KakaoGiftbizTemplatePage.class); + + assertThat(result.last()).isTrue(); + assertThat(result.totalCount()).isEqualTo(1L); + assertThat(result.contents()).hasSize(1); + assertThat(result.contents().get(0).templateTraceId()) + .isEqualTo(2026072304113962800L); + assertThat(result.contents().get(0).product().productPrice()).isEqualTo(300); + } +} From ba7c4674b419c1676f18c56a8fd12c77f4a47cc2 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Thu, 23 Jul 2026 23:47:58 +0900 Subject: [PATCH 03/48] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=9A=94=EC=B2=AD=EC=97=90=20=EC=84=A0=ED=83=9D=20=EA=B8=B0?= =?UTF-8?q?=ED=94=84=ED=8B=B0=EC=BD=98=20=EC=97=B0=EA=B2=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/request/SendMessageRequestDto.java | 2 ++ .../service/MessageRequestService.java | 33 +++++++++++++++++-- .../gifticon/entity/GifticonProduct.java | 6 ++++ .../messageRequest/entity/MessageRequest.java | 14 +++++++- .../controller/MessageRequestController.java | 8 ++++- .../V30__link_gifticon_to_message_request.sql | 10 ++++++ 6 files changed, 69 insertions(+), 4 deletions(-) create mode 100644 manabom/src/main/resources/db/migration/V30__link_gifticon_to_message_request.sql 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..4b60c7d 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 @@ -15,4 +15,6 @@ public class SendMessageRequestDto { private MessageSource source; private String message; + + private Long gifticonProductId; } 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..3a915cd 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 @@ -11,6 +11,8 @@ import mannabom_server.manabom.application.signal.dto.response.RespondSignalResponseDto; import mannabom_server.manabom.domain.currency.entity.TingWallet; import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; import mannabom_server.manabom.domain.matching.entity.LoveViewRecommendHistory; import mannabom_server.manabom.domain.matching.entity.ProfileRecommendHistory; import mannabom_server.manabom.domain.matching.repository.LoveViewRecommendHistoryRepository; @@ -41,9 +43,16 @@ public class MessageRequestService { private final ProfileRecommendHistoryRepository profileRecommendHistoryRepository; private final LoveViewRecommendHistoryRepository loveViewRecommendHistoryRepository; private final ChatRoomService chatRoomService; + private final GifticonProductRepository gifticonProductRepository; @Transactional - public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfileId, String message, MessageSource source) { + public SendMessageResponseDto sendMessageRequest( + Long fromUserId, + Long toProfileId, + String message, + MessageSource source, + Long gifticonProductId + ) { if(fromUserId == null) throw new IllegalArgumentException("요청자의 정보를 찾을 수 없습니다."); if(toProfileId == null) throw new IllegalArgumentException("toProfileId가 비어있습니다."); @@ -52,6 +61,7 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile Long toUserId = toProfile.getUser().getUserId(); if(fromUserId.equals(toUserId)) throw new IllegalArgumentException("본인에게 메시지 요청을 보낼 수 없습니다."); + GifticonProduct gifticonProduct = findOrderableGifticon(gifticonProductId); RuntimePolicySnapshot p = runtimePolicyService.snapshot(); LocalDate today = LocalDate.now(); LocalDateTime now = LocalDateTime.now(); @@ -95,7 +105,13 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile throw new IllegalStateException("보유 재화가 부족합니다.(팅, 아밴트 팅, 맴버쉽, vip 혜택권 등)"); } - MessageRequest messageRequest = new MessageRequest(fromUserId, toUserId, message, source); + MessageRequest messageRequest = new MessageRequest( + fromUserId, + toUserId, + message, + source, + gifticonProduct + ); messageRequestRepository.save(messageRequest); try { @@ -118,6 +134,19 @@ public SendMessageResponseDto sendMessageRequest(Long fromUserId, Long toProfile } + private GifticonProduct findOrderableGifticon(Long gifticonProductId) { + if (gifticonProductId == null) { + return null; + } + + GifticonProduct product = gifticonProductRepository.findById(gifticonProductId) + .orElseThrow(() -> new IllegalArgumentException("기프티콘 상품을 찾을 수 없습니다.")); + if (!product.isAvailableAt(LocalDateTime.now())) { + throw new IllegalStateException("현재 선택할 수 없는 기프티콘 상품입니다."); + } + return product; + } + @Transactional public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long messageRequestId, boolean accepted, String rejectReason) { if (responderUserId == null) throw new IllegalArgumentException("응답자의 정보를 찾을 수 없습니다."); 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 index 70a9c7b..b2fe040 100644 --- 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 @@ -150,4 +150,10 @@ public void useCalculatedTingPrice(int calculatedTingPrice) { this.tingPrice = calculatedTingPrice; this.tingPriceManuallySet = false; } + + public boolean isAvailableAt(LocalDateTime now) { + return available + && (startAt == null || !startAt.isAfter(now)) + && (endAt == null || endAt.isAfter(now)); + } } 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..270a8fc 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,7 @@ import jakarta.persistence.*; import lombok.Getter; import lombok.NoArgsConstructor; +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; @@ -53,11 +54,22 @@ 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; + + 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(); } 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..87b13e8 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,13 @@ 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(), + request.getGifticonProductId() + ) ); } 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); From eb775a11bc659c2e7c21f46624e607cfc543f65e Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 16:17:36 +0900 Subject: [PATCH 04/48] =?UTF-8?q?feat:=20Gift=20Biz=20=ED=85=9C=ED=94=8C?= =?UTF-8?q?=EB=A6=BF=20=ED=86=A0=ED=81=B0=20=EA=B4=80=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../AdminConfigureGifticonTokenRequest.java | 18 ++++ .../AdminGifticonProductResponse.java | 29 ++++++ .../AdminGifticonProductSliceResponse.java | 10 +++ .../admin/service/AdminGifticonService.java | 88 +++++++++++++++++++ .../service/MessageRequestService.java | 3 + .../admin/enums/AdminAuditActionType.java | 1 + .../admin/enums/AdminAuditTargetType.java | 1 + .../gifticon/entity/GifticonProduct.java | 18 ++++ .../repository/GifticonProductRepository.java | 12 +++ .../controller/AdminGifticonController.java | 59 +++++++++++++ .../V31__add_gifticon_template_token.sql | 6 ++ 11 files changed, 245 insertions(+) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminConfigureGifticonTokenRequest.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductSliceResponse.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java create mode 100644 manabom/src/main/resources/db/migration/V31__add_gifticon_template_token.sql 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..4ee3037 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/request/AdminConfigureGifticonTokenRequest.java @@ -0,0 +1,18 @@ +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; + + @Size(max = 500, message = "reason은 500자를 초과할 수 없습니다.") + private String reason; +} 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..380f64e --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/dto/response/AdminGifticonProductResponse.java @@ -0,0 +1,29 @@ +package mannabom_server.manabom.application.admin.dto.response; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; + +public record AdminGifticonProductResponse( + Long gifticonProductId, + String templateTraceId, + String templateName, + String productName, + String brandName, + int productPrice, + int tingPrice, + boolean available, + boolean templateTokenConfigured +) { + public static AdminGifticonProductResponse from(GifticonProduct product) { + return new AdminGifticonProductResponse( + product.getGifticonProductId(), + String.valueOf(product.getTemplateTraceId()), + product.getTemplateName(), + product.getProductName(), + product.getBrandName(), + product.getProductPrice(), + product.getTingPrice(), + product.isAvailable(), + product.hasTemplateToken() + ); + } +} 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/service/AdminGifticonService.java b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java new file mode 100644 index 0000000..da374c2 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/admin/service/AdminGifticonService.java @@ -0,0 +1,88 @@ +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.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 java.util.List; + +@Service +@RequiredArgsConstructor +public class AdminGifticonService { + + private static final int MAX_PAGE_SIZE = 100; + + private final GifticonProductRepository gifticonProductRepository; + private final AdminAuditService adminAuditService; + + @Transactional(readOnly = true) + public AdminGifticonProductSliceResponse getProducts( + AdminPrincipal admin, + Long cursor, + int size + ) { + 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, + 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); + } + + @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.configureTemplateToken(request.getTemplateToken()); + + 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/messageRequest/service/MessageRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java index 3a915cd..a4df5a0 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 @@ -144,6 +144,9 @@ private GifticonProduct findOrderableGifticon(Long gifticonProductId) { if (!product.isAvailableAt(LocalDateTime.now())) { throw new IllegalStateException("현재 선택할 수 없는 기프티콘 상품입니다."); } + if (!product.hasTemplateToken()) { + throw new IllegalStateException("발송 토큰이 등록되지 않은 기프티콘 상품입니다."); + } return product; } 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..bd0cd12 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,7 @@ public enum AdminAuditActionType { WALLET_ADJUST, MEMBERSHIP_ACTIVATE, POLICY_UPDATE, + GIFTICON_TEMPLATE_TOKEN_UPDATE, 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..651e50f 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,7 @@ public enum AdminAuditTargetType { ADMIN, USER, TING_WALLET, + GIFTICON_PRODUCT, POLICY, PUSH, REPORT 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 index b2fe040..96eeffc 100644 --- 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 @@ -28,6 +28,9 @@ public class GifticonProduct extends BaseTimeEntity { @Column(name = "template_trace_id", nullable = false, unique = true) private Long templateTraceId; + @Column(name = "template_token", unique = true, length = 512) + private String templateToken; + @Column(name = "template_name", nullable = false, length = 200) private String templateName; @@ -156,4 +159,19 @@ public boolean isAvailableAt(LocalDateTime now) { && (startAt == null || !startAt.isAfter(now)) && (endAt == null || endAt.isAfter(now)); } + + public boolean isOrderableAt(LocalDateTime now) { + return hasTemplateToken() && isAvailableAt(now); + } + + public boolean hasTemplateToken() { + return templateToken != null && !templateToken.isBlank(); + } + + public void configureTemplateToken(String templateToken) { + if (templateToken == null || templateToken.isBlank()) { + throw new IllegalArgumentException("템플릿 토큰은 비어있을 수 없습니다."); + } + this.templateToken = templateToken.trim(); + } } 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 index 8b890bc..1276165 100644 --- 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 @@ -29,6 +29,7 @@ public interface GifticonProductRepository extends JpaRepository :now) and product.gifticonProductId > :cursor @@ -41,4 +42,15 @@ List findAvailableProductsAfter( @Param("cursor") Long cursor, Pageable pageable ); + + @Query(""" + select product + from GifticonProduct product + where product.gifticonProductId > :cursor + order by product.gifticonProductId asc + """) + List findProductsForAdminAfter( + @Param("cursor") Long cursor, + Pageable pageable + ); } 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..90e2abf --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java @@ -0,0 +1,59 @@ +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.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.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 + ) { + return ResponseEntity.ok(adminGifticonService.getProducts(admin, cursor, size)); + } + + @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/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); From af187c6ecc3ce5ee34008f504ac6c57e84eb9756 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 17:32:28 +0900 Subject: [PATCH 05/48] =?UTF-8?q?security:=20Gift=20Biz=20=ED=85=9C?= =?UTF-8?q?=ED=94=8C=EB=A6=BF=20=ED=86=A0=ED=81=B0=20=EC=95=94=ED=98=B8?= =?UTF-8?q?=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../admin/service/AdminGifticonService.java | 6 +- .../gifticon/port/GifticonTokenCipher.java | 8 ++ .../gifticon/entity/GifticonProduct.java | 14 +-- .../repository/GifticonProductRepository.java | 2 +- .../crypto/AesGcmGifticonTokenCipher.java | 103 ++++++++++++++++++ manabom/src/main/resources/application.yml | 1 + .../V32__encrypt_gifticon_template_token.sql | 8 ++ .../crypto/AesGcmGifticonTokenCipherTest.java | 51 +++++++++ 8 files changed, 184 insertions(+), 9 deletions(-) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonTokenCipher.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipher.java create mode 100644 manabom/src/main/resources/db/migration/V32__encrypt_gifticon_template_token.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipherTest.java 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 index da374c2..4436d21 100644 --- 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 @@ -4,6 +4,7 @@ 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.gifticon.port.GifticonTokenCipher; import mannabom_server.manabom.domain.admin.enums.AdminAuditActionType; import mannabom_server.manabom.domain.admin.enums.AdminAuditTargetType; import mannabom_server.manabom.domain.admin.enums.AdminRole; @@ -24,6 +25,7 @@ public class AdminGifticonService { private final GifticonProductRepository gifticonProductRepository; private final AdminAuditService adminAuditService; + private final GifticonTokenCipher gifticonTokenCipher; @Transactional(readOnly = true) public AdminGifticonProductSliceResponse getProducts( @@ -65,7 +67,9 @@ public AdminGifticonProductResponse configureTemplateToken( GifticonProduct product = gifticonProductRepository.findById(gifticonProductId) .orElseThrow(() -> new IllegalArgumentException("기프티콘 상품을 찾을 수 없습니다.")); boolean configuredBefore = product.hasTemplateToken(); - product.configureTemplateToken(request.getTemplateToken()); + product.configureEncryptedTemplateToken( + gifticonTokenCipher.encrypt(request.getTemplateToken().trim()) + ); adminAuditService.log( admin.adminId(), 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/domain/gifticon/entity/GifticonProduct.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonProduct.java index 96eeffc..47eae6b 100644 --- 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 @@ -28,8 +28,8 @@ public class GifticonProduct extends BaseTimeEntity { @Column(name = "template_trace_id", nullable = false, unique = true) private Long templateTraceId; - @Column(name = "template_token", unique = true, length = 512) - private String templateToken; + @Column(name = "encrypted_template_token", length = 1024) + private String encryptedTemplateToken; @Column(name = "template_name", nullable = false, length = 200) private String templateName; @@ -165,13 +165,13 @@ public boolean isOrderableAt(LocalDateTime now) { } public boolean hasTemplateToken() { - return templateToken != null && !templateToken.isBlank(); + return encryptedTemplateToken != null && !encryptedTemplateToken.isBlank(); } - public void configureTemplateToken(String templateToken) { - if (templateToken == null || templateToken.isBlank()) { - throw new IllegalArgumentException("템플릿 토큰은 비어있을 수 없습니다."); + public void configureEncryptedTemplateToken(String encryptedTemplateToken) { + if (encryptedTemplateToken == null || encryptedTemplateToken.isBlank()) { + throw new IllegalArgumentException("암호화된 템플릿 토큰은 비어있을 수 없습니다."); } - this.templateToken = templateToken.trim(); + this.encryptedTemplateToken = encryptedTemplateToken.trim(); } } 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 index 1276165..d81e03b 100644 --- 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 @@ -29,7 +29,7 @@ public interface GifticonProductRepository extends JpaRepository :now) and product.gifticonProductId > :cursor 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..409dd27 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipher.java @@ -0,0 +1,103 @@ +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 final String encodedKey; + private final SecureRandom secureRandom = new SecureRandom(); + + public AesGcmGifticonTokenCipher( + @Value("${app.kakao.giftbiz.token-encryption-key:}") String encodedKey + ) { + this.encodedKey = 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, secretKey(), 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, secretKey(), 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 secretKey() { + if (encodedKey == null || encodedKey.isBlank()) { + throw new IllegalStateException("GIFTICON_TOKEN_ENCRYPTION_KEY 설정이 필요합니다."); + } + byte[] decoded; + try { + decoded = Base64.getDecoder().decode(encodedKey.trim()); + } catch (IllegalArgumentException e) { + throw new IllegalStateException( + "GIFTICON_TOKEN_ENCRYPTION_KEY는 Base64 형식이어야 합니다.", + e + ); + } + if (decoded.length != AES_256_KEY_BYTES) { + throw new IllegalStateException( + "GIFTICON_TOKEN_ENCRYPTION_KEY는 Base64로 인코딩한 32바이트 키여야 합니다." + ); + } + return new SecretKeySpec(decoded, "AES"); + } +} diff --git a/manabom/src/main/resources/application.yml b/manabom/src/main/resources/application.yml index 1c3ff47..f5023ec 100644 --- a/manabom/src/main/resources/application.yml +++ b/manabom/src/main/resources/application.yml @@ -94,6 +94,7 @@ app: giftbiz: base-url: ${KAKAO_GIFTBIZ_BASE_URL:https://gateway-giftbiz.kakao.com} authorization: ${KAKAO_GIFTBIZ_AUTHORIZATION:} + 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} 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/test/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipherTest.java b/manabom/src/test/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipherTest.java new file mode 100644 index 0000000..056b81b --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/infrastructure/security/crypto/AesGcmGifticonTokenCipherTest.java @@ -0,0 +1,51 @@ +package mannabom_server.manabom.infrastructure.security.crypto; + +import org.junit.jupiter.api.Test; + +import java.nio.charset.StandardCharsets; +import java.util.Base64; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class AesGcmGifticonTokenCipherTest { + + private static final String ENCODED_KEY = Base64.getEncoder().encodeToString( + "0123456789abcdef0123456789abcdef".getBytes(StandardCharsets.UTF_8) + ); + + @Test + void encryptsAndDecryptsTemplateToken() { + AesGcmGifticonTokenCipher cipher = new AesGcmGifticonTokenCipher(ENCODED_KEY); + + String encrypted = cipher.encrypt("secret-template-token"); + + assertThat(encrypted) + .startsWith("v1:") + .doesNotContain("secret-template-token"); + assertThat(cipher.decrypt(encrypted)).isEqualTo("secret-template-token"); + } + + @Test + void usesDifferentIvForEveryEncryption() { + AesGcmGifticonTokenCipher cipher = new AesGcmGifticonTokenCipher(ENCODED_KEY); + + assertThat(cipher.encrypt("same-token")) + .isNotEqualTo(cipher.encrypt("same-token")); + } + + @Test + void rejectsCiphertextEncryptedWithAnotherKey() { + AesGcmGifticonTokenCipher cipher = new AesGcmGifticonTokenCipher(ENCODED_KEY); + AesGcmGifticonTokenCipher anotherCipher = new AesGcmGifticonTokenCipher( + Base64.getEncoder().encodeToString( + "abcdef0123456789abcdef0123456789".getBytes(StandardCharsets.UTF_8) + ) + ); + + String encrypted = cipher.encrypt("secret-template-token"); + + assertThatThrownBy(() -> anotherCipher.decrypt(encrypted)) + .isInstanceOf(IllegalStateException.class); + } +} From 11cba0f9a0cdcda2358cbc442e4d20315dd1434c Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 17:33:29 +0900 Subject: [PATCH 06/48] =?UTF-8?q?feat:=20=EB=A9=94=EC=8B=9C=EC=A7=80=20?= =?UTF-8?q?=EC=88=98=EB=9D=BD=20=ED=9B=84=20Gift=20Biz=20=EC=84=A0?= =?UTF-8?q?=EB=AC=BC=20=EC=A3=BC=EB=AC=B8=20=EC=9A=94=EC=B2=AD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../event/GifticonOrderReadyEvent.java | 4 + .../GifticonOrderReadyEventListener.java | 19 +++ .../gifticon/port/GifticonOrderRequester.java | 8 + .../port/command/GifticonOrderCommand.java | 10 ++ .../GifticonOrderRetryScheduler.java | 45 ++++++ .../service/GifticonOrderProcessor.java | 75 ++++++++++ .../service/GifticonOrderService.java | 69 +++++++++ .../service/MessageRequestService.java | 8 + .../response/RespondSignalResponseDto.java | 1 + .../domain/gifticon/entity/GifticonOrder.java | 138 ++++++++++++++++++ .../gifticon/enums/GifticonOrderStatus.java | 7 + .../repository/GifticonOrderRepository.java | 40 +++++ .../giftbiz/KakaoGiftbizOrderClient.java | 82 +++++++++++ .../giftbiz/config/GiftbizProperties.java | 20 +++ .../giftbiz/dto/KakaoGiftbizOrderRequest.java | 28 ++++ manabom/src/main/resources/application.yml | 10 ++ .../migration/V33__create_gifticon_order.sql | 24 +++ .../service/GifticonOrderProcessorTest.java | 104 +++++++++++++ .../dto/KakaoGiftbizOrderRequestTest.java | 45 ++++++ 19 files changed, 737 insertions(+) create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEvent.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEventListener.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/GifticonOrderRequester.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonOrderRetryScheduler.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessor.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderService.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonOrder.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonOrderRepository.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizOrderClient.java create mode 100644 manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequest.java create mode 100644 manabom/src/main/resources/db/migration/V33__create_gifticon_order.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessorTest.java create mode 100644 manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequestTest.java 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..4755119 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/event/GifticonOrderReadyEventListener.java @@ -0,0 +1,19 @@ +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.transaction.event.TransactionPhase; +import org.springframework.transaction.event.TransactionalEventListener; + +@Component +@RequiredArgsConstructor +public class GifticonOrderReadyEventListener { + + private final GifticonOrderProcessor gifticonOrderProcessor; + + @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT) + public void requestGift(GifticonOrderReadyEvent event) { + gifticonOrderProcessor.process(event.gifticonOrderId()); + } +} 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/command/GifticonOrderCommand.java b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java new file mode 100644 index 0000000..5fc46e1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/port/command/GifticonOrderCommand.java @@ -0,0 +1,10 @@ +package mannabom_server.manabom.application.gifticon.port.command; + +public record GifticonOrderCommand( + String templateToken, + String receiverPhone, + String receiverName, + String externalKey, + String externalOrderId +) { +} 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..80c0d76 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/scheduler/GifticonOrderRetryScheduler.java @@ -0,0 +1,45 @@ +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; + +@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.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(), + PageRequest.of(0, retry.getBatchSize()) + ); + orderIds.forEach(gifticonOrderProcessor::process); + } +} 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..fba3e5d --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessor.java @@ -0,0 +1,75 @@ +package mannabom_server.manabom.application.gifticon.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import mannabom_server.manabom.application.gifticon.port.GifticonOrderRequester; +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.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.Clock; +import java.time.Instant; + +@Slf4j +@Service +@RequiredArgsConstructor +public class GifticonOrderProcessor { + + private final GifticonOrderRepository gifticonOrderRepository; + private final GifticonOrderRequester gifticonOrderRequester; + private final GifticonTokenCipher gifticonTokenCipher; + private final GiftbizProperties giftbizProperties; + private final Clock clock = Clock.systemUTC(); + + @Transactional(propagation = Propagation.REQUIRES_NEW) + public void process(Long gifticonOrderId) { + GifticonOrder order = gifticonOrderRepository.findByIdForUpdate(gifticonOrderId) + .orElse(null); + if (order == null) { + log.warn("[Gift Biz] 발송 대기 주문을 찾을 수 없습니다. gifticonOrderId={}", gifticonOrderId); + return; + } + + int maxAttempts = giftbizProperties.getOrder().getRetry().getMaxAttempts(); + if (!order.canAttempt(maxAttempts)) { + return; + } + + Instant attemptedAt = clock.instant(); + try { + gifticonOrderRequester.requestGift(new GifticonOrderCommand( + gifticonTokenCipher.decrypt(order.getEncryptedTemplateToken()), + order.getReceiverPhone(), + order.getReceiverName(), + order.getExternalKey(), + order.getExternalOrderId() + )); + order.markRequested(attemptedAt); + log.info( + "[Gift Biz] 선물 발송 요청 접수 완료. gifticonOrderId={}, externalOrderId={}", + order.getGifticonOrderId(), + order.getExternalOrderId() + ); + } catch (Exception e) { + order.markFailed(attemptedAt, safeFailureReason(e)); + log.error( + "[Gift Biz] 선물 발송 요청 실패. gifticonOrderId={}, externalOrderId={}, attempt={}", + order.getGifticonOrderId(), + order.getExternalOrderId(), + order.getAttemptCount(), + 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..c7849ba --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderService.java @@ -0,0 +1,69 @@ +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.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; +import mannabom_server.manabom.domain.gifticon.repository.GifticonOrderRepository; +import mannabom_server.manabom.domain.messageRequest.entity.MessageRequest; +import mannabom_server.manabom.domain.user.entity.User; +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 UserRepository userRepository; + private final ApplicationEventPublisher eventPublisher; + + @Transactional + public GifticonOrderStatus prepareOrder(MessageRequest messageRequest) { + GifticonProduct product = messageRequest.getGifticonProduct(); + if (product == null) { + throw new IllegalArgumentException("메시지 요청에 기프티콘 상품이 없습니다."); + } + if (!product.isOrderableAt(java.time.LocalDateTime.now())) { + 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(); + GifticonOrder order = gifticonOrderRepository.save(new GifticonOrder( + messageRequest, + product.getEncryptedTemplateToken(), + 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/messageRequest/service/MessageRequestService.java b/manabom/src/main/java/mannabom_server/manabom/application/messageRequest/service/MessageRequestService.java index a4df5a0..6a2252f 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,6 +5,7 @@ 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.gifticon.service.GifticonOrderService; 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; @@ -13,6 +14,7 @@ import mannabom_server.manabom.domain.currency.repository.TingWalletRepository; import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; import mannabom_server.manabom.domain.gifticon.repository.GifticonProductRepository; +import mannabom_server.manabom.domain.gifticon.enums.GifticonOrderStatus; import mannabom_server.manabom.domain.matching.entity.LoveViewRecommendHistory; import mannabom_server.manabom.domain.matching.entity.ProfileRecommendHistory; import mannabom_server.manabom.domain.matching.repository.LoveViewRecommendHistoryRepository; @@ -44,6 +46,7 @@ public class MessageRequestService { private final LoveViewRecommendHistoryRepository loveViewRecommendHistoryRepository; private final ChatRoomService chatRoomService; private final GifticonProductRepository gifticonProductRepository; + private final GifticonOrderService gifticonOrderService; @Transactional public SendMessageResponseDto sendMessageRequest( @@ -162,9 +165,13 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long } Long chatRoomId = null; + GifticonOrderStatus gifticonOrderStatus = null; if (accepted) { messageRequest.accept(); chatRoomId = createChatRoom(messageRequest.getFromUserId(), messageRequest.getToUserId(), messageRequest.getSource()); + if (messageRequest.getGifticonProduct() != null) { + gifticonOrderStatus = gifticonOrderService.prepareOrder(messageRequest); + } } else { messageRequest.reject(rejectReason); } @@ -179,6 +186,7 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long .accepted(accepted) .chatRoomId(chatRoomId) .status(messageRequest.getStatus().name()) + .gifticonOrderStatus(gifticonOrderStatus == null ? null : gifticonOrderStatus.name()) .build(); } 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..a349ca9 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,5 @@ public class RespondSignalResponseDto { private boolean accepted; private Long chatRoomId; private String status; + private String gifticonOrderStatus; } 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..da2fce1 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/entity/GifticonOrder.java @@ -0,0 +1,138 @@ +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.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, optional = false) + @JoinColumn(name = "message_request_id", nullable = false, unique = true) + private MessageRequest messageRequest; + + @Column(name = "encrypted_template_token", nullable = false, length = 1024) + private String encryptedTemplateToken; + + @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( + MessageRequest messageRequest, + String encryptedTemplateToken, + String receiverPhone, + String receiverName, + String externalKey, + String externalOrderId + ) { + if (messageRequest == null || messageRequest.getGifticonProduct() == null) { + throw new IllegalArgumentException("기프티콘이 연결된 메시지 요청이 필요합니다."); + } + if (encryptedTemplateToken == null || encryptedTemplateToken.isBlank()) { + throw new IllegalArgumentException("암호화된 템플릿 토큰은 필수입니다."); + } + if (receiverPhone == null || receiverPhone.isBlank()) { + throw new IllegalArgumentException("수신자 휴대폰 번호는 필수입니다."); + } + if (receiverName == null || receiverName.isBlank()) { + throw new IllegalArgumentException("수신자 이름은 필수입니다."); + } + this.messageRequest = messageRequest; + this.encryptedTemplateToken = encryptedTemplateToken; + this.receiverPhone = receiverPhone; + this.receiverName = receiverName; + this.externalKey = requireExternalId(externalKey, "externalKey"); + this.externalOrderId = requireExternalId(externalOrderId, "externalOrderId"); + this.status = GifticonOrderStatus.PENDING; + } + + public boolean canAttempt(int maxAttempts) { + return status != GifticonOrderStatus.REQUESTED && attemptCount < maxAttempts; + } + + public void markRequested(Instant now) { + attemptCount++; + lastAttemptAt = now; + requestedAt = now; + failureReason = null; + status = GifticonOrderStatus.REQUESTED; + } + + public void markFailed(Instant now, String reason) { + attemptCount++; + lastAttemptAt = now; + failureReason = abbreviate(reason); + status = GifticonOrderStatus.FAILED; + } + + 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/enums/GifticonOrderStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java new file mode 100644 index 0000000..6947d87 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonOrderStatus.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonOrderStatus { + PENDING, + REQUESTED, + FAILED +} 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..812b604 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/repository/GifticonOrderRepository.java @@ -0,0 +1,40 @@ +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; + +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 + ); + + @Query(""" + select gifticonOrder.gifticonOrderId + from GifticonOrder gifticonOrder + where gifticonOrder.status in :statuses + and gifticonOrder.attemptCount < :maxAttempts + order by gifticonOrder.createdAt asc + """) + List findRetryableOrderIds( + @Param("statuses") Collection statuses, + @Param("maxAttempts") int maxAttempts, + Pageable pageable + ); +} 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..b1a862a --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/KakaoGiftbizOrderClient.java @@ -0,0 +1,82 @@ +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(), + properties.getSenderName(), + 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초 이상이어야 합니다."); + } + } + + 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/config/GiftbizProperties.java b/manabom/src/main/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/config/GiftbizProperties.java index 11bda8f..c57c5ec 100644 --- 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 @@ -17,6 +17,7 @@ public class GiftbizProperties { private String text = ""; private int requestTimeoutSeconds = 10; private Sync sync = new Sync(); + private Order order = new Order(); @Getter @Setter @@ -26,4 +27,23 @@ public static class Sync { 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/resources/application.yml b/manabom/src/main/resources/application.yml index f5023ec..7f13af5 100644 --- a/manabom/src/main/resources/application.yml +++ b/manabom/src/main/resources/application.yml @@ -103,6 +103,16 @@ app: 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} # 운영환경에서는 실제 도메인으로 변경 필요 # redirect-uri: https://company-domain.com/api/auth/login/kakao 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/test/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessorTest.java b/manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessorTest.java new file mode 100644 index 0000000..a9c489d --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/application/gifticon/service/GifticonOrderProcessorTest.java @@ -0,0 +1,104 @@ +package mannabom_server.manabom.application.gifticon.service; + +import mannabom_server.manabom.application.gifticon.port.GifticonOrderRequester; +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.repository.GifticonOrderRepository; +import mannabom_server.manabom.domain.messageRequest.entity.MessageRequest; +import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; +import mannabom_server.manabom.infrastructure.external.kakao.giftbiz.config.GiftbizProperties; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +@ExtendWith(MockitoExtension.class) +class GifticonOrderProcessorTest { + + @Mock + private GifticonOrderRepository gifticonOrderRepository; + + @Mock + private GifticonOrderRequester gifticonOrderRequester; + + @Mock + private GifticonTokenCipher gifticonTokenCipher; + + private GifticonOrderProcessor processor; + + @BeforeEach + void setUp() { + GiftbizProperties properties = new GiftbizProperties(); + properties.getOrder().getRetry().setMaxAttempts(5); + processor = new GifticonOrderProcessor( + gifticonOrderRepository, + gifticonOrderRequester, + gifticonTokenCipher, + properties + ); + } + + @Test + void requestsGiftWithDecryptedTokenAndMarksRequested() { + GifticonOrder order = order(); + when(gifticonOrderRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(order)); + when(gifticonTokenCipher.decrypt("encrypted-token")).thenReturn("plain-token"); + + processor.process(1L); + + ArgumentCaptor commandCaptor = + ArgumentCaptor.forClass(GifticonOrderCommand.class); + verify(gifticonOrderRequester).requestGift(commandCaptor.capture()); + assertThat(commandCaptor.getValue().templateToken()).isEqualTo("plain-token"); + assertThat(order.getStatus()).isEqualTo(GifticonOrderStatus.REQUESTED); + assertThat(order.getAttemptCount()).isEqualTo(1); + } + + @Test + void marksFailedWhenKakaoRequestFails() { + GifticonOrder order = order(); + when(gifticonOrderRepository.findByIdForUpdate(1L)).thenReturn(Optional.of(order)); + when(gifticonTokenCipher.decrypt("encrypted-token")).thenReturn("plain-token"); + doThrow(new IllegalStateException("temporary failure")) + .when(gifticonOrderRequester) + .requestGift(org.mockito.ArgumentMatchers.any()); + + processor.process(1L); + + assertThat(order.getStatus()).isEqualTo(GifticonOrderStatus.FAILED); + assertThat(order.getAttemptCount()).isEqualTo(1); + assertThat(order.getFailureReason()).contains("temporary failure"); + } + + private GifticonOrder order() { + GifticonProduct product = new GifticonProduct(100L); + product.configureEncryptedTemplateToken("encrypted-token"); + MessageRequest messageRequest = new MessageRequest( + 1L, + 2L, + "안녕하세요", + MessageSource.PROFILE_MATCH, + product + ); + return new GifticonOrder( + messageRequest, + "encrypted-token", + "01012345678", + "수신자", + "MESSAGE-GIFT-1-2", + "MESSAGE-GIFT-1" + ); + } +} diff --git a/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequestTest.java b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequestTest.java new file mode 100644 index 0000000..ea29172 --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/infrastructure/external/kakao/giftbiz/dto/KakaoGiftbizOrderRequestTest.java @@ -0,0 +1,45 @@ +package mannabom_server.manabom.infrastructure.external.kakao.giftbiz.dto; + +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import org.junit.jupiter.api.Test; + +import java.util.List; + +import static org.assertj.core.api.Assertions.assertThat; + +class KakaoGiftbizOrderRequestTest { + + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Test + void serializesKakaoOrderFieldNamesAndOmitsEmptyCallbacks() throws Exception { + KakaoGiftbizOrderRequest request = new KakaoGiftbizOrderRequest( + "template-token", + "PHONE", + List.of(new KakaoGiftbizOrderRequest.Receiver( + "01012345678", + "수신자", + "MESSAGE-GIFT-1-2", + "만나봄", + "마음이 도착했어요" + )), + null, + null, + null, + "MESSAGE-GIFT-1" + ); + + JsonNode json = objectMapper.readTree(objectMapper.writeValueAsBytes(request)); + + assertThat(json.get("template_token").asText()).isEqualTo("template-token"); + assertThat(json.get("receiver_type").asText()).isEqualTo("PHONE"); + assertThat(json.get("external_order_id").asText()).isEqualTo("MESSAGE-GIFT-1"); + assertThat(json.has("success_callback_url")).isFalse(); + JsonNode receiver = json.get("receivers").get(0); + assertThat(receiver.get("receiver_id").asText()).isEqualTo("01012345678"); + assertThat(receiver.get("external_key").asText()).isEqualTo("MESSAGE-GIFT-1-2"); + assertThat(receiver.get("sender_name").asText()).isEqualTo("만나봄"); + assertThat(receiver.get("mc_text").asText()).isEqualTo("마음이 도착했어요"); + } +} From d0de5c8b82fbd35f61d78db7c8a8707e0fdbb737 Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 17:36:07 +0900 Subject: [PATCH 07/48] =?UTF-8?q?feat:=20=EA=B8=B0=ED=94=84=ED=8B=B0?= =?UTF-8?q?=EC=BD=98=20=EC=9A=94=EC=B2=AD=20=EC=9C=A0=EC=83=81=20=ED=8C=85?= =?UTF-8?q?=20=EB=B3=B4=EB=A5=98=20=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../service/GifticonOrderService.java | 3 ++ .../service/MessageRequestService.java | 19 +++++++ .../gifticon/enums/GifticonPaymentStatus.java | 7 +++ .../messageRequest/entity/MessageRequest.java | 35 +++++++++++++ .../V34__hold_paid_ting_for_gifticon.sql | 17 ++++++ .../entity/MessageRequestGiftPaymentTest.java | 52 +++++++++++++++++++ 6 files changed, 133 insertions(+) create mode 100644 manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java create mode 100644 manabom/src/main/resources/db/migration/V34__hold_paid_ting_for_gifticon.sql create mode 100644 manabom/src/test/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequestGiftPaymentTest.java 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 index c7849ba..d0d83fd 100644 --- 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 @@ -27,6 +27,9 @@ public GifticonOrderStatus prepareOrder(MessageRequest messageRequest) { if (product == null) { throw new IllegalArgumentException("메시지 요청에 기프티콘 상품이 없습니다."); } + if (!messageRequest.hasCapturedGiftPayment()) { + throw new IllegalStateException("기프티콘 유상 팅 결제가 확정되지 않았습니다."); + } if (!product.isOrderableAt(java.time.LocalDateTime.now())) { throw new IllegalStateException("현재 발송할 수 없는 기프티콘 상품입니다."); } 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 6a2252f..351244c 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 @@ -72,6 +72,13 @@ public SendMessageResponseDto sendMessageRequest( TingWallet tingWallet = tingWalletRepository.findByUserIdForUpdate(fromUserId) .orElseGet(() -> tingWalletRepository.save(new TingWallet(fromUserId))); + if (gifticonProduct != null) { + if (gifticonProduct.getTingPrice() <= 0) { + throw new IllegalStateException("기프티콘의 팅 가격이 올바르지 않습니다."); + } + tingWallet.spendTing(gifticonProduct.getTingPrice()); + } + messageRequestRepository.findByFromUserIdAndToUserId(fromUserId, toUserId) .ifPresent(existing -> { throw new IllegalStateException("이미 요청을 보냈습니다."); @@ -168,12 +175,14 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long GifticonOrderStatus gifticonOrderStatus = null; if (accepted) { messageRequest.accept(); + messageRequest.captureGiftPayment(); chatRoomId = createChatRoom(messageRequest.getFromUserId(), messageRequest.getToUserId(), messageRequest.getSource()); if (messageRequest.getGifticonProduct() != null) { gifticonOrderStatus = gifticonOrderService.prepareOrder(messageRequest); } } else { messageRequest.reject(rejectReason); + releaseHeldGiftTing(messageRequest); } try { @@ -190,6 +199,16 @@ public RespondSignalResponseDto respondMessageRequest(Long responderUserId, Long .build(); } + private void releaseHeldGiftTing(MessageRequest messageRequest) { + if (messageRequest.getGifticonProduct() == null) { + return; + } + TingWallet senderWallet = tingWalletRepository + .findByUserIdForUpdate(messageRequest.getFromUserId()) + .orElseThrow(() -> new IllegalStateException("발신자의 팅 지갑을 찾을 수 없습니다.")); + senderWallet.addTing(messageRequest.releaseGiftPayment()); + } + 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/domain/gifticon/enums/GifticonPaymentStatus.java b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java new file mode 100644 index 0000000..532e303 --- /dev/null +++ b/manabom/src/main/java/mannabom_server/manabom/domain/gifticon/enums/GifticonPaymentStatus.java @@ -0,0 +1,7 @@ +package mannabom_server.manabom.domain.gifticon.enums; + +public enum GifticonPaymentStatus { + HELD, + CAPTURED, + RELEASED +} 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 270a8fc..60070c8 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 @@ -4,6 +4,7 @@ import lombok.Getter; import lombok.NoArgsConstructor; import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; import mannabom_server.manabom.domain.messageRequest.enums.MessageRequestStatus; import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; @@ -58,6 +59,13 @@ public class MessageRequest { @JoinColumn(name = "gifticon_product_id") private GifticonProduct gifticonProduct; + @Column(name = "held_gift_ting", nullable = false) + private int heldGiftTing; + + @Enumerated(EnumType.STRING) + @Column(name = "gift_payment_status", length = 30) + private GifticonPaymentStatus giftPaymentStatus; + public MessageRequest( Long fromUserId, Long toUserId, @@ -70,6 +78,8 @@ public MessageRequest( this.message = message; this.source = source; this.gifticonProduct = gifticonProduct; + this.heldGiftTing = gifticonProduct == null ? 0 : gifticonProduct.getTingPrice(); + this.giftPaymentStatus = gifticonProduct == null ? null : GifticonPaymentStatus.HELD; this.status = MessageRequestStatus.PENDING; this.createdAt = LocalDateTime.now(); } @@ -94,4 +104,29 @@ public void reject(String reason) { this.rejectReason = reason; this.respondedAt = LocalDateTime.now(); } + + public void captureGiftPayment() { + if (gifticonProduct == null) { + return; + } + if (giftPaymentStatus != GifticonPaymentStatus.HELD) { + throw new IllegalStateException("보류 중인 기프티콘 결제가 아닙니다."); + } + giftPaymentStatus = GifticonPaymentStatus.CAPTURED; + } + + public int releaseGiftPayment() { + if (gifticonProduct == null) { + return 0; + } + if (giftPaymentStatus != GifticonPaymentStatus.HELD) { + throw new IllegalStateException("보류 중인 기프티콘 결제가 아닙니다."); + } + giftPaymentStatus = GifticonPaymentStatus.RELEASED; + return heldGiftTing; + } + + public boolean hasCapturedGiftPayment() { + return giftPaymentStatus == GifticonPaymentStatus.CAPTURED; + } } 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/test/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequestGiftPaymentTest.java b/manabom/src/test/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequestGiftPaymentTest.java new file mode 100644 index 0000000..2e62e8f --- /dev/null +++ b/manabom/src/test/java/mannabom_server/manabom/domain/messageRequest/entity/MessageRequestGiftPaymentTest.java @@ -0,0 +1,52 @@ +package mannabom_server.manabom.domain.messageRequest.entity; + +import mannabom_server.manabom.domain.gifticon.entity.GifticonProduct; +import mannabom_server.manabom.domain.gifticon.enums.GifticonPaymentStatus; +import mannabom_server.manabom.domain.messageRequest.enums.MessageSource; +import org.junit.jupiter.api.Test; + +import java.lang.reflect.Field; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class MessageRequestGiftPaymentTest { + + @Test + void holdsAndCapturesPaidTingForGifticon() throws Exception { + MessageRequest request = giftMessageRequest(3); + + assertThat(request.getHeldGiftTing()).isEqualTo(3); + assertThat(request.getGiftPaymentStatus()).isEqualTo(GifticonPaymentStatus.HELD); + + request.captureGiftPayment(); + + assertThat(request.getGiftPaymentStatus()).isEqualTo(GifticonPaymentStatus.CAPTURED); + assertThatThrownBy(request::releaseGiftPayment) + .isInstanceOf(IllegalStateException.class); + } + + @Test + void releasesExactlyHeldPaidTingWhenRejected() throws Exception { + MessageRequest request = giftMessageRequest(100); + + int released = request.releaseGiftPayment(); + + assertThat(released).isEqualTo(100); + assertThat(request.getGiftPaymentStatus()).isEqualTo(GifticonPaymentStatus.RELEASED); + } + + private MessageRequest giftMessageRequest(int tingPrice) throws Exception { + GifticonProduct product = new GifticonProduct(100L); + Field tingPriceField = GifticonProduct.class.getDeclaredField("tingPrice"); + tingPriceField.setAccessible(true); + tingPriceField.setInt(product, tingPrice); + return new MessageRequest( + 1L, + 2L, + "안녕하세요", + MessageSource.PROFILE_MATCH, + product + ); + } +} From 79a8a260185ce05163e217b281ba3696eb92a8ba Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 21:29:48 +0900 Subject: [PATCH 08/48] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=ED=94=84=ED=8B=B0=EC=BD=98=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=EA=B2=80=EC=83=89=20=EC=A7=80=EC=9B=90?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../dto/response/AdminGifticonProductResponse.java | 10 ++++++++-- .../admin/service/AdminGifticonService.java | 7 ++++++- .../repository/GifticonProductRepository.java | 13 +++++++++++++ .../admin/controller/AdminGifticonController.java | 14 ++++++++++++-- 4 files changed, 39 insertions(+), 5 deletions(-) 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 index 380f64e..9bf1e94 100644 --- 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 @@ -2,16 +2,20 @@ 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 tingPrice, boolean available, - boolean templateTokenConfigured + boolean templateTokenConfigured, + Instant lastSyncedAt ) { public static AdminGifticonProductResponse from(GifticonProduct product) { return new AdminGifticonProductResponse( @@ -20,10 +24,12 @@ public static AdminGifticonProductResponse from(GifticonProduct product) { product.getTemplateName(), product.getProductName(), product.getBrandName(), + product.getProductThumbnailImageUrl(), product.getProductPrice(), product.getTingPrice(), product.isAvailable(), - product.hasTemplateToken() + product.hasTemplateToken(), + product.getLastSyncedAt() ); } } 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 index 4436d21..669bac1 100644 --- 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 @@ -14,6 +14,7 @@ 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; @@ -31,7 +32,9 @@ public class AdminGifticonService { public AdminGifticonProductSliceResponse getProducts( AdminPrincipal admin, Long cursor, - int size + int size, + Boolean tokenConfigured, + String keyword ) { requireSuperAdmin(admin); if (cursor != null && cursor < 0) { @@ -43,6 +46,8 @@ public AdminGifticonProductSliceResponse getProducts( List fetched = gifticonProductRepository.findProductsForAdminAfter( cursor == null ? 0L : cursor, + tokenConfigured, + StringUtils.hasText(keyword) ? keyword.trim() : "", PageRequest.of(0, size + 1) ); boolean hasNext = fetched.size() > size; 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 index d81e03b..a390cbb 100644 --- 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 @@ -47,10 +47,23 @@ List findAvailableProductsAfter( 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/presentation/admin/controller/AdminGifticonController.java b/manabom/src/main/java/mannabom_server/manabom/presentation/admin/controller/AdminGifticonController.java index 90e2abf..3cf311b 100644 --- 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 @@ -29,9 +29,19 @@ public class AdminGifticonController { public ResponseEntity getProducts( @AuthenticationPrincipal AdminPrincipal admin, @RequestParam(required = false) Long cursor, - @RequestParam(defaultValue = "20") int size + @RequestParam(defaultValue = "20") int size, + @RequestParam(required = false) Boolean tokenConfigured, + @RequestParam(required = false) String keyword ) { - return ResponseEntity.ok(adminGifticonService.getProducts(admin, cursor, size)); + return ResponseEntity.ok( + adminGifticonService.getProducts( + admin, + cursor, + size, + tokenConfigured, + keyword + ) + ); } @PutMapping("/{gifticonProductId}/template-token") From fa4a15962c099388b6f6ab949869b5e97a70647e Mon Sep 17 00:00:00 2001 From: sehi55 Date: Fri, 24 Jul 2026 21:29:55 +0900 Subject: [PATCH 09/48] =?UTF-8?q?feat:=20=EA=B4=80=EB=A6=AC=EC=9E=90=20?= =?UTF-8?q?=EA=B8=B0=ED=94=84=ED=8B=B0=EC=BD=98=20=EA=B4=80=EB=A6=AC=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/main/resources/static/admin/app.js | 276 +++++++++++++++++- .../main/resources/static/admin/index.html | 91 ++++++ .../main/resources/static/admin/styles.css | 125 +++++++- 3 files changed, 485 insertions(+), 7 deletions(-) diff --git a/manabom/src/main/resources/static/admin/app.js b/manabom/src/main/resources/static/admin/app.js index c7d129e..e1141d7 100644 --- a/manabom/src/main/resources/static/admin/app.js +++ b/manabom/src/main/resources/static/admin/app.js @@ -17,7 +17,17 @@ 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 }; const policyKeys = [ @@ -160,6 +170,37 @@ function bindEvents() { state.auditPage += 1; loadAudits(); }); + $("gifticonTokenForm").addEventListener("submit", saveGifticonToken); + $("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(); + }); renderRoleCards([]); document.querySelectorAll(".nav-item").forEach((button) => { button.addEventListener("click", () => switchView(button.dataset.view)); @@ -240,6 +281,7 @@ function switchView(viewId) { }); const titles = { usersView: "회원 관리", + gifticonsView: "기프티콘 관리", policiesView: "운영 정책", pushView: "푸시 알림", reportsView: "신고/CS 처리", @@ -252,7 +294,9 @@ function switchView(viewId) { } function refreshCurrentView() { - if (!$("auditsView").classList.contains("hidden")) { + if (!$("gifticonsView").classList.contains("hidden")) { + loadGifticons(); + } else if (!$("auditsView").classList.contains("hidden")) { loadAudits(); } else if (!$("reportsView").classList.contains("hidden")) { loadReports(); @@ -275,6 +319,223 @@ 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 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.tingPrice)}팅 + ${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.tingPrice)}팅`)} + ${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 + ? `등록 완료` + : `미등록`; +} + async function loadUsers() { const query = new URLSearchParams({ page: String(state.page), @@ -1239,7 +1500,8 @@ function auditActionLabel(actionType) { MEMBERSHIP_ACTIVATE: "멤버십 활성화", POLICY_UPDATE: "운영 정책 변경", PUSH_SEND: "푸시 발송", - REPORT_PROCESS: "신고 처리" + REPORT_PROCESS: "신고 처리", + GIFTICON_TEMPLATE_TOKEN_UPDATE: "기프티콘 토큰 등록/변경" }; return labels[actionType] || actionType || "-"; } @@ -1251,7 +1513,8 @@ function auditTargetLabel(targetType) { TING_WALLET: "팅 지갑", POLICY: "운영 정책", PUSH: "푸시", - REPORT: "신고" + REPORT: "신고", + GIFTICON_PRODUCT: "기프티콘 상품" }; return labels[targetType] || targetType || "-"; } @@ -1287,6 +1550,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..a8c0bb0 100644 --- a/manabom/src/main/resources/static/admin/index.html +++ b/manabom/src/main/resources/static/admin/index.html @@ -36,6 +36,7 @@

관리자 로그인