Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions FUNDING_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`를 지원합니다.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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<FundingOrder> 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<Long> orderIds = result.getContent().stream().map(FundingOrder::getId).toList();
Map<Long, TumblbugOrderDetailResponse> tumblbugDetails = tumblbugImportRecordService.getDetails(orderIds);
Expand Down Expand Up @@ -458,19 +458,34 @@ private static FundingOrderSummaryResponse toSummary(FundingOrder order, List<Cl
ticket.getActivatedAt(), ticket.getExpiresAt(),
registrant == null ? null : registrant.getId(),
registrant == null ? null : registrant.getNickname(), ticket.getCodeSuffix(),
deliveries.get(ticket.getId()) == DeliveryStatus.SUCCESS
? com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT
: com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING));
toDeliveryView(deliveries.get(ticket.getId()))));
}
boolean issuable = order.isIssuable() && ticketViews.size() < order.getQuantity();
return new FundingOrderSummaryResponse(order.getId(), order.getPlatform(), order.getCampaignId(),
order.getOrderId(), order.getRewardTier(), order.getQuantity(), order.getSupporterName(),
order.getSupporterEmail(), order.getSupporterPhone(), order.getPaymentStatus(), order.getPaidAt(),
issuable, ticketViews.isEmpty() ? null : ticketViews.getFirst(), List.copyOf(ticketViews), tumblbug,
!ticketViews.isEmpty() && ticketViews.stream().allMatch(t ->
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<FundingOrderSummaryResponse.Ticket> 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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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))))
Expand Down Expand Up @@ -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))))
Expand All @@ -231,7 +229,8 @@ Page<FundingOrder> 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);
}
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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,[email protected],,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<String> 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();
Expand All @@ -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<Long, String> 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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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();
}
}
Expand Down
Loading