diff --git a/FUNDING_API.md b/FUNDING_API.md index e003e53b..6d45791c 100644 --- a/FUNDING_API.md +++ b/FUNDING_API.md @@ -67,6 +67,7 @@ Start는 공통 `import` 또는 `manual` 경로로 등록하고 1:1 메시지로 ## 검색과 클래스 연동 주문 검색은 기존 `/admin/funding/orders` 한 곳에서 `campaignId`, `keyword`, `capacity`, `paymentStatus`, `deliveryStatus`, `ticketStatus`, `codeStatus`, `page`, `size`를 지원합니다. 캠페인 대시보드는 후원 주문 수·결제 완료·전달 완료·미전달 수를 캠페인 단위로 집계합니다. +주문의 `deliveryStatus`는 살아 있는 이용권의 마지막 전달 기록 기준입니다. 모두 전달 완료면 `SENT`, 하나라도 실패면 `FAILED`, 그 밖에는 `PENDING`이며 같은 값으로 필터합니다. 기존 주문 응답 필드를 유지하고 `tumblbug`에 요금제·후원일시·상품·금액·후원번호 등을, `tickets`에 발급된 티켓 목록과 전달 상태를 추가합니다. 대시보드는 관리자 전용 `GET /admin/funding/tickets/{ticketId}/code`를 코드 전체 보기·복사 시 호출합니다. 코드 조회는 감사 로그에 기록되고 응답은 캐시되지 않습니다. 코드 암호화를 위해 `TICKET_CODE_ENCRYPTION_KEY`에 Base64 인코딩한 32바이트 키를 설정해야 합니다. 기존 코드 행은 해시만 저장되어 있어 코드 원문을 복구할 수 없습니다. 미등록 기존 코드는 재발급 후 새 코드 조회가 가능합니다. 이용권 검색은 `keyword`, `capacity`, `status`, `registeredFrom`, `registeredTo`, `page`, `size`를 지원합니다. diff --git a/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java b/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java index d0a83f05..9dd76175 100644 --- a/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java +++ b/src/main/java/com/swyp/picke/domain/admin/service/AdminFundingService.java @@ -392,13 +392,13 @@ public FundingOrderPageResponse getOrders(String campaignId, PaymentStatus payme throw new CustomException(ErrorCode.FUNDING_REWARD_UNSUPPORTED); } } + // 전달 상태는 boolean 으로 넘긴다. enum 파라미터를 상수와 비교하면 Hibernate 가 파라미터 타입을 정하지 못해 500 이 난다 (#612) boolean pending = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; - DeliveryStatus recordedStatus = deliveryStatus == null || pending ? null - : deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT - ? DeliveryStatus.SUCCESS : DeliveryStatus.FAILED; + boolean sent = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + boolean failed = deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; Page result = fundingOrderRepository.searchOrders( - paymentStatus, ticketStatus, codeStatus, normalizedCampaignId, normalizedKeyword, rewardTier, recordedStatus, pending, + paymentStatus, ticketStatus, codeStatus, normalizedCampaignId, normalizedKeyword, rewardTier, sent, failed, pending, PageRequest.of(pageNumber, pageSize)); List orderIds = result.getContent().stream().map(FundingOrder::getId).toList(); Map tumblbugDetails = tumblbugImportRecordService.getDetails(orderIds); @@ -458,19 +458,34 @@ private static FundingOrderSummaryResponse toSummary(FundingOrder order, List - t.deliveryStatus() == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT) - ? com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT - : com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING); + orderDeliveryView(ticketViews)); + } + + /** 이용권의 마지막 전달 기록. 기록이 없으면 PENDING */ + private static com.swyp.picke.domain.funding.enums.DeliveryStatus toDeliveryView(DeliveryStatus latest) { + if (latest == DeliveryStatus.SUCCESS) return com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + if (latest == DeliveryStatus.FAILED) return com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; + return com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; + } + + /** 주문 단위 전달 상태. 목록 필터(searchOrders)와 같은 기준: 모두 SENT 면 SENT, 하나라도 FAILED 면 FAILED (#612) */ + private static com.swyp.picke.domain.funding.enums.DeliveryStatus orderDeliveryView( + List tickets) { + if (!tickets.isEmpty() && tickets.stream().allMatch(t -> + t.deliveryStatus() == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT)) { + return com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT; + } + if (tickets.stream().anyMatch(t -> t.deliveryStatus() == com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED)) { + return com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED; + } + return com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING; } @Transactional diff --git a/src/main/java/com/swyp/picke/domain/ticket/repository/FundingOrderRepository.java b/src/main/java/com/swyp/picke/domain/ticket/repository/FundingOrderRepository.java index cb4e28a1..38e313c9 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/repository/FundingOrderRepository.java +++ b/src/main/java/com/swyp/picke/domain/ticket/repository/FundingOrderRepository.java @@ -164,17 +164,16 @@ or exists (select t.id from ClassTicket t where t.fundingOrder = o and not exists (select a.id from TicketDeliveryAttempt a where a.ticket = t and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t)))) - and (:deliveryStatus is null - or (:deliveryStatus = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS - and exists (select t.id from ClassTicket t where t.fundingOrder = o + and (:deliverySent = false + or (exists (select t.id from ClassTicket t where t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) and not exists (select t.id from ClassTicket t where t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and not exists (select a.id from TicketDeliveryAttempt a where a.ticket = t and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS - and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t)))) - or (:deliveryStatus = com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED - and exists (select a.id from TicketDeliveryAttempt a where a.ticket.fundingOrder = o + and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t))))) + and (:deliveryFailed = false + or (exists (select a.id from TicketDeliveryAttempt a where a.ticket.fundingOrder = o and a.ticket.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = a.ticket)))) @@ -203,17 +202,16 @@ or exists (select t.id from ClassTicket t where t.fundingOrder = o and not exists (select a.id from TicketDeliveryAttempt a where a.ticket = t and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t)))) - and (:deliveryStatus is null - or (:deliveryStatus = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS - and exists (select t.id from ClassTicket t where t.fundingOrder = o + and (:deliverySent = false + or (exists (select t.id from ClassTicket t where t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) and not exists (select t.id from ClassTicket t where t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and not exists (select a.id from TicketDeliveryAttempt a where a.ticket = t and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS - and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t)))) - or (:deliveryStatus = com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED - and exists (select a.id from TicketDeliveryAttempt a where a.ticket.fundingOrder = o + and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = t))))) + and (:deliveryFailed = false + or (exists (select a.id from TicketDeliveryAttempt a where a.ticket.fundingOrder = o and a.ticket.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and a.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.FAILED and a.id = (select max(latest.id) from TicketDeliveryAttempt latest where latest.ticket = a.ticket)))) @@ -231,7 +229,8 @@ Page searchOrders(@Param("paymentStatus") PaymentStatus paymentSta @Param("campaignId") String campaignId, @Param("keyword") String keyword, @Param("rewardTier") RewardTier rewardTier, - @Param("deliveryStatus") DeliveryStatus deliveryStatus, + @Param("deliverySent") boolean deliverySent, + @Param("deliveryFailed") boolean deliveryFailed, @Param("deliveryPending") boolean deliveryPending, Pageable pageable); } diff --git a/src/test/java/com/swyp/picke/domain/admin/controller/AdminFundingApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/admin/controller/AdminFundingApiIntegrationTest.java index e6227f3f..c89cf226 100644 --- a/src/test/java/com/swyp/picke/domain/admin/controller/AdminFundingApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/admin/controller/AdminFundingApiIntegrationTest.java @@ -111,8 +111,8 @@ void importCsv_countsAndIdempotent() throws Exception { assertThat(first.get("errorCount").asInt()).isEqualTo(1); assertThat(first.get("errors").get(0).get("line").asInt()).isEqualTo(5); assertThat(first.get("errors").get(0).get("orderId").asText()).isEqualTo(prefix + "ORD-BAD"); - assertThat(first.get("issuableCount").asLong()).isEqualTo(1); // ORD-A 만 - assertThat(first.get("heldCount").asLong()).isEqualTo(2); // ORD-B(수량 2), ORD-C(결제 대기) + assertThat(first.get("issuableCount").asLong()).isEqualTo(2); // ORD-A, ORD-B(수량 2 도 수량만큼 발급, #586) + assertThat(first.get("heldCount").asLong()).isEqualTo(1); // ORD-C(결제 대기) assertThat(first.get("fileHash").asText()).hasSize(64); // 같은 파일 재업로드 → 전부 unchanged, 결제 대기가 확정되면 updated @@ -122,23 +122,33 @@ void importCsv_countsAndIdempotent() throws Exception { JsonNode third = upload(HEADER + "TUMBLBUG,camp,ORD-C,5,1,후원C,,,PAID_CONFIRMED,2026-10-05 10:00:00\n"); assertThat(third.get("updated").asInt()).isEqualTo(1); - assertThat(third.get("issuableCount").asLong()).isEqualTo(2); + assertThat(third.get("issuableCount").asLong()).isEqualTo(3); FundingOrder c = fundingOrderRepository.findByOrderId(prefix + "ORD-C").orElseThrow(); assertThat(c.getPaymentStatus()).isEqualTo(PaymentStatus.PAID_CONFIRMED); assertThat(c.getImportFileHash()).isEqualTo(third.get("fileHash").asText()); } @Test - @DisplayName("발급 → 코드 1회 노출(DB 는 해시만) → 후원자가 그 코드로 등록 → 재실행은 중복 발급하지 않는다") + @DisplayName("발급 → 수량만큼 코드 1회 노출(DB 는 해시만) → 후원자가 그 코드로 등록 → 재실행은 중복 발급하지 않는다") void issue_thenUserRegisters_thenRerunSkips() throws Exception { upload(HEADER + "TUMBLBUG,camp,ORD-A,SEAT_15,1,후원A,a@test.com,,PAID_CONFIRMED,2026-10-01 10:00:00\n" - + "TUMBLBUG,camp,ORD-B,SEAT_5,2,후원B,,,PAID_CONFIRMED,2026-10-01 10:00:00\n"); + + "TUMBLBUG,camp,ORD-B,SEAT_5,2,후원B,,,PAID_CONFIRMED,2026-10-01 10:00:00\n" + + "TUMBLBUG,camp,ORD-P,SEAT_5,1,후원P,,,PAYMENT_PENDING,\n"); + // 수량 2 주문은 코드 2개를 받는다 (#586) JsonNode issued = postJson("/api/v1/admin/funding/tickets/issue", null, adminToken()).get("issued"); - assertThat(issued).hasSize(1); - JsonNode item = issued.get(0); - assertThat(item.get("orderId").asText()).isEqualTo(prefix + "ORD-A"); + assertThat(issued).hasSize(3); + List issuedOrders = new java.util.ArrayList<>(); + issued.forEach(node -> issuedOrders.add(node.get("orderId").asText())); + assertThat(issuedOrders).containsExactlyInAnyOrder(prefix + "ORD-A", prefix + "ORD-B", prefix + "ORD-B"); + JsonNode item = null; + for (JsonNode node : issued) { + if (node.get("orderId").asText().equals(prefix + "ORD-A")) { + item = node; + } + } + assertThat(item).isNotNull(); assertThat(item.get("capacity").asInt()).isEqualTo(15); assertThat(item.get("registerBy").asText()).startsWith("2027-10-01T10:00"); String code = item.get("code").asText(); @@ -154,13 +164,17 @@ void issue_thenUserRegisters_thenRerunSkips() throws Exception { Long orderA = fundingOrderRepository.findByOrderId(prefix + "ORD-A").orElseThrow().getId(); Long orderB = fundingOrderRepository.findByOrderId(prefix + "ORD-B").orElseThrow().getId(); + Long orderP = fundingOrderRepository.findByOrderId(prefix + "ORD-P").orElseThrow().getId(); JsonNode byIds = postJson("/api/v1/admin/funding/tickets/issue", - Map.of("fundingOrderIds", List.of(orderA, orderB, 999_999L)), adminToken()); + Map.of("fundingOrderIds", List.of(orderA, orderB, orderP, 999_999L)), adminToken()); assertThat(byIds.get("issued")).isEmpty(); - assertThat(byIds.get("skipped")).hasSize(3); - assertThat(byIds.get("skipped").get(0).get("reason").asText()).isEqualTo("ALREADY_ISSUED"); - assertThat(byIds.get("skipped").get(1).get("reason").asText()).isEqualTo("NOT_ISSUABLE"); - assertThat(byIds.get("skipped").get(2).get("reason").asText()).isEqualTo("NOT_FOUND"); + Map skippedReasons = new java.util.HashMap<>(); + byIds.get("skipped").forEach(node -> skippedReasons.put(node.get("fundingOrderId").asLong(), node.get("reason").asText())); + assertThat(skippedReasons).containsOnly( + Map.entry(orderA, "ALREADY_ISSUED"), + Map.entry(orderB, "ALREADY_ISSUED"), + Map.entry(orderP, "NOT_ISSUABLE"), + Map.entry(999_999L, "NOT_FOUND")); // 후원자가 받은 코드(소문자·하이픈 그대로)로 등록 mockMvc.perform(post("/api/v1/class-tickets/register") diff --git a/src/test/java/com/swyp/picke/domain/funding/controller/TumblbugFundingApiIntegrationTest.java b/src/test/java/com/swyp/picke/domain/funding/controller/TumblbugFundingApiIntegrationTest.java index 9b736c9d..c221dc1f 100644 --- a/src/test/java/com/swyp/picke/domain/funding/controller/TumblbugFundingApiIntegrationTest.java +++ b/src/test/java/com/swyp/picke/domain/funding/controller/TumblbugFundingApiIntegrationTest.java @@ -133,8 +133,12 @@ void duplicateImportIsRejectedAndHeldRowsDoNotIssueTickets() throws Exception { byte[] source = workbook(row("201", "클래스 19900원", "결제완료"), row("202", "클래스 19900원 (x2)", "결제완료"), row("203", "클래스 19900원", "결제대기")); mvc.perform(upload("/preview", source)).andExpect(status().isOk()) - .andExpect(jsonPath("$.data.importableRows").value(3)).andExpect(jsonPath("$.data.codeEligibleRows").value(1)); - issue(source); + .andExpect(jsonPath("$.data.importableRows").value(3)).andExpect(jsonPath("$.data.codeEligibleRows").value(2)); + // 결제완료 201(1장) + 202(x2 → 2장, #587 구매 수량만큼 발급). 결제대기 203 은 코드를 받지 않는다 + // 결과 엑셀은 후원자당 한 행이고 202 의 코드 2개는 한 칸에 줄바꿈으로 들어간다 + long ticketsBefore = tickets.count(); + issue(source, 2); + assertThat(tickets.count() - ticketsBefore).isEqualTo(3); long orderCount = orders.count(); long ticketCount = tickets.count(); assertThat(imports.findAll().stream().filter(r -> r.getCampaign().getExternalCampaignId().equals(campaign))).hasSize(3); @@ -191,11 +195,15 @@ void adminRoleAndDeliveryValidationAreEnforced() throws Exception { } private String issue(byte[] source) throws Exception { + return issue(source, 1); + } + + private String issue(byte[] source, int expectedRows) throws Exception { byte[] result = mvc.perform(upload("/digital-gifts", source)).andExpect(status().isOk()) .andExpect(header().string("Cache-Control", "no-store, no-cache, must-revalidate")) .andReturn().getResponse().getContentAsByteArray(); try (var workbook = new XSSFWorkbook(new ByteArrayInputStream(result))) { - assertThat(workbook.getSheetAt(0).getLastRowNum()).isEqualTo(1); + assertThat(workbook.getSheetAt(0).getLastRowNum()).isEqualTo(expectedRows); return workbook.getSheetAt(0).getRow(1).getCell(1).getStringCellValue(); } }