From 8745acd7a4450f62ab85628e3b8a3446aad7bcf1 Mon Sep 17 00:00:00 2001 From: JOO Date: Thu, 29 Oct 2026 01:07:23 +0900 Subject: [PATCH 1/2] =?UTF-8?q?Feat:=20Start=C2=B7Run=20=EC=9A=94=EA=B8=88?= =?UTF-8?q?=EC=A0=9C=20=EC=B2=98=EB=A6=AC=20=EC=A0=84=EB=9E=B5=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 --- .../domain/funding/entity/FundingCampaign.java | 10 ++++++++++ .../domain/funding/enums/TumblbugPlan.java | 7 +++++++ .../service/RunTumblbugPlanHandler.java | 18 ++++++++++++++++++ .../service/StartTumblbugPlanHandler.java | 18 ++++++++++++++++++ .../funding/service/TumblbugPlanHandler.java | 10 ++++++++++ 5 files changed, 63 insertions(+) create mode 100644 src/main/java/com/swyp/picke/domain/funding/enums/TumblbugPlan.java create mode 100644 src/main/java/com/swyp/picke/domain/funding/service/RunTumblbugPlanHandler.java create mode 100644 src/main/java/com/swyp/picke/domain/funding/service/StartTumblbugPlanHandler.java create mode 100644 src/main/java/com/swyp/picke/domain/funding/service/TumblbugPlanHandler.java diff --git a/src/main/java/com/swyp/picke/domain/funding/entity/FundingCampaign.java b/src/main/java/com/swyp/picke/domain/funding/entity/FundingCampaign.java index fcecfb90..a3d1a344 100644 --- a/src/main/java/com/swyp/picke/domain/funding/entity/FundingCampaign.java +++ b/src/main/java/com/swyp/picke/domain/funding/entity/FundingCampaign.java @@ -1,6 +1,7 @@ package com.swyp.picke.domain.funding.entity; import com.swyp.picke.domain.funding.enums.FundingCampaignStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; import com.swyp.picke.domain.ticket.enums.FundingPlatform; import com.swyp.picke.global.common.BaseEntity; import jakarta.persistence.Column; @@ -46,9 +47,18 @@ public class FundingCampaign extends BaseEntity { @Column(name = "register_policy_version", nullable = false, length = 30) private String registerPolicyVersion = "1"; + @Enumerated(EnumType.STRING) + @Column(name = "tumblbug_plan", nullable = false, length = 20) + private TumblbugPlan tumblbugPlan = TumblbugPlan.RUN; + public FundingCampaign(FundingPlatform platform, String externalCampaignId, String name) { + this(platform, externalCampaignId, name, TumblbugPlan.RUN); + } + + public FundingCampaign(FundingPlatform platform, String externalCampaignId, String name, TumblbugPlan tumblbugPlan) { this.platform = platform; this.externalCampaignId = externalCampaignId; this.name = name; + this.tumblbugPlan = tumblbugPlan; } } diff --git a/src/main/java/com/swyp/picke/domain/funding/enums/TumblbugPlan.java b/src/main/java/com/swyp/picke/domain/funding/enums/TumblbugPlan.java new file mode 100644 index 00000000..2b566f35 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/funding/enums/TumblbugPlan.java @@ -0,0 +1,7 @@ +package com.swyp.picke.domain.funding.enums; + +/** 텀블벅 요금제별 코드 전달 기능. 구매자 등록과 코드 발급은 공통입니다. */ +public enum TumblbugPlan { + START, + RUN +} diff --git a/src/main/java/com/swyp/picke/domain/funding/service/RunTumblbugPlanHandler.java b/src/main/java/com/swyp/picke/domain/funding/service/RunTumblbugPlanHandler.java new file mode 100644 index 00000000..433a5cad --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/funding/service/RunTumblbugPlanHandler.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.funding.service; + +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import org.springframework.stereotype.Component; + +/** Run: Start 공통 기능에 텀블벅 디지털 선물 코드 XLSX 전달을 더합니다. */ +@Component +public class RunTumblbugPlanHandler implements TumblbugPlanHandler { + @Override + public TumblbugPlan plan() { + return TumblbugPlan.RUN; + } + + @Override + public boolean supportsDigitalGiftWorkbook() { + return true; + } +} diff --git a/src/main/java/com/swyp/picke/domain/funding/service/StartTumblbugPlanHandler.java b/src/main/java/com/swyp/picke/domain/funding/service/StartTumblbugPlanHandler.java new file mode 100644 index 00000000..9672093e --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/funding/service/StartTumblbugPlanHandler.java @@ -0,0 +1,18 @@ +package com.swyp.picke.domain.funding.service; + +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import org.springframework.stereotype.Component; + +/** Start: 명단 업로드·수동 등록·코드 발급 후 텀블벅 1:1 메시지로 전달합니다. */ +@Component +public class StartTumblbugPlanHandler implements TumblbugPlanHandler { + @Override + public TumblbugPlan plan() { + return TumblbugPlan.START; + } + + @Override + public boolean supportsDigitalGiftWorkbook() { + return false; + } +} diff --git a/src/main/java/com/swyp/picke/domain/funding/service/TumblbugPlanHandler.java b/src/main/java/com/swyp/picke/domain/funding/service/TumblbugPlanHandler.java new file mode 100644 index 00000000..31cdc1a4 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/funding/service/TumblbugPlanHandler.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.funding.service; + +import com.swyp.picke.domain.funding.enums.TumblbugPlan; + +/** 요금제별 텀블벅 전달 기능을 구분하는 전략 계약. */ +public interface TumblbugPlanHandler { + TumblbugPlan plan(); + + boolean supportsDigitalGiftWorkbook(); +} From 812c6152d9f35884d82d61fa9525eb1c44123761 Mon Sep 17 00:00:00 2001 From: JOO Date: Thu, 29 Oct 2026 01:08:51 +0900 Subject: [PATCH 2/2] =?UTF-8?q?Feat:=20=ED=85=80=EB=B8=94=EB=B2=85=20?= =?UTF-8?q?=EA=B5=AC=EB=A7=A4=EC=9E=90=20=EC=97=91=EC=85=80=C2=B7=EC=88=98?= =?UTF-8?q?=EB=8F=99=20=EB=93=B1=EB=A1=9D=20API=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../request/ManualTumblbugBuyerRequest.java | 34 +++ .../response/ManualTumblbugBuyerResponse.java | 27 +++ .../controller/FundingAdminController.java | 23 ++ .../TumblbugSupporterImportRequest.java | 13 +- .../response/TumblbugOrderDetailResponse.java | 5 + .../TumblbugSupporterImportResponse.java | 32 +++ .../TumblbugImportRecordRepository.java | 5 +- .../funding/service/FundingAdminService.java | 4 + .../service/TumblbugImportRecordService.java | 14 +- .../service/impl/FundingAdminServiceImpl.java | 203 +++++++++++++++--- 10 files changed, 329 insertions(+), 31 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugSupporterImportResponse.java diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java new file mode 100644 index 00000000..64fd0222 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/ManualTumblbugBuyerRequest.java @@ -0,0 +1,34 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.PositiveOrZero; +import jakarta.validation.constraints.Size; +import java.time.LocalDateTime; + +@Schema(description = "텀블벅 펀딩 구매자 수동 등록. 코드는 입력하지 않고 결제 완료 시 PICKE가 발급합니다.") +public record ManualTumblbugBuyerRequest( + @NotNull TumblbugPlan plan, + @NotBlank @Size(max = 50) String campaignId, + @NotBlank @Size(max = 120) String campaignName, + @NotBlank @Size(max = 120) String supporterNumber, + @NotBlank @Size(max = 120) String rewardLineId, + @NotBlank @Size(max = 50) String buyerName, + @NotBlank @Size(max = 120) String rewardName, + @Size(max = 300) String giftText, + @NotNull @Min(5) @Max(30) Integer capacity, + @NotNull @Min(1) @Max(7) Integer quantity, + @NotNull LocalDateTime supportedAt, + @NotNull @PositiveOrZero Long rewardAmount, + @NotNull @PositiveOrZero Long additionalSupportAmount, + @NotNull @PositiveOrZero Long totalSupportAmount, + @NotNull PaymentStatus paymentStatus, + @NotNull DeliveryStatus giftDeliveryStatus +) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java new file mode 100644 index 00000000..56e15167 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/ManualTumblbugBuyerResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import java.time.LocalDateTime; +import java.util.List; + +/** 수동 등록 결과. 발급 코드는 관리자 응답에만 포함하고 로그에는 기록하지 않습니다. */ +public record ManualTumblbugBuyerResponse( + Long fundingOrderId, + TumblbugPlan plan, + String campaignId, + String supporterNumber, + String buyerName, + String rewardName, + int quantity, + LocalDateTime supportedAt, + long rewardAmount, + long additionalSupportAmount, + long totalSupportAmount, + PaymentStatus paymentStatus, + DeliveryStatus giftDeliveryStatus, + List issuedCodes +) { + public record IssuedCode(Long ticketId, String code) {} +} diff --git a/src/main/java/com/swyp/picke/domain/funding/controller/FundingAdminController.java b/src/main/java/com/swyp/picke/domain/funding/controller/FundingAdminController.java index 5b68e6d3..bfa9f133 100644 --- a/src/main/java/com/swyp/picke/domain/funding/controller/FundingAdminController.java +++ b/src/main/java/com/swyp/picke/domain/funding/controller/FundingAdminController.java @@ -1,5 +1,7 @@ package com.swyp.picke.domain.funding.controller; +import com.swyp.picke.domain.admin.dto.funding.request.ManualTumblbugBuyerRequest; +import com.swyp.picke.domain.admin.dto.funding.response.ManualTumblbugBuyerResponse; import com.swyp.picke.domain.funding.dto.request.*; import com.swyp.picke.domain.funding.dto.response.*; import com.swyp.picke.domain.funding.service.FundingAdminService; @@ -77,6 +79,27 @@ public ApiResponse inspectTumblbugWorkbook( return ApiResponse.onSuccess(fundingAdminService.inspectTumblbugWorkbook(readXlsx(file))); } + @Operation(summary = "텀블벅 구매자 공통 등록", description = "Start·Run 공통 엑셀 등록입니다. 컬럼 검증·중복 방지 후 결제 완료 건은 수량만큼 코드를 발급하고 결과를 돌려줍니다.") + @PostMapping(value = "/funding-orders/tumblbug/import", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) + public ResponseEntity> importTumblbugSupporters( + @Valid @RequestPart("options") TumblbugSupporterImportRequest options, + @RequestPart("file") MultipartFile file, + @AuthenticationPrincipal Long adminUserId + ) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(ApiResponse.onSuccess(fundingAdminService.importTumblbugSupporters(options, readXlsx(file), adminUserId))); + } + + @Operation(summary = "텀블벅 구매자 수동 등록", description = "Start·Run 공통 8개 화면 필드를 수동 저장합니다. 결제 예약은 코드 미발급, 결제 완료는 구매 수량만큼 코드 발급입니다.") + @PostMapping("/funding-orders/tumblbug/manual") + public ResponseEntity> createManualTumblbugBuyer( + @Valid @RequestBody ManualTumblbugBuyerRequest request, + @AuthenticationPrincipal Long adminUserId + ) { + return ResponseEntity.ok().cacheControl(CacheControl.noStore()) + .body(ApiResponse.onSuccess(fundingAdminService.createManualTumblbugBuyer(request, adminUserId))); + } + @Operation(summary = "텀블벅 후원자 저장 및 코드 엑셀 생성", description = "자동 판별한 후원자 표 데이터를 DB에 저장하고 결제 완료 행의 이용권 코드를 텀블벅 업로드용 엑셀로 반환합니다. 원문 코드는 이 파일에만 포함됩니다.") @io.swagger.v3.oas.annotations.responses.ApiResponses({ @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200", description = "후원번호-이용권 코드 XLSX 다운로드", diff --git a/src/main/java/com/swyp/picke/domain/funding/dto/request/TumblbugSupporterImportRequest.java b/src/main/java/com/swyp/picke/domain/funding/dto/request/TumblbugSupporterImportRequest.java index 2851169a..91c01192 100644 --- a/src/main/java/com/swyp/picke/domain/funding/dto/request/TumblbugSupporterImportRequest.java +++ b/src/main/java/com/swyp/picke/domain/funding/dto/request/TumblbugSupporterImportRequest.java @@ -1,5 +1,7 @@ package com.swyp.picke.domain.funding.dto.request; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; + import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Min; @@ -14,6 +16,15 @@ public record TumblbugSupporterImportRequest( @NotBlank @Size(max = 31) @Schema(description = "처리할 리워드 시트 이름. 열 제목은 자동 판별") String sheetName, @Size(max = 120) @Schema(description = "픽케 내부 중복 방지용 리워드 키. 생략하면 시트명을 사용", example = "class-ticket-15") String rewardLineId, @NotNull @Min(5) @Max(30) @Schema(description = "리워드에 대응하는 이용권 정원. 5, 15, 30", allowableValues = {"5", "15", "30"}, example = "15") Integer capacity, - @Size(max = 50) @Schema(description = "원본 파일명 또는 내보내기 버전. 미입력 시 서버가 생성") String sourceVersion + @Size(max = 50) @Schema(description = "원본 파일명 또는 내보내기 버전. 미입력 시 서버가 생성") String sourceVersion, + @Schema(description = "텀블벅 요금제. 생략하면 기존 호환을 위해 RUN", example = "START") TumblbugPlan plan ) { + public TumblbugSupporterImportRequest(String campaignId, String campaignName, String sheetName, + String rewardLineId, Integer capacity, String sourceVersion) { + this(campaignId, campaignName, sheetName, rewardLineId, capacity, sourceVersion, null); + } + + public TumblbugPlan effectivePlan() { + return plan == null ? TumblbugPlan.RUN : plan; + } } diff --git a/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugOrderDetailResponse.java b/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugOrderDetailResponse.java index d338817c..d721d180 100644 --- a/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugOrderDetailResponse.java +++ b/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugOrderDetailResponse.java @@ -1,10 +1,15 @@ package com.swyp.picke.domain.funding.dto.response; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import java.time.LocalDateTime; + /** XLSX-only fields attached to the existing admin order response. */ public record TumblbugOrderDetailResponse( String campaignName, + TumblbugPlan plan, String supporterNumber, String rewardLineId, + LocalDateTime supportedAt, String rewardName, String giftText, Long rewardAmount, diff --git a/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugSupporterImportResponse.java b/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugSupporterImportResponse.java new file mode 100644 index 00000000..ab769eef --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/funding/dto/response/TumblbugSupporterImportResponse.java @@ -0,0 +1,32 @@ +package com.swyp.picke.domain.funding.dto.response; + +import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; +import com.swyp.picke.domain.ticket.enums.PaymentStatus; +import java.time.LocalDateTime; +import java.util.List; + +/** Start·Run 공통 업로드 결과. 전체 코드는 관리자 전용 응답에서만 전달합니다. */ +public record TumblbugSupporterImportResponse( + TumblbugPlan plan, + int importedCount, + int issuedCodeCount, + List buyers +) { + public record Buyer( + int rowNumber, + String supporterNumber, + String buyerName, + String rewardName, + int quantity, + LocalDateTime supportedAt, + Long rewardAmount, + Long additionalSupportAmount, + Long totalSupportAmount, + PaymentStatus paymentStatus, + DeliveryStatus giftDeliveryStatus, + List issuedCodes + ) {} + + public record IssuedCode(Long ticketId, String code) {} +} diff --git a/src/main/java/com/swyp/picke/domain/funding/repository/TumblbugImportRecordRepository.java b/src/main/java/com/swyp/picke/domain/funding/repository/TumblbugImportRecordRepository.java index c3d39420..a6f273bc 100644 --- a/src/main/java/com/swyp/picke/domain/funding/repository/TumblbugImportRecordRepository.java +++ b/src/main/java/com/swyp/picke/domain/funding/repository/TumblbugImportRecordRepository.java @@ -3,12 +3,15 @@ import com.swyp.picke.domain.funding.entity.TumblbugImportRecord; import java.util.Collection; import java.util.List; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.EntityGraph; public interface TumblbugImportRecordRepository extends JpaRepository { @EntityGraph(attributePaths = {"campaign", "fundingOrder"}) - List findByFundingOrderIdIn(Collection fundingOrderIds); + @Query("select r from TumblbugImportRecord r join fetch r.campaign where r.fundingOrder.id in :fundingOrderIds") + List findByFundingOrderIdIn(@Param("fundingOrderIds") Collection fundingOrderIds); boolean existsByCampaignIdAndSupporterNumberAndRewardLineId(Long campaignId, String supporterNumber, String rewardLineId); List findByCampaignIdAndSupporterNumberInAndRewardLineId( diff --git a/src/main/java/com/swyp/picke/domain/funding/service/FundingAdminService.java b/src/main/java/com/swyp/picke/domain/funding/service/FundingAdminService.java index 98887749..8800c0b2 100644 --- a/src/main/java/com/swyp/picke/domain/funding/service/FundingAdminService.java +++ b/src/main/java/com/swyp/picke/domain/funding/service/FundingAdminService.java @@ -1,5 +1,7 @@ package com.swyp.picke.domain.funding.service; +import com.swyp.picke.domain.admin.dto.funding.request.ManualTumblbugBuyerRequest; +import com.swyp.picke.domain.admin.dto.funding.response.ManualTumblbugBuyerResponse; import com.swyp.picke.domain.funding.dto.request.TumblbugSupporterImportRequest; import com.swyp.picke.domain.funding.dto.request.TumblbugDigitalGiftDeliveryUpdateRequest; import com.swyp.picke.domain.funding.dto.response.*; @@ -8,6 +10,8 @@ public interface FundingAdminService { TumblbugImportPreviewResponse previewTumblbugSupporters(TumblbugSupporterImportRequest request, byte[] file); TumblbugWorkbookInspectResponse inspectTumblbugWorkbook(byte[] file); + TumblbugSupporterImportResponse importTumblbugSupporters(TumblbugSupporterImportRequest request, byte[] file, Long adminUserId); + ManualTumblbugBuyerResponse createManualTumblbugBuyer(ManualTumblbugBuyerRequest request, Long adminUserId); byte[] issueTumblbugDigitalGiftCodes(TumblbugSupporterImportRequest request, byte[] file, Long adminUserId); TumblbugDigitalGiftDeliveryUpdateResponse updateTumblbugDigitalGiftDelivery( TumblbugDigitalGiftDeliveryUpdateRequest request, Long adminUserId); diff --git a/src/main/java/com/swyp/picke/domain/funding/service/TumblbugImportRecordService.java b/src/main/java/com/swyp/picke/domain/funding/service/TumblbugImportRecordService.java index 08786949..7b6b08a2 100644 --- a/src/main/java/com/swyp/picke/domain/funding/service/TumblbugImportRecordService.java +++ b/src/main/java/com/swyp/picke/domain/funding/service/TumblbugImportRecordService.java @@ -27,8 +27,9 @@ public Map getDetails(List orderIds) { try { JsonNode row = objectMapper.readTree(record.getSourceSnapshot()); result.put(record.getFundingOrder().getId(), new TumblbugOrderDetailResponse( - record.getCampaign().getName(), record.getSupporterNumber(), record.getRewardLineId(), - text(row, "rewardName"), text(row, "giftText"), number(row, "rewardAmount"), + record.getCampaign().getName(), record.getCampaign().getTumblbugPlan(), + record.getSupporterNumber(), record.getRewardLineId(), + date(row, "paidAt"), text(row, "rewardName"), text(row, "giftText"), number(row, "rewardAmount"), number(row, "additionalSupportAmount"), number(row, "totalSupportAmount"), row.hasNonNull("notificationRequested") ? row.get("notificationRequested").asBoolean() : null, text(row, "notificationRequest"), text(row, "rawPaymentStatus"), text(row, "giftDeliveryStatus"))); @@ -46,4 +47,13 @@ private String text(JsonNode row, String field) { private Long number(JsonNode row, String field) { return row.hasNonNull(field) ? row.get(field).asLong() : null; } + + private java.time.LocalDateTime date(JsonNode row, String field) { + if (!row.hasNonNull(field)) return null; + try { + return java.time.LocalDateTime.parse(row.get(field).asText()); + } catch (java.time.format.DateTimeParseException ignored) { + return null; + } + } } diff --git a/src/main/java/com/swyp/picke/domain/funding/service/impl/FundingAdminServiceImpl.java b/src/main/java/com/swyp/picke/domain/funding/service/impl/FundingAdminServiceImpl.java index e042fb90..b5b0256f 100644 --- a/src/main/java/com/swyp/picke/domain/funding/service/impl/FundingAdminServiceImpl.java +++ b/src/main/java/com/swyp/picke/domain/funding/service/impl/FundingAdminServiceImpl.java @@ -3,8 +3,10 @@ import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.swyp.picke.domain.admin.service.AdminFundingService; +import com.swyp.picke.domain.admin.dto.funding.request.ManualTumblbugBuyerRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketIssueRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketDeliveryRequest; +import com.swyp.picke.domain.admin.dto.funding.response.ManualTumblbugBuyerResponse; import com.swyp.picke.domain.funding.dto.request.*; import com.swyp.picke.domain.funding.dto.response.*; import com.swyp.picke.domain.funding.entity.AdminAuditLog; @@ -12,6 +14,7 @@ import com.swyp.picke.domain.funding.entity.TumblbugImportRecord; import com.swyp.picke.domain.funding.enums.FundingCampaignStatus; import com.swyp.picke.domain.funding.enums.DeliveryStatus; +import com.swyp.picke.domain.funding.enums.TumblbugPlan; import com.swyp.picke.domain.funding.repository.AdminAuditLogRepository; import com.swyp.picke.domain.funding.repository.FundingCampaignRepository; import com.swyp.picke.domain.funding.repository.TumblbugImportRecordRepository; @@ -50,11 +53,13 @@ public class FundingAdminServiceImpl implements FundingAdminService { private final ObjectMapper objectMapper; private final TumblbugSupporterExcelProcessor tumblbugExcelProcessor; private final TumblbugDigitalGiftWorkbookWriter tumblbugWorkbookWriter; + private final List planHandlers; @Override @Transactional(readOnly = true) public TumblbugImportPreviewResponse previewTumblbugSupporters(TumblbugSupporterImportRequest request, byte[] file) { validateTumblbugImportRequest(request); + validateCampaignPlan(request); return analyzeTumblbugWorkbook(request, file, true).preview(); } @@ -70,35 +75,107 @@ public TumblbugWorkbookInspectResponse inspectTumblbugWorkbook(byte[] file) { @Transactional public byte[] issueTumblbugDigitalGiftCodes(TumblbugSupporterImportRequest request, byte[] file, Long adminUserId) { validateTumblbugImportRequest(request); - // Serialize imports per campaign, then recheck duplicates inside the lock. - FundingCampaign campaign = fundingCampaignRepository - .findByPlatformAndExternalCampaignIdForUpdate(FundingPlatform.TUMBLBUG, request.campaignId().trim()) - .orElseGet(() -> fundingCampaignRepository.saveAndFlush(new FundingCampaign( - FundingPlatform.TUMBLBUG, request.campaignId().trim(), request.campaignName().trim()))); + if (!planHandler(request.effectivePlan()).supportsDigitalGiftWorkbook()) { + throw new CustomException(ErrorCode.TUMBLBUG_PLAN_FEATURE_UNAVAILABLE); + } + TumblbugSupporterImportResponse imported = importTumblbugSupporters(request, file, adminUserId); + Map codes = new LinkedHashMap<>(); + imported.buyers().forEach(buyer -> { + if (!buyer.issuedCodes().isEmpty()) { + codes.put(buyer.supporterNumber(), buyer.issuedCodes().stream() + .map(TumblbugSupporterImportResponse.IssuedCode::code) + .collect(Collectors.joining("\n"))); + } + }); + return tumblbugWorkbookWriter.write(codes); + } + + @Override + @Transactional + public TumblbugSupporterImportResponse importTumblbugSupporters( + TumblbugSupporterImportRequest request, byte[] file, Long adminUserId) { + validateTumblbugImportRequest(request); + FundingCampaign campaign = getOrCreateCampaign(request.campaignId().trim(), request.campaignName().trim(), + request.effectivePlan()); ImportAnalysis analysis = analyzeTumblbugWorkbook(request, file, true); if (analysis.duplicateRows() > 0) throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_DUPLICATE); if (analysis.invalidRows() > 0) throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_ROWS_INVALID); if (analysis.importableRows().isEmpty()) throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_EMPTY); - Map codes = new LinkedHashMap<>(); + List buyers = new ArrayList<>(); + int issuedCodeCount = 0; for (PreparedSupporter row : analysis.importableRows()) { FundingOrder order = fundingOrderRepository.saveAndFlush(FundingOrder.builder() .platform(FundingPlatform.TUMBLBUG).campaignId(request.campaignId().trim()) .orderId(internalOrderId(request, row.supporterNumber())) .rewardTier(RewardTier.valueOf("SEAT_" + row.capacity())).quantity(row.quantity()) - .supporterName(row.buyerName()).paymentStatus(row.paymentStatus()).paidAt(row.paidAt()).build()); + .supporterName(row.buyerName()).paymentStatus(row.paymentStatus()) + .paidAt(row.paymentStatus() == PaymentStatus.PAID_CONFIRMED ? row.paidAt() : null).build()); importRecordRepository.save(new TumblbugImportRecord(campaign, order, row.supporterNumber(), effectiveRewardLineId(request), request.sourceVersion(), snapshot(row))); - // The latest implementation deliberately holds quantities other than one. - if (order.isIssuable() && order.getPaidAt() != null) { + List codes = new ArrayList<>(); + if (order.isIssuable()) { var issued = adminFundingService.issueTickets(new TicketIssueRequest(List.of(order.getId()))); - codes.put(row.supporterNumber(), issued.issued().getFirst().code()); + issued.issued().forEach(ticket -> codes.add( + new TumblbugSupporterImportResponse.IssuedCode(ticket.ticketId(), ticket.code()))); } + issuedCodeCount += codes.size(); writeAudit(adminUserId, "TUMBLBUG_ORDER_IMPORTED", "FUNDING_ORDER", order.getId(), null, Map.of("supporterNumber", row.supporterNumber(), "rewardLineId", effectiveRewardLineId(request), "quantity", row.quantity(), "paymentStatus", row.paymentStatus())); + buyers.add(new TumblbugSupporterImportResponse.Buyer(row.rowNumber(), row.supporterNumber(), row.buyerName(), + row.rewardName(), row.quantity(), row.paidAt(), row.rewardAmount(), row.additionalSupportAmount(), + row.totalSupportAmount(), row.paymentStatus(), parseGiftDeliveryStatus(row.giftDeliveryStatus()), + List.copyOf(codes))); } - return tumblbugWorkbookWriter.write(codes); + return new TumblbugSupporterImportResponse(request.effectivePlan(), buyers.size(), issuedCodeCount, List.copyOf(buyers)); + } + + @Override + @Transactional + public ManualTumblbugBuyerResponse createManualTumblbugBuyer(ManualTumblbugBuyerRequest request, Long adminUserId) { + validateManualRequest(request); + FundingCampaign campaign = getOrCreateCampaign(request.campaignId().trim(), request.campaignName().trim(), request.plan()); + String supporterNumber = request.supporterNumber().trim(); + String rewardLineId = request.rewardLineId().trim(); + if (importRecordRepository.existsByCampaignIdAndSupporterNumberAndRewardLineId( + campaign.getId(), supporterNumber, rewardLineId)) { + throw new CustomException(ErrorCode.FUNDING_DUPLICATE_SUPPORTER); + } + + String rawStatus = request.paymentStatus() == PaymentStatus.PAID_CONFIRMED ? "결제 완료" : "결제 예약"; + String rawDelivery = request.giftDeliveryStatus() == DeliveryStatus.SENT ? "전달 완료" : "대기 중"; + LocalDateTime paidAt = request.paymentStatus() == PaymentStatus.PAID_CONFIRMED ? request.supportedAt() : null; + PreparedSupporter snapshot = new PreparedSupporter(0, supporterNumber, request.buyerName().trim(), + request.giftText(), request.rewardName().trim(), request.rewardAmount(), request.additionalSupportAmount(), + request.totalSupportAmount(), request.quantity(), request.capacity(), request.paymentStatus(), + request.supportedAt(), null, null, rawStatus, rawDelivery, "MANUAL"); + FundingOrder order = fundingOrderRepository.saveAndFlush(FundingOrder.builder() + .platform(FundingPlatform.TUMBLBUG).campaignId(request.campaignId().trim()) + .orderId(manualInternalOrderId(request.campaignId().trim(), supporterNumber, rewardLineId)) + .rewardTier(RewardTier.valueOf("SEAT_" + request.capacity())).quantity(request.quantity()) + .supporterName(request.buyerName().trim()).paymentStatus(request.paymentStatus()).paidAt(paidAt).build()); + importRecordRepository.save(new TumblbugImportRecord(campaign, order, supporterNumber, rewardLineId, + "MANUAL", snapshot(snapshot))); + + List issuedCodes = new ArrayList<>(); + if (order.isIssuable()) { + var issued = adminFundingService.issueTickets(new TicketIssueRequest(List.of(order.getId()))); + for (var ticket : issued.issued()) { + issuedCodes.add(new ManualTumblbugBuyerResponse.IssuedCode(ticket.ticketId(), ticket.code())); + if (request.giftDeliveryStatus() == DeliveryStatus.SENT) { + adminFundingService.recordDelivery(ticket.ticketId(), new TicketDeliveryRequest( + DeliveryChannel.MANUAL, com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS, null)); + } + } + } + writeAudit(adminUserId, "TUMBLBUG_ORDER_MANUALLY_REGISTERED", "FUNDING_ORDER", order.getId(), null, + Map.of("supporterNumber", supporterNumber, "rewardLineId", rewardLineId, + "quantity", request.quantity(), "paymentStatus", request.paymentStatus())); + return new ManualTumblbugBuyerResponse(order.getId(), request.plan(), request.campaignId().trim(), supporterNumber, + request.buyerName().trim(), request.rewardName().trim(), request.quantity(), request.supportedAt(), + request.rewardAmount(), request.additionalSupportAmount(), request.totalSupportAmount(), + request.paymentStatus(), request.giftDeliveryStatus(), List.copyOf(issuedCodes)); } @Override @@ -116,20 +193,24 @@ public TumblbugDigitalGiftDeliveryUpdateResponse updateTumblbugDigitalGiftDelive List numbers = request.supporterNumbers().stream().map(String::trim).distinct().toList(); List records = importRecordRepository .findByCampaignIdAndSupporterNumberInAndRewardLineId(campaign.getId(), numbers, request.rewardLineId().trim()); + Map> ticketsByOrder = records.isEmpty() ? Map.of() + : classTicketRepository.findByFundingOrderIdInAndStatusNotOrderByIdAsc( + records.stream().map(record -> record.getFundingOrder().getId()).toList(), TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(ticket -> ticket.getFundingOrder().getId())); int updated = 0; for (TumblbugImportRecord record : records) { - ClassTicket ticket = classTicketRepository.findByFundingOrderIdAndStatusNot( - record.getFundingOrder().getId(), TicketStatus.REVOKED).orElse(null); - if (ticket == null || ticket.getCodeStatus() != TicketCodeStatus.ISSUED - || Objects.equals(record.getDeliveryRecordedTicketId(), ticket.getId())) continue; - adminFundingService.recordDelivery(ticket.getId(), new TicketDeliveryRequest( - DeliveryChannel.TUMBLBUG_DIGITAL_GIFT, - request.status() == DeliveryStatus.SENT - ? com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS - : com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED, - request.failedReason())); - record.recordDelivery(ticket.getId()); - updated++; + List tickets = ticketsByOrder.getOrDefault(record.getFundingOrder().getId(), List.of()); + for (ClassTicket ticket : tickets) { + if (ticket.getCodeStatus() != TicketCodeStatus.ISSUED) continue; + adminFundingService.recordDelivery(ticket.getId(), new TicketDeliveryRequest( + DeliveryChannel.TUMBLBUG_DIGITAL_GIFT, + request.status() == DeliveryStatus.SENT + ? com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS + : com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED, + request.failedReason())); + record.recordDelivery(ticket.getId()); + updated++; + } } writeAudit(adminUserId, "TUMBLBUG_DELIVERY_RECORDED", "FUNDING_CAMPAIGN", campaign.getId(), request.failedReason(), Map.of("status", request.status(), "updated", updated)); @@ -148,6 +229,74 @@ private String internalOrderId(TumblbugSupporterImportRequest request, String su } } + private String manualInternalOrderId(String campaignId, String supporterNumber, String rewardLineId) { + try { + String identity = objectMapper.writeValueAsString(List.of(campaignId, supporterNumber, rewardLineId)); + return "TB-" + HexFormat.of().formatHex(MessageDigest.getInstance("SHA-256") + .digest(identity.getBytes(StandardCharsets.UTF_8))).substring(0, 47); + } catch (NoSuchAlgorithmException | JsonProcessingException e) { + throw new IllegalStateException(e); + } + } + + private FundingCampaign getOrCreateCampaign(String campaignId, String campaignName, TumblbugPlan plan) { + FundingCampaign campaign = fundingCampaignRepository + .findByPlatformAndExternalCampaignIdForUpdate(FundingPlatform.TUMBLBUG, campaignId) + .orElseGet(() -> fundingCampaignRepository.saveAndFlush(new FundingCampaign( + FundingPlatform.TUMBLBUG, campaignId, campaignName, plan))); + if (campaign.getTumblbugPlan() != plan) { + throw new CustomException(ErrorCode.TUMBLBUG_PLAN_CONFLICT); + } + if (campaign.getStatus() != FundingCampaignStatus.ACTIVE) { + throw new CustomException(ErrorCode.FUNDING_CAMPAIGN_INACTIVE); + } + return campaign; + } + + private void validateCampaignPlan(TumblbugSupporterImportRequest request) { + fundingCampaignRepository.findByPlatformAndExternalCampaignId(FundingPlatform.TUMBLBUG, request.campaignId().trim()) + .ifPresent(campaign -> { + if (campaign.getTumblbugPlan() != request.effectivePlan()) { + throw new CustomException(ErrorCode.TUMBLBUG_PLAN_CONFLICT); + } + if (campaign.getStatus() != FundingCampaignStatus.ACTIVE) { + throw new CustomException(ErrorCode.FUNDING_CAMPAIGN_INACTIVE); + } + }); + } + + private TumblbugPlanHandler planHandler(TumblbugPlan plan) { + return planHandlers.stream().filter(handler -> handler.plan() == plan).findFirst() + .orElseThrow(() -> new IllegalStateException("No Tumblbug plan handler for " + plan)); + } + + private void validateManualRequest(ManualTumblbugBuyerRequest request) { + if (!List.of(5, 15, 30).contains(request.capacity()) || request.quantity() < 1 || request.quantity() > 7) { + throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID); + } + if (request.paymentStatus() != PaymentStatus.RESERVED && request.paymentStatus() != PaymentStatus.PAID_CONFIRMED) { + throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID); + } + if (request.giftDeliveryStatus() != DeliveryStatus.PENDING && request.giftDeliveryStatus() != DeliveryStatus.SENT) { + throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID); + } + if (request.giftDeliveryStatus() == DeliveryStatus.SENT && request.paymentStatus() != PaymentStatus.PAID_CONFIRMED) { + throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID); + } + if (request.supportedAt().isAfter(LocalDateTime.now(ZoneId.of("Asia/Seoul"))) + || request.totalSupportAmount() < request.rewardAmount() + || request.totalSupportAmount() - request.rewardAmount() < request.additionalSupportAmount()) { + throw new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID); + } + } + + private DeliveryStatus parseGiftDeliveryStatus(String rawValue) { + if (rawValue == null) return DeliveryStatus.PENDING; + String normalized = rawValue.replaceAll("\\s+", "").toLowerCase(); + return normalized.contains("완료") || normalized.contains("sent") + ? DeliveryStatus.SENT : DeliveryStatus.PENDING; + } + private String snapshot(PreparedSupporter row) { try { return objectMapper.writeValueAsString(row); @@ -264,16 +413,16 @@ private ImportAnalysis analyzeTumblbugWorkbook( totalSupportAmount, quantity, request.capacity(), paymentStatus, paidAt, notificationRequested, notificationRequest, rawPaymentStatus, giftDeliveryStatus, sourceSnapshot)); - boolean issuable = paymentStatus == PaymentStatus.PAID_CONFIRMED && quantity == 1; + boolean issuable = paymentStatus == PaymentStatus.PAID_CONFIRMED; String result = issuable ? "READY_CODE" : "SAVED_NO_CODE"; String message = issuable - ? "표 정보를 저장하고 이용권 코드를 발급합니다." - : "표 정보를 저장하며 결제 미확정 또는 수량이 1이 아니므로 발급을 보류합니다."; + ? "표 정보를 저장하고 구매 수량만큼 이용권 코드를 발급합니다." + : "표 정보를 저장하며 결제 미확정이므로 코드는 발급하지 않습니다."; previewRows.add(importRow(source, supporterNumber, buyerName, giftInfo, additionalSupportAmount, totalSupportAmount, paidAt, notificationRequest, quantity, result, message)); } int codeEligibleRows = (int) importableRows.stream() - .filter(row -> row.paymentStatus() == PaymentStatus.PAID_CONFIRMED && row.quantity() == 1).count(); + .filter(row -> row.paymentStatus() == PaymentStatus.PAID_CONFIRMED).count(); TumblbugWorkbookInspectResponse.SheetSample sheetInfo = tumblbugExcelProcessor.inspect(file).sheets().stream() .filter(sheet -> sheet.sheetName().equals(request.sheetName())).findFirst() .orElseThrow(() -> new CustomException(ErrorCode.FUNDING_TUMBLBUG_IMPORT_INVALID));