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
29 changes: 18 additions & 11 deletions FUNDING_API.md
Original file line number Diff line number Diff line change
Expand Up @@ -8,8 +8,11 @@
| --- | --- |
| CSV 주문 등록·갱신 | `POST /admin/funding/imports` |
| 주문·이용권 목록 및 텀블벅 원본 정보 | `GET /admin/funding/orders` |
| 캠페인별 후원자 목록 | `GET /admin/funding/orders?campaignId=...` |
| 텀블벅 캠페인 대시보드 요약 | `GET /admin/funding/orders/tumblbug/dashboard?campaignId=...` |
| 펀딩 현황 | `GET /admin/funding/summary` |
| 코드 발급 | `POST /admin/funding/tickets/issue` |
| 관리자 코드 조회·복사 | `GET /admin/funding/tickets/{ticketId}/code` |
| 전달 기록 | `POST /admin/funding/tickets/{ticketId}/deliveries` |
| 미등록 코드 재발급 | `POST /admin/funding/tickets/{ticketId}/reissue` |
| 주문 환불·이용권 취소 | `POST /admin/funding/refunds` |
Expand All @@ -18,14 +21,16 @@
| 내 이용권 | `GET /class-tickets/me` |
| XLSX 시트·열·샘플 검사 | `POST /admin/funding-orders/tumblbug/inspect` |
| 선택 시트 검토 | `POST /admin/funding-orders/tumblbug/preview` |
| Start·Run 공통 엑셀 등록 및 발급 | `POST /admin/funding-orders/tumblbug/import` |
| Start·Run 공통 구매자 수동 등록 | `POST /admin/funding-orders/tumblbug/manual` |
| 주문 저장·발급·디지털 선물 XLSX 생성 | `POST /admin/funding-orders/tumblbug/digital-gifts` |
| 텀블벅 전달 결과 기록 | `PATCH /admin/funding-orders/tumblbug/digital-gifts/delivery` |
| 이용권·등록자·등록일 검색 | `GET /admin/tickets` |

## 텀블벅 XLSX

관리자 API는 `ROLE_ADMIN`이 필요합니다. 검사는 multipart `file`, 미리보기와 발급은 `file`과 JSON `options` 파트를 사용합니다.
파일은 XLSX 10MB 이하, 최대 5,000개 데이터 행입니다.
파일은 XLSX 10MB 이하, 최대 5,000개 데이터 행입니다. 선물 수량은 1~7이며 코드도 수량만큼 발급합니다.

```json
{
Expand All @@ -34,19 +39,20 @@
"sheetName": "15인 이용권",
"rewardLineId": "class-ticket-15",
"capacity": 15,
"sourceVersion": "export-20261021"
"sourceVersion": "export-20261021",
"plan": "START"
}
```

`campaignId`는 50자 이하이며 `rewardLineId`를 생략하면 시트명을 사용합니다.
`campaignId`는 50자 이하이며 `rewardLineId`를 생략하면 시트명을 사용합니다. `plan`은 `START` 또는 `RUN`이며 생략 시 기존 API와의 호환을 위해 `RUN`으로 처리합니다. 캠페인을 처음 등록할 때 선택한 요금제는 고정됩니다.
필수 열은 후원번호, 이름, 선물, 추가 후원금, 총 후원 금액, 후원 일시, 알림 신청, 결제 상태, 선물 전달입니다.
미리보기는 저장하지 않습니다. 중복 또는 잘못된 행이 있으면 발급 요청 전체를 거부합니다.

최신 정책에 따라 **수량 1·결제 확정·결제일 존재**인 주문만 발급합니다.
다른 주문은 보관하고 발급을 보류합니다. `codeEligibleRows`에도 이 조건을 적용합니다.
**결제 완료·후원일시 존재**인 주문은 구매 수량만큼 코드를 발급합니다.
결제 예약 상태는 저장만 하고 코드를 발급하지 않습니다. `codeEligibleRows`는 결제 완료된 행 수입니다.
등록기한은 결제일 + 1년, 이용기간은 등록일 + 6개월입니다.
재발급은 기존 이용권을 무효화하고 같은 등록기한으로 새 행을 생성합니다.
코드 형식은 `XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX`이며 SHA-256 해시만 저장합니다.
코드 형식은 `XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX`입니다. SHA-256 해시로 코드를 검증하고 AES-GCM 암호문으로 관리자 코드 보기를 지원합니다.

발급 주문은 최신 `FundingOrder`에 저장하고 `AdminFundingService`로 `ClassTicket`을 발급합니다.
후원번호·리워드 키·금액·알림·원본 전달 상태·원본 행 JSON은 `TumblbugImportRecord`에 보관합니다.
Expand All @@ -56,12 +62,13 @@
전달 결과 요청 필드는 `campaignId`, `rewardLineId`, `supporterNumbers`, `status`(`SENT`/`FAILED`), `failedReason`입니다.
실패에는 사유가 필요합니다. 공통 이력에는 `TUMBLBUG_DIGITAL_GIFT` 채널 및 `SUCCESS`/`FAILED` 상태로 기록합니다.
이미 등록·취소되었거나 결과를 기록한 코드는 건너뛰고, 재발급 코드는 새 이용권 ID로 구분합니다.
코드 원문은 XLSX 생성 응답에서만 반환하며 실제 텀블벅 업로드는 관리자가 수행합니다.
Start는 공통 `import` 또는 `manual` 경로로 등록하고 1:1 메시지로 직접 전달합니다. Run은 같은 경로에 더해 `digital-gifts` XLSX 경로를 쓸 수 있습니다. 실제 텀블벅 전달은 관리자가 수행합니다.

## 검색과 클래스 연동

주문 검색은 기존 `/admin/funding/orders` 한 곳에서 `keyword`, `capacity`, `paymentStatus`, `deliveryStatus`, `ticketStatus`, `codeStatus`, `page`, `size`를 지원합니다.
기존 주문 응답 필드를 유지하고 `tumblbug`에 원본 금액·알림·후원번호 등을, `deliveryStatus`에 최근 전달 결과를 추가합니다.
주문 검색은 기존 `/admin/funding/orders` 한 곳에서 `campaignId`, `keyword`, `capacity`, `paymentStatus`, `deliveryStatus`, `ticketStatus`, `codeStatus`, `page`, `size`를 지원합니다. 캠페인 대시보드는 후원 주문 수·결제 완료·전달 완료·미전달 수를 캠페인 단위로 집계합니다.
기존 주문 응답 필드를 유지하고 `tumblbug`에 요금제·후원일시·상품·금액·후원번호 등을, `tickets`에 발급된 티켓 목록과 전달 상태를 추가합니다. 대시보드는 관리자 전용 `GET /admin/funding/tickets/{ticketId}/code`를 코드 전체 보기·복사 시 호출합니다. 코드 조회는 감사 로그에 기록되고 응답은 캐시되지 않습니다.
코드 암호화를 위해 `TICKET_CODE_ENCRYPTION_KEY`에 Base64 인코딩한 32바이트 키를 설정해야 합니다. 기존 코드 행은 해시만 저장되어 있어 코드 원문을 복구할 수 없습니다. 미등록 기존 코드는 재발급 후 새 코드 조회가 가능합니다.
이용권 검색은 `keyword`, `capacity`, `status`, `registeredFrom`, `registeredTo`, `page`, `size`를 지원합니다.
이용권 검색 응답의 연락처·코드를 마스킹하며 전체 코드 검색은 해시로 비교합니다.
끝 4자리 검색은 통합 이후 발급·재발급한 코드에서 지원합니다. 기존 해시로 접미사를 복원할 수는 없습니다.
Expand All @@ -72,7 +79,7 @@

## 추가 스키마

`V20261021_02__tumblbug_import_extensions.sql`을 기존 클래스 스키마 이후 적용합니다.
`V20261021_02__tumblbug_import_extensions.sql`과 `V20261025_01__funding_plan_and_ticket_code_vault.sql`을 기존 클래스 스키마 이후 적용합니다.
`funding_campaigns`, `tumblbug_import_records`, `funding_admin_audit_logs`, 코드 접미사,
전달 채널 길이 확장을 포함합니다. 별도 Funding 주문·이용권·코드·전달 테이블은 생성하지 않습니다.
전달 채널 길이 확장, 요금제, 암호화 코드 보관, 주문 수량당 복수 코드 제약 변경을 포함합니다. 별도 Funding 주문·이용권·코드·전달 테이블은 생성하지 않습니다.
이 저장소에는 Flyway 의존성이 없으므로 기존 DB 반영 절차에서 SQL을 적용해야 합니다.
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@
import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderPageResponse;
import com.swyp.picke.domain.admin.dto.funding.response.FundingRefundResponse;
import com.swyp.picke.domain.admin.dto.funding.response.FundingSummaryResponse;
import com.swyp.picke.domain.admin.dto.funding.response.AdminTicketCodeResponse;
import com.swyp.picke.domain.admin.dto.funding.response.TicketDeliveryResponse;
import com.swyp.picke.domain.admin.dto.funding.response.TicketIssueResponse;
import com.swyp.picke.domain.admin.dto.funding.response.TumblbugCampaignDashboardResponse;
import com.swyp.picke.domain.admin.service.AdminFundingService;
import com.swyp.picke.domain.funding.enums.DeliveryStatus;
import com.swyp.picke.domain.ticket.enums.PaymentStatus;
Expand All @@ -25,6 +27,7 @@
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
Expand All @@ -36,7 +39,7 @@

/**
* 어드민 펀딩 명단·이용권 운영. 모든 API 는 ROLE_ADMIN.
* 이용권 코드 원문은 발급·재발급 응답에서만 1회 노출되며 조회 API 에는 절대 포함되지 않는다.
* 이용권 코드 원문은 발급 응답 또는 별도 관리자 코드 조회 API 로만 노출됩니다.
*/
@Tag(name = "관리자 펀딩·이용권 API", description = "펀딩 명단 CSV 가져오기, 이용권 코드 발급·전달 기록·재발급·환불, 주문/이용권 현황")
@RestController
Expand All @@ -58,14 +61,25 @@ public ApiResponse<FundingImportResponse> importCsv(@RequestParam("file") Multip
}

@Operation(summary = "이용권 코드 발급",
description = "fundingOrderIds 를 비우면 발급 가능한 전체 주문(결제 확정·수량 1·미발급)에 발급합니다. "
+ "이미 살아 있는 이용권이 있는 주문은 건너뛰므로(ALREADY_ISSUED) 재실행해도 중복 발급되지 않습니다. "
+ "응답의 code 는 이 응답에서만 볼 수 있습니다 (서버는 해시만 저장).")
description = "fundingOrderIds 를 비우면 발급 가능한 전체 주문(결제 완료·미발급 수량 있음)에 발급합니다. "
+ "주문 수량만큼 발급하며 이미 수량을 모두 발급한 주문은 건너뛰므로 재실행해도 중복 발급되지 않습니다. "
+ "응답의 code 를 반환하고 서버는 해시와 암호문을 저장합니다.")
@PostMapping("/tickets/issue")
public ApiResponse<TicketIssueResponse> issueTickets(@RequestBody(required = false) TicketIssueRequest request) {
return ApiResponse.onSuccess(adminFundingService.issueTickets(request));
}

@Operation(summary = "관리자 이용권 코드 조회", description = "대시보드에서 코드 전체 보기·복사할 때 호출합니다. ROLE_ADMIN 전용이며 조회 이력을 남깁니다.")
@GetMapping("/tickets/{ticketId}/code")
public org.springframework.http.ResponseEntity<ApiResponse<AdminTicketCodeResponse>> getTicketCode(
@PathVariable Long ticketId,
@AuthenticationPrincipal Long adminUserId
) {
return org.springframework.http.ResponseEntity.ok()
.cacheControl(org.springframework.http.CacheControl.noStore())
.body(ApiResponse.onSuccess(adminFundingService.getTicketCode(ticketId, adminUserId)));
}

@Operation(summary = "이용권 전달 기록",
description = "이메일/SMS/수동으로 코드를 전달한 결과를 기록합니다. SUCCESS 면 codeStatus 가 DELIVERED 로 바뀝니다. "
+ "이미 등록(CLAIMED)됐거나 무효화(REVOKED)된 이용권이면 409 TICKET_409_STATE.")
Expand Down Expand Up @@ -98,6 +112,7 @@ public ApiResponse<FundingRefundResponse> refund(@Valid @RequestBody FundingRefu
description = "주문과 살아 있는 이용권 요약 및 텀블벅 원본 정보(코드 원문·해시 제외). 주문번호·후원자명·이메일·후원번호 검색과 정원·전달 결과 필터를 지원합니다.")
@GetMapping("/orders")
public ApiResponse<FundingOrderPageResponse> getOrders(
@RequestParam(required = false) String campaignId,
@RequestParam(required = false) PaymentStatus paymentStatus,
@RequestParam(required = false) TicketStatus ticketStatus,
@RequestParam(required = false) TicketCodeStatus codeStatus,
Expand All @@ -108,7 +123,16 @@ public ApiResponse<FundingOrderPageResponse> getOrders(
@RequestParam(defaultValue = "20") int size
) {
return ApiResponse.onSuccess(
adminFundingService.getOrders(paymentStatus, ticketStatus, codeStatus, keyword, capacity, deliveryStatus, page, size));
adminFundingService.getOrders(campaignId, paymentStatus, ticketStatus, codeStatus, keyword, capacity, deliveryStatus, page, size));
}

@Operation(summary = "텀블벅 캠페인 대시보드 통계",
description = "캠페인의 후원 건수, 결제 완료, 전달 완료, 미전달 수를 조회합니다. 전달 완료는 최근 전달 기록이 성공인 주문 기준입니다.")
@GetMapping("/orders/tumblbug/dashboard")
public ApiResponse<TumblbugCampaignDashboardResponse> getTumblbugCampaignDashboard(
@RequestParam String campaignId
) {
return ApiResponse.onSuccess(adminFundingService.getTumblbugCampaignDashboard(campaignId));
}

@Operation(summary = "등록기한 임박 알림 대상",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,6 @@
import java.util.List;

public record TicketIssueRequest(
@Schema(description = "발급할 펀딩 주문 ID 목록. 비우면 발급 가능한 전체 주문(결제 확정·수량 1·미발급)을 대상으로 한다", nullable = true)
@Schema(description = "발급할 펀딩 주문 ID 목록. 비우면 결제 완료된 전체 주문의 미발급 수량을 대상으로 한다", nullable = true)
List<Long> fundingOrderIds
) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package com.swyp.picke.domain.admin.dto.funding.response;

public record AdminTicketCodeResponse(Long ticketId, String code) {
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ public record FundingImportResponse(
@Schema(description = "값이 바뀌어 갱신된 주문 수") int updated,
@Schema(description = "값 변화가 없던 주문 수") int unchanged,
@Schema(description = "반영하지 못한 행 수 (= errors.size())") int errorCount,
@Schema(description = "업로드 후 기준, 지금 발급 가능한 주문 수 (결제 확정·수량 1·미발급)") long issuableCount,
@Schema(description = "업로드 후 기준, 지금 발급 가능한 주문 수 (결제 완료·남은 수량 있음)") long issuableCount,
@Schema(description = "보류 주문 수 (결제 확정이지만 수량이 1이 아니거나 결제일 없음, 결제 대기·예약)") long heldCount,
List<RowError> errors
) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,12 @@
import com.swyp.picke.domain.ticket.enums.TicketCodeStatus;
import com.swyp.picke.domain.ticket.enums.TicketStatus;
import com.swyp.picke.domain.funding.dto.response.TumblbugOrderDetailResponse;
import com.swyp.picke.domain.funding.enums.DeliveryStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import java.time.LocalDateTime;
import java.util.List;

/** 주문 + 살아 있는 이용권 요약. 코드 원문·해시는 절대 포함하지 않는다. */
/** 주문 + 살아 있는 이용권 요약. 코드 원문은 별도 관리자 조회 API로만 반환합니다. */
public record FundingOrderSummaryResponse(
Long fundingOrderId,
FundingPlatform platform,
Expand All @@ -22,11 +24,12 @@ public record FundingOrderSummaryResponse(
@Schema(nullable = true) String supporterPhone,
PaymentStatus paymentStatus,
@Schema(nullable = true) LocalDateTime paidAt,
@Schema(description = "발급 가능 여부 (결제 확정·수량 1·결제일 있음·미발급)") boolean issuable,
@Schema(description = "결제 완료·결제일 존재·미발급 수량이 남아 있는지") boolean issuable,
@Schema(description = "살아 있는(REVOKED 아닌) 이용권. 없으면 null", nullable = true) Ticket ticket,
@Schema(description = "주문 수량만큼 발급된 살아 있는 이용권 목록") List<Ticket> tickets,
@Schema(description = "텀블벅 XLSX 원본 정보. CSV 주문은 null", nullable = true) TumblbugOrderDetailResponse tumblbug,
@Schema(description = "최근 전달 결과. 이력이 없으면 PENDING")
com.swyp.picke.domain.funding.enums.DeliveryStatus deliveryStatus
DeliveryStatus deliveryStatus
) {
public record Ticket(
Long ticketId,
Expand All @@ -38,6 +41,8 @@ public record Ticket(
@Schema(nullable = true) LocalDateTime activatedAt,
@Schema(nullable = true) LocalDateTime expiresAt,
@Schema(nullable = true) Long registeredUserId,
@Schema(nullable = true) String registeredNickname
@Schema(nullable = true) String registeredNickname,
@Schema(nullable = true, description = "관리자 조회용 화면에서 코드를 찾을 때 보조하는 끝 4자리") String codeSuffix,
DeliveryStatus deliveryStatus
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,7 @@
public record FundingSummaryResponse(
@Schema(description = "전체 주문 수") long totalOrders,
@Schema(description = "결제 확정 주문 수") long paidConfirmed,
@Schema(description = "발급 가능 (결제 확정·수량 1·미발급)") long issuable,
@Schema(description = "발급 가능 (결제 완료·남은 수량 있음)") long issuable,
@Schema(description = "보류 (결제 확정이지만 수량이 1이 아니거나 결제일 없음, 결제 대기·예약)") long held,
@Schema(description = "발급된 이용권 수 (살아 있는 행)") long issued,
@Schema(description = "미전달 (codeStatus=ISSUED)") long undelivered,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ public record IssuedTicket(
@Schema(nullable = true) String supporterPhone,
int capacity,
@Schema(description = "등록 기한 (결제 확정일 + 1년)") LocalDateTime registerBy,
@Schema(description = "이용권 코드 (XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX). 1회만 노출") String code
@Schema(description = "관리자에게 표시되는 이용권 코드 (XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX)") String code
) {}

public record Skipped(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package com.swyp.picke.domain.admin.dto.funding.response;

import io.swagger.v3.oas.annotations.media.Schema;

@Schema(description = "텀블벅 캠페인 관리자 대시보드 통계")
public record TumblbugCampaignDashboardResponse(
String campaignId,
@Schema(description = "등록된 후원 주문 수") long supporterCount,
@Schema(description = "결제 완료 주문 수") long paidConfirmed,
@Schema(description = "최근 전달 결과가 성공인 주문 수") long delivered,
@Schema(description = "최근 전달 결과가 성공이 아닌 주문 수") long undelivered
) {
}
Loading
Loading