diff --git a/FUNDING_API.md b/FUNDING_API.md index 55149541..e003e53b 100644 --- a/FUNDING_API.md +++ b/FUNDING_API.md @@ -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` | @@ -18,6 +21,8 @@ | 내 이용권 | `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` | @@ -25,7 +30,7 @@ ## 텀블벅 XLSX 관리자 API는 `ROLE_ADMIN`이 필요합니다. 검사는 multipart `file`, 미리보기와 발급은 `file`과 JSON `options` 파트를 사용합니다. -파일은 XLSX 10MB 이하, 최대 5,000개 데이터 행입니다. +파일은 XLSX 10MB 이하, 최대 5,000개 데이터 행입니다. 선물 수량은 1~7이며 코드도 수량만큼 발급합니다. ```json { @@ -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`에 보관합니다. @@ -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자리 검색은 통합 이후 발급·재발급한 코드에서 지원합니다. 기존 해시로 접미사를 복원할 수는 없습니다. @@ -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을 적용해야 합니다. diff --git a/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java b/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java index 98aeda87..076302c3 100644 --- a/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java +++ b/src/main/java/com/swyp/picke/domain/admin/controller/AdminFundingController.java @@ -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; @@ -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; @@ -36,7 +39,7 @@ /** * 어드민 펀딩 명단·이용권 운영. 모든 API 는 ROLE_ADMIN. - * 이용권 코드 원문은 발급·재발급 응답에서만 1회 노출되며 조회 API 에는 절대 포함되지 않는다. + * 이용권 코드 원문은 발급 응답 또는 별도 관리자 코드 조회 API 로만 노출됩니다. */ @Tag(name = "관리자 펀딩·이용권 API", description = "펀딩 명단 CSV 가져오기, 이용권 코드 발급·전달 기록·재발급·환불, 주문/이용권 현황") @RestController @@ -58,14 +61,25 @@ public ApiResponse importCsv(@RequestParam("file") Multip } @Operation(summary = "이용권 코드 발급", - description = "fundingOrderIds 를 비우면 발급 가능한 전체 주문(결제 확정·수량 1·미발급)에 발급합니다. " - + "이미 살아 있는 이용권이 있는 주문은 건너뛰므로(ALREADY_ISSUED) 재실행해도 중복 발급되지 않습니다. " - + "응답의 code 는 이 응답에서만 볼 수 있습니다 (서버는 해시만 저장).") + description = "fundingOrderIds 를 비우면 발급 가능한 전체 주문(결제 완료·미발급 수량 있음)에 발급합니다. " + + "주문 수량만큼 발급하며 이미 수량을 모두 발급한 주문은 건너뛰므로 재실행해도 중복 발급되지 않습니다. " + + "응답의 code 를 반환하고 서버는 해시와 암호문을 저장합니다.") @PostMapping("/tickets/issue") public ApiResponse 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> 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.") @@ -98,6 +112,7 @@ public ApiResponse refund(@Valid @RequestBody FundingRefu description = "주문과 살아 있는 이용권 요약 및 텀블벅 원본 정보(코드 원문·해시 제외). 주문번호·후원자명·이메일·후원번호 검색과 정원·전달 결과 필터를 지원합니다.") @GetMapping("/orders") public ApiResponse getOrders( + @RequestParam(required = false) String campaignId, @RequestParam(required = false) PaymentStatus paymentStatus, @RequestParam(required = false) TicketStatus ticketStatus, @RequestParam(required = false) TicketCodeStatus codeStatus, @@ -108,7 +123,16 @@ public ApiResponse 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 getTumblbugCampaignDashboard( + @RequestParam String campaignId + ) { + return ApiResponse.onSuccess(adminFundingService.getTumblbugCampaignDashboard(campaignId)); } @Operation(summary = "등록기한 임박 알림 대상", diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java index ee1936fb..eb55c2b5 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/TicketIssueRequest.java @@ -4,6 +4,6 @@ import java.util.List; public record TicketIssueRequest( - @Schema(description = "발급할 펀딩 주문 ID 목록. 비우면 발급 가능한 전체 주문(결제 확정·수량 1·미발급)을 대상으로 한다", nullable = true) + @Schema(description = "발급할 펀딩 주문 ID 목록. 비우면 결제 완료된 전체 주문의 미발급 수량을 대상으로 한다", nullable = true) List fundingOrderIds ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java new file mode 100644 index 00000000..f55a0b15 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/AdminTicketCodeResponse.java @@ -0,0 +1,4 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +public record AdminTicketCodeResponse(Long ticketId, String code) { +} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java index a793bc07..344564e1 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingImportResponse.java @@ -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 errors ) { diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java index 250ce887..c199e509 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingOrderSummaryResponse.java @@ -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, @@ -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 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, @@ -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 ) {} } diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java index d766fa7e..441966fe 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/FundingSummaryResponse.java @@ -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, diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java index ddfb117d..135909fe 100644 --- a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TicketIssueResponse.java @@ -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( diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java new file mode 100644 index 00000000..af1678f7 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/TumblbugCampaignDashboardResponse.java @@ -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 +) { +} 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 fbf67658..d0a83f05 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 @@ -11,8 +11,10 @@ import com.swyp.picke.domain.admin.dto.funding.response.FundingSummaryResponse; import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderResponse; import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderTargetResponse; +import com.swyp.picke.domain.admin.dto.funding.response.TumblbugCampaignDashboardResponse; 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.AdminTicketCodeResponse; import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.ticket.entity.FundingOrder; import com.swyp.picke.domain.ticket.entity.TicketDeliveryAttempt; @@ -20,8 +22,11 @@ import com.swyp.picke.domain.ticket.enums.DeliveryStatus; import com.swyp.picke.domain.ticket.enums.PaymentStatus; import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import com.swyp.picke.domain.ticket.enums.FundingPlatform; import com.swyp.picke.domain.ticket.enums.RewardTier; import com.swyp.picke.domain.funding.dto.response.TumblbugOrderDetailResponse; +import com.swyp.picke.domain.funding.entity.AdminAuditLog; +import com.swyp.picke.domain.funding.repository.AdminAuditLogRepository; import com.swyp.picke.domain.funding.service.TumblbugImportRecordService; import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; import com.swyp.picke.domain.ticket.enums.TicketStatus; @@ -32,6 +37,7 @@ import com.swyp.picke.domain.ticket.util.FundingCsvParser; import com.swyp.picke.domain.ticket.util.TicketCodeGenerator; import com.swyp.picke.domain.ticket.util.TicketCodeHasher; +import com.swyp.picke.domain.ticket.service.TicketCodeVault; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.global.common.exception.CustomException; import com.swyp.picke.global.common.exception.ErrorCode; @@ -61,7 +67,7 @@ * 어드민 펀딩 명단·이용권 운영 (PRD §30~§33). *
    *
  • 명단 CSV 업로드: order_id 기준 upsert (CF01). 환불·취소는 종착 상태라 되살리지 않는다 (CF07).
  • - *
  • 발급: 결제 확정·수량 1·미발급 주문에 코드 생성. 코드 원문은 응답에서 1회만 노출, 서버는 SHA-256 만 저장.
  • + *
  • 발급: 결제 확정·미발급 수량만큼 코드 생성. SHA-256 해시와 AES-GCM 암호문을 저장합니다.
  • *
  • 재발급: 기존 행 REVOKED + 같은 등록기한의 새 행 (CF06). 환불: 주문 REFUNDED + 살아 있는 이용권 REVOKED.
  • *
* 코드 원문은 어떤 경로로도 로그에 남기지 않는다. @@ -81,6 +87,8 @@ public class AdminFundingService { private final TicketDeliveryAttemptRepository ticketDeliveryAttemptRepository; private final TicketRegisterReminderRepository ticketRegisterReminderRepository; private final TumblbugImportRecordService tumblbugImportRecordService; + private final TicketCodeVault ticketCodeVault; + private final AdminAuditLogRepository adminAuditLogRepository; // ───────────── 1. 명단 가져오기 ───────────── @@ -104,10 +112,10 @@ public FundingImportResponse importCsv(MultipartFile file) { List orderIds = parsed.rows().stream().map(FundingCsvParser.Row::orderId).toList(); Map existingByOrderId = fundingOrderRepository.findByOrderIdIn(orderIds).stream() .collect(Collectors.toMap(FundingOrder::getOrderId, Function.identity())); - Set orderIdsWithLiveTicket = classTicketRepository + Map liveTicketCountByOrder = classTicketRepository .findByFundingOrderIdInAndStatusNot( existingByOrderId.values().stream().map(FundingOrder::getId).toList(), TicketStatus.REVOKED) - .stream().map(t -> t.getFundingOrder().getId()).collect(Collectors.toSet()); + .stream().collect(Collectors.groupingBy(t -> t.getFundingOrder().getId(), Collectors.counting())); int created = 0; int updated = 0; @@ -136,11 +144,17 @@ public FundingImportResponse importCsv(MultipartFile file) { "이미 " + existing.getPaymentStatus() + " 처리된 주문은 " + row.paymentStatus() + " 로 되돌릴 수 없습니다.")); continue; } - if (orderIdsWithLiveTicket.contains(existing.getId()) && existing.getRewardTier() != row.rewardTier()) { + long liveTicketCount = liveTicketCountByOrder.getOrDefault(existing.getId(), 0L); + if (liveTicketCount > 0 && existing.getRewardTier() != row.rewardTier()) { errors.add(new FundingImportResponse.RowError(row.line(), row.orderId(), "이용권이 이미 발급된 주문의 reward_tier 는 변경할 수 없습니다. 환불 처리 후 다시 올려 주세요.")); continue; } + if (liveTicketCount > row.quantity()) { + errors.add(new FundingImportResponse.RowError(row.line(), row.orderId(), + "이미 발급된 코드 수보다 quantity 를 작게 변경할 수 없습니다.")); + continue; + } boolean changed = existing.applyImport(row.platform(), row.campaignId(), row.rewardTier(), row.quantity(), row.supporterName(), row.supporterEmail(), row.supporterPhone(), row.paymentStatus(), row.paidAt(), fileHash); @@ -167,35 +181,48 @@ public TicketIssueResponse issueTickets(TicketIssueRequest request) { List issued = new ArrayList<>(); List skipped = new ArrayList<>(); - List targets; + List targetIds = new ArrayList<>(); if (request == null || request.fundingOrderIds() == null || request.fundingOrderIds().isEmpty()) { - targets = fundingOrderRepository.findIssuable(); + targetIds.addAll(fundingOrderRepository.findIssuable().stream().map(FundingOrder::getId).toList()); } else { - List ids = request.fundingOrderIds().stream().distinct().toList(); + List ids = request.fundingOrderIds().stream().distinct().sorted().toList(); Map found = fundingOrderRepository.findAllById(ids).stream() .collect(Collectors.toMap(FundingOrder::getId, Function.identity())); - Set alreadyIssued = classTicketRepository.findByFundingOrderIdInAndStatusNot(ids, TicketStatus.REVOKED) - .stream().map(t -> t.getFundingOrder().getId()).collect(Collectors.toSet()); - targets = new ArrayList<>(); for (Long id : ids) { FundingOrder order = found.get(id); if (order == null) { skipped.add(new TicketIssueResponse.Skipped(id, null, "NOT_FOUND")); - } else if (alreadyIssued.contains(id)) { - skipped.add(new TicketIssueResponse.Skipped(id, order.getOrderId(), "ALREADY_ISSUED")); } else if (!order.isIssuable() || order.getPaidAt() == null) { skipped.add(new TicketIssueResponse.Skipped(id, order.getOrderId(), "NOT_ISSUABLE")); } else { - targets.add(order); + targetIds.add(id); } } } - for (FundingOrder order : targets) { - String rawCode = generateUniqueCode(); - ClassTicket ticket = classTicketRepository.save(ClassTicket.issue(order, TicketCodeHasher.hash(rawCode))); - ticket.recordCodeSuffix(rawCode); - issued.add(toIssued(ticket, rawCode)); + if (targetIds.isEmpty()) return new TicketIssueResponse(issued, skipped); + targetIds.sort(Long::compareTo); + List lockedTargets = fundingOrderRepository.findAllByIdForUpdate(targetIds); + Map liveCountByOrder = classTicketRepository.findByFundingOrderIdInAndStatusNot( + lockedTargets.stream().map(FundingOrder::getId).toList(), TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(ticket -> ticket.getFundingOrder().getId(), Collectors.counting())); + for (FundingOrder order : lockedTargets) { + if (!order.isIssuable()) { + skipped.add(new TicketIssueResponse.Skipped(order.getId(), order.getOrderId(), "NOT_ISSUABLE")); + continue; + } + long liveCount = liveCountByOrder.getOrDefault(order.getId(), 0L); + if (liveCount >= order.getQuantity()) { + skipped.add(new TicketIssueResponse.Skipped(order.getId(), order.getOrderId(), "ALREADY_ISSUED")); + continue; + } + for (long i = liveCount; i < order.getQuantity(); i++) { + String rawCode = generateUniqueCode(); + ClassTicket ticket = classTicketRepository.save(ClassTicket.issue(order, TicketCodeHasher.hash(rawCode), + ticketCodeVault.encrypt(TicketCodeGenerator.format(rawCode)))); + ticket.recordCodeSuffix(rawCode); + issued.add(toIssued(ticket, rawCode)); + } } return new TicketIssueResponse(issued, skipped); } @@ -262,7 +289,8 @@ public TicketIssueResponse.IssuedTicket reissue(Long ticketId) { String rawCode = generateUniqueCode(); ClassTicket reissued = classTicketRepository.save( - ClassTicket.reissueFrom(previous, TicketCodeHasher.hash(rawCode))); + ClassTicket.reissueFrom(previous, TicketCodeHasher.hash(rawCode), + ticketCodeVault.encrypt(TicketCodeGenerator.format(rawCode)))); reissued.recordCodeSuffix(rawCode); return toIssued(reissued, rawCode); } @@ -276,6 +304,8 @@ public FundingRefundResponse refund(FundingRefundRequest request) { if (orders.size() != ids.size()) { throw new CustomException(ErrorCode.FUNDING_ORDER_NOT_FOUND); } + Map> ticketsByOrder = classTicketRepository.findByFundingOrderIdInAndStatusNot(ids, TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(ticket -> ticket.getFundingOrder().getId())); LocalDateTime now = LocalDateTime.now(KST); int refunded = 0; int revokedTickets = 0; @@ -287,9 +317,7 @@ public FundingRefundResponse refund(FundingRefundRequest request) { } order.updatePayment(PaymentStatus.REFUNDED, order.getPaidAt()); refunded++; - ClassTicket live = classTicketRepository - .findByFundingOrderIdAndStatusNot(order.getId(), TicketStatus.REVOKED).orElse(null); - if (live != null) { + for (ClassTicket live : ticketsByOrder.getOrDefault(order.getId(), List.of())) { live.revoke(now); revokedTickets++; } @@ -348,13 +376,14 @@ private long countRegisterReminderTargets(RegisterReminderStage stage, LocalDate // ───────────── 6. 조회 ───────────── - public FundingOrderPageResponse getOrders(PaymentStatus paymentStatus, TicketStatus ticketStatus, + public FundingOrderPageResponse getOrders(String campaignId, PaymentStatus paymentStatus, TicketStatus ticketStatus, TicketCodeStatus codeStatus, String keyword, Integer capacity, com.swyp.picke.domain.funding.enums.DeliveryStatus deliveryStatus, int page, int size) { int pageNumber = Math.max(page, 0); int pageSize = size <= 0 ? DEFAULT_PAGE_SIZE : Math.min(size, MAX_PAGE_SIZE); String normalizedKeyword = (keyword == null || keyword.isBlank()) ? null : keyword.trim(); + String normalizedCampaignId = (campaignId == null || campaignId.isBlank()) ? null : campaignId.trim(); RewardTier rewardTier = null; if (capacity != null) { try { @@ -368,21 +397,22 @@ public FundingOrderPageResponse getOrders(PaymentStatus paymentStatus, TicketSta : deliveryStatus == com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT ? DeliveryStatus.SUCCESS : DeliveryStatus.FAILED; - Page result = fundingOrderRepository.searchWithLiveTicket( - paymentStatus, ticketStatus, codeStatus, normalizedKeyword, rewardTier, recordedStatus, pending, + Page result = fundingOrderRepository.searchOrders( + paymentStatus, ticketStatus, codeStatus, normalizedCampaignId, normalizedKeyword, rewardTier, recordedStatus, pending, PageRequest.of(pageNumber, pageSize)); - List orderIds = result.getContent().stream().map(row -> ((FundingOrder) row[0]).getId()).toList(); + List orderIds = result.getContent().stream().map(FundingOrder::getId).toList(); Map tumblbugDetails = tumblbugImportRecordService.getDetails(orderIds); - List ticketIds = result.getContent().stream().map(row -> (ClassTicket) row[1]) - .filter(java.util.Objects::nonNull).map(ClassTicket::getId).toList(); + Map> liveTicketsByOrder = orderIds.isEmpty() ? Map.of() + : classTicketRepository.findByFundingOrderIdInAndStatusNotOrderByIdAsc(orderIds, TicketStatus.REVOKED) + .stream().collect(Collectors.groupingBy(t -> t.getFundingOrder().getId())); + List ticketIds = liveTicketsByOrder.values().stream().flatMap(List::stream).map(ClassTicket::getId).toList(); Map deliveries = ticketIds.isEmpty() ? Map.of() : ticketDeliveryAttemptRepository.findByTicketIdInOrderByIdDesc(ticketIds).stream() .collect(Collectors.toMap(a -> a.getTicket().getId(), TicketDeliveryAttempt::getStatus, (latest, ignored) -> latest)); List items = result.getContent().stream() - .map(row -> toSummary((FundingOrder) row[0], (ClassTicket) row[1], - tumblbugDetails.get(((FundingOrder) row[0]).getId()), - row[1] == null ? null : deliveries.get(((ClassTicket) row[1]).getId()))) + .map(order -> toSummary(order, liveTicketsByOrder.getOrDefault(order.getId(), List.of()), + tumblbugDetails.get(order.getId()), deliveries)) .toList(); return new FundingOrderPageResponse(items, pageNumber, pageSize, result.getTotalElements(), result.hasNext()); } @@ -404,26 +434,54 @@ public FundingSummaryResponse getSummary() { countRegisterReminderTargets(RegisterReminderStage.D7, now)); } - private static FundingOrderSummaryResponse toSummary(FundingOrder order, ClassTicket ticket, - TumblbugOrderDetailResponse tumblbug, DeliveryStatus deliveryStatus) { - FundingOrderSummaryResponse.Ticket ticketView = null; - if (ticket != null) { + public TumblbugCampaignDashboardResponse getTumblbugCampaignDashboard(String campaignId) { + String normalizedCampaignId = campaignId == null ? "" : campaignId.trim(); + if (normalizedCampaignId.isBlank() || normalizedCampaignId.length() > 50) { + throw new CustomException(ErrorCode.COMMON_INVALID_PARAMETER); + } + long total = fundingOrderRepository.countByPlatformAndCampaignId( + FundingPlatform.TUMBLBUG, normalizedCampaignId); + long paid = fundingOrderRepository.countByPlatformAndCampaignIdAndPaymentStatus( + FundingPlatform.TUMBLBUG, normalizedCampaignId, PaymentStatus.PAID_CONFIRMED); + long delivered = fundingOrderRepository.countDeliveredByTumblbugCampaign(normalizedCampaignId); + return new TumblbugCampaignDashboardResponse(normalizedCampaignId, total, paid, delivered, total - delivered); + } + + private static FundingOrderSummaryResponse toSummary(FundingOrder order, List tickets, + TumblbugOrderDetailResponse tumblbug, + Map deliveries) { + List ticketViews = new ArrayList<>(); + for (ClassTicket ticket : tickets) { User registrant = ticket.getRegisteredUser(); - ticketView = new FundingOrderSummaryResponse.Ticket(ticket.getId(), ticket.getCapacity(), + ticketViews.add(new FundingOrderSummaryResponse.Ticket(ticket.getId(), ticket.getCapacity(), ticket.getCodeStatus(), ticket.getStatus(), ticket.getDeliveredAt(), ticket.getRegisterBy(), ticket.getActivatedAt(), ticket.getExpiresAt(), registrant == null ? null : registrant.getId(), - registrant == null ? null : registrant.getNickname()); + 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)); } - boolean issuable = ticket == null && order.isIssuable() && order.getPaidAt() != null; + 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, ticketView, tumblbug, - deliveryStatus == null ? com.swyp.picke.domain.funding.enums.DeliveryStatus.PENDING - : deliveryStatus == DeliveryStatus.SUCCESS - ? com.swyp.picke.domain.funding.enums.DeliveryStatus.SENT - : com.swyp.picke.domain.funding.enums.DeliveryStatus.FAILED); + 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); + } + + @Transactional + public AdminTicketCodeResponse getTicketCode(Long ticketId, Long adminUserId) { + ClassTicket ticket = getTicket(ticketId); + if (ticket.getEncryptedCode() == null || ticket.getEncryptedCode().isBlank()) { + throw new CustomException(ErrorCode.TICKET_CODE_NOT_RETRIEVABLE); + } + adminAuditLogRepository.save(new AdminAuditLog(adminUserId, "TICKET_CODE_VIEWED", "CLASS_TICKET", + ticketId, null, "{}")); + return new AdminTicketCodeResponse(ticketId, ticketCodeVault.decrypt(ticket.getEncryptedCode())); } // ───────────── 공통 ───────────── diff --git a/src/main/java/com/swyp/picke/domain/ticket/entity/ClassTicket.java b/src/main/java/com/swyp/picke/domain/ticket/entity/ClassTicket.java index 65b6b43f..6f70e698 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/entity/ClassTicket.java +++ b/src/main/java/com/swyp/picke/domain/ticket/entity/ClassTicket.java @@ -20,7 +20,7 @@ import lombok.NoArgsConstructor; /** - * 클래스 이용권. 코드 원문은 저장하지 않고 SHA-256 해시만 보관한다. + * 클래스 이용권. SHA-256 해시로 검증하고 AES-GCM 암호문으로 관리자 표시용 원문을 보관한다. *
    *
  • registerBy = 결제 확정일 + 1년 (발급 시 계산)
  • *
  • activatedAt = 등록 시각, expiresAt = activatedAt + 6개월
  • @@ -51,6 +51,10 @@ public class ClassTicket extends BaseEntity { @Column(name = "code_hash", nullable = false, length = 64) private String codeHash; + /** Recoverable display value encrypted with AES-GCM; nullable for tickets issued before this feature. */ + @Column(name = "encrypted_code", length = 160) + private String encryptedCode; + /** Optional display/search suffix. Raw codes remain unavailable after issuance. */ @Column(name = "code_suffix", length = 4) private String codeSuffix; @@ -87,34 +91,46 @@ public void recordCodeSuffix(String rawCode) { private LocalDateTime revokedAt; @Builder - private ClassTicket(FundingOrder fundingOrder, int capacity, String codeHash, LocalDateTime registerBy) { + private ClassTicket(FundingOrder fundingOrder, int capacity, String codeHash, String encryptedCode, LocalDateTime registerBy) { this.fundingOrder = fundingOrder; this.capacity = capacity; this.codeHash = codeHash; + this.encryptedCode = encryptedCode; this.registerBy = registerBy; this.codeStatus = TicketCodeStatus.ISSUED; this.status = TicketStatus.UNREGISTERED; } - public static ClassTicket issue(FundingOrder order, String codeHash) { + public static ClassTicket issue(FundingOrder order, String codeHash, String encryptedCode) { return ClassTicket.builder() .fundingOrder(order) .capacity(order.getRewardTier().getCapacity()) .codeHash(codeHash) + .encryptedCode(encryptedCode) .registerBy(order.getPaidAt().plusYears(REGISTER_WINDOW_YEARS)) .build(); } + /** 기존 해시 전용 테스트/운영 호출 호환용. 새 발급 경로에서는 암호문 인자를 사용합니다. */ + public static ClassTicket issue(FundingOrder order, String codeHash) { + return issue(order, codeHash, null); + } + /** 재발급: 같은 주문·정원·등록기한으로 새 코드 행을 만든다. 기존 행은 호출 측에서 revoke 한다 (CF06) */ - public static ClassTicket reissueFrom(ClassTicket previous, String codeHash) { + public static ClassTicket reissueFrom(ClassTicket previous, String codeHash, String encryptedCode) { return ClassTicket.builder() .fundingOrder(previous.getFundingOrder()) .capacity(previous.getCapacity()) .codeHash(codeHash) + .encryptedCode(encryptedCode) .registerBy(previous.getRegisterBy()) .build(); } + public static ClassTicket reissueFrom(ClassTicket previous, String codeHash) { + return reissueFrom(previous, codeHash, null); + } + /** 미사용 코드(발급·전달 단계)인지. 재발급·전달 기록은 이 상태에서만 가능하다 */ public boolean isUnclaimed() { return codeStatus == TicketCodeStatus.ISSUED || codeStatus == TicketCodeStatus.DELIVERED; diff --git a/src/main/java/com/swyp/picke/domain/ticket/entity/FundingOrder.java b/src/main/java/com/swyp/picke/domain/ticket/entity/FundingOrder.java index 2f6923e3..f3d5d0db 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/entity/FundingOrder.java +++ b/src/main/java/com/swyp/picke/domain/ticket/entity/FundingOrder.java @@ -124,6 +124,6 @@ public void updatePayment(PaymentStatus paymentStatus, LocalDateTime paidAt) { } public boolean isIssuable() { - return paymentStatus == PaymentStatus.PAID_CONFIRMED && quantity == 1; + return paymentStatus == PaymentStatus.PAID_CONFIRMED && paidAt != null && quantity > 0; } } diff --git a/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java b/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java index a123b23d..051429f7 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java +++ b/src/main/java/com/swyp/picke/domain/ticket/repository/ClassTicketRepository.java @@ -38,9 +38,18 @@ long countRegisterReminderTargets(@Param("from") LocalDateTime from, @Param("to" boolean existsByCodeHash(String codeHash); - /** 주문의 살아 있는(REVOKED 아닌) 이용권. 재발급 이력이 있어도 1건 (partial unique) */ + /** 기존 단일 티켓 호출 호환용. 복수 코드 처리는 findAll/count 메서드를 사용합니다. */ Optional findByFundingOrderIdAndStatusNot(Long fundingOrderId, TicketStatus status); + List findAllByFundingOrderIdAndStatusNot(Long fundingOrderId, TicketStatus status); + + long countByFundingOrderIdAndStatusNot(Long fundingOrderId, TicketStatus status); + + @Query("select t from ClassTicket t left join fetch t.registeredUser where t.fundingOrder.id in :fundingOrderIds " + + "and t.status <> :status order by t.id asc") + List findByFundingOrderIdInAndStatusNotOrderByIdAsc( + @Param("fundingOrderIds") Collection fundingOrderIds, @Param("status") TicketStatus status); + List findByFundingOrderIdInAndStatusNot(Collection fundingOrderIds, TicketStatus status); long countByStatusNot(TicketStatus status); 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 1b6edfc0..cb4e28a1 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 @@ -6,9 +6,12 @@ import com.swyp.picke.domain.ticket.enums.DeliveryStatus; import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; import com.swyp.picke.domain.ticket.enums.TicketStatus; +import com.swyp.picke.domain.ticket.enums.FundingPlatform; import java.util.Collection; import java.util.List; import java.util.Optional; +import jakarta.persistence.LockModeType; +import org.springframework.data.jpa.repository.Lock; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -19,17 +22,24 @@ public interface FundingOrderRepository extends JpaRepository findByOrderId(String orderId); + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select o from FundingOrder o where o.id = :id") + Optional findByIdForUpdate(@Param("id") Long id); + + @Lock(LockModeType.PESSIMISTIC_WRITE) + @Query("select o from FundingOrder o where o.id in :ids order by o.id asc") + List findAllByIdForUpdate(@Param("ids") Collection ids); + List findByOrderIdIn(Collection orderIds); - /** 발급 대상: 결제 확정·수량 1·결제일 있음·살아 있는 이용권 없음 (PRD §30, CF08 재실행 안전) */ + /** 발급 대상: 결제 완료·결제일 있음·발급된 코드 수가 주문 수량보다 적음. */ @Query(""" select o from FundingOrder o where o.paymentStatus = com.swyp.picke.domain.ticket.enums.PaymentStatus.PAID_CONFIRMED - and o.quantity = 1 and o.paidAt is not null - and not exists (select 1 from ClassTicket t - where t.fundingOrder = o - and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) + and o.quantity > (select count(t) from ClassTicket t + where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) order by o.id asc """) List findIssuable(); @@ -37,19 +47,18 @@ and not exists (select 1 from ClassTicket t @Query(""" select count(o) from FundingOrder o where o.paymentStatus = com.swyp.picke.domain.ticket.enums.PaymentStatus.PAID_CONFIRMED - and o.quantity = 1 and o.paidAt is not null - and not exists (select 1 from ClassTicket t - where t.fundingOrder = o - and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) + and o.quantity > (select count(t) from ClassTicket t + where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) """) long countIssuable(); - /** 보류: 결제 확정이지만 수량이 1 이 아니거나 결제일이 없는 행 + 결제 대기·예약 */ + /** 보류: 결제 완료 행에 결제일이 없거나 결제 대기·예약 상태인 주문. */ @Query(""" select count(o) from FundingOrder o where (o.paymentStatus = com.swyp.picke.domain.ticket.enums.PaymentStatus.PAID_CONFIRMED - and (o.quantity <> 1 or o.paidAt is null)) + and o.paidAt is null) or o.paymentStatus in (com.swyp.picke.domain.ticket.enums.PaymentStatus.RESERVED, com.swyp.picke.domain.ticket.enums.PaymentStatus.PAYMENT_PENDING) """) @@ -57,6 +66,26 @@ or o.paymentStatus in (com.swyp.picke.domain.ticket.enums.PaymentStatus.RESERVED long countByPaymentStatus(PaymentStatus paymentStatus); + long countByPlatformAndCampaignId(FundingPlatform platform, String campaignId); + + long countByPlatformAndCampaignIdAndPaymentStatus( + FundingPlatform platform, String campaignId, PaymentStatus paymentStatus); + + @Query(""" + select count(o) from FundingOrder o + where o.platform = com.swyp.picke.domain.ticket.enums.FundingPlatform.TUMBLBUG + and o.campaignId = :campaignId + and 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))) + """) + long countDeliveredByTumblbugCampaign(@Param("campaignId") String campaignId); + /** * 어드민 목록: 주문 + 살아 있는 이용권(없으면 null). 필터는 null 이면 무시. * 결과 Object[] = {FundingOrder, ClassTicket|null} @@ -66,7 +95,8 @@ or o.paymentStatus in (com.swyp.picke.domain.ticket.enums.PaymentStatus.RESERVED left join ClassTicket t on t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED left join fetch t.registeredUser - where (:paymentStatus is null or o.paymentStatus = :paymentStatus) + where (:campaignId is null or o.campaignId = :campaignId) + and (:paymentStatus is null or o.paymentStatus = :paymentStatus) and (:ticketStatus is null or t.status = :ticketStatus) and (:codeStatus is null or t.codeStatus = :codeStatus) and (:rewardTier is null or o.rewardTier = :rewardTier) @@ -89,7 +119,8 @@ and lower(r.supporterNumber) like lower(concat('%', :keyword, '%')))) select count(o) from FundingOrder o left join ClassTicket t on t.fundingOrder = o and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED - where (:paymentStatus is null or o.paymentStatus = :paymentStatus) + where (:campaignId is null or o.campaignId = :campaignId) + and (:paymentStatus is null or o.paymentStatus = :paymentStatus) and (:ticketStatus is null or t.status = :ticketStatus) and (:codeStatus is null or t.codeStatus = :codeStatus) and (:rewardTier is null or o.rewardTier = :rewardTier) @@ -110,9 +141,97 @@ and lower(r.supporterNumber) like lower(concat('%', :keyword, '%')))) Page searchWithLiveTicket(@Param("paymentStatus") PaymentStatus paymentStatus, @Param("ticketStatus") TicketStatus ticketStatus, @Param("codeStatus") TicketCodeStatus codeStatus, + @Param("campaignId") String campaignId, @Param("keyword") String keyword, @Param("rewardTier") RewardTier rewardTier, @Param("deliveryStatus") DeliveryStatus deliveryStatus, @Param("deliveryPending") boolean deliveryPending, Pageable pageable); + + @Query(value = """ + select distinct o from FundingOrder o + where (:campaignId is null or o.campaignId = :campaignId) + and (:paymentStatus is null or o.paymentStatus = :paymentStatus) + and (:rewardTier is null or o.rewardTier = :rewardTier) + and (:ticketStatus is null or exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and t.status = :ticketStatus)) + and (:codeStatus is null or exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and t.codeStatus = :codeStatus)) + and (:deliveryPending = false or not exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) + 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 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 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.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)))) + and (:keyword is null + or lower(o.orderId) like lower(concat('%', :keyword, '%')) + or lower(o.supporterName) like lower(concat('%', :keyword, '%')) + or lower(coalesce(o.supporterEmail, '')) like lower(concat('%', :keyword, '%')) + or o.supporterPhone = :keyword + or exists (select r.id from TumblbugImportRecord r where r.fundingOrder = o + and lower(r.supporterNumber) like lower(concat('%', :keyword, '%')))) + order by o.id desc + """, + countQuery = """ + select count(distinct o) from FundingOrder o + where (:campaignId is null or o.campaignId = :campaignId) + and (:paymentStatus is null or o.paymentStatus = :paymentStatus) + and (:rewardTier is null or o.rewardTier = :rewardTier) + and (:ticketStatus is null or exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and t.status = :ticketStatus)) + and (:codeStatus is null or exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED and t.codeStatus = :codeStatus)) + and (:deliveryPending = false or not exists (select t.id from ClassTicket t where t.fundingOrder = o + and t.status <> com.swyp.picke.domain.ticket.enums.TicketStatus.REVOKED) + 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 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 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.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)))) + and (:keyword is null + or lower(o.orderId) like lower(concat('%', :keyword, '%')) + or lower(o.supporterName) like lower(concat('%', :keyword, '%')) + or lower(coalesce(o.supporterEmail, '')) like lower(concat('%', :keyword, '%')) + or o.supporterPhone = :keyword + or exists (select r.id from TumblbugImportRecord r where r.fundingOrder = o + and lower(r.supporterNumber) like lower(concat('%', :keyword, '%')))) + """) + Page searchOrders(@Param("paymentStatus") PaymentStatus paymentStatus, + @Param("ticketStatus") TicketStatus ticketStatus, + @Param("codeStatus") TicketCodeStatus codeStatus, + @Param("campaignId") String campaignId, + @Param("keyword") String keyword, + @Param("rewardTier") RewardTier rewardTier, + @Param("deliveryStatus") DeliveryStatus deliveryStatus, + @Param("deliveryPending") boolean deliveryPending, + Pageable pageable); } diff --git a/src/main/java/com/swyp/picke/domain/ticket/service/TicketCodeVault.java b/src/main/java/com/swyp/picke/domain/ticket/service/TicketCodeVault.java new file mode 100644 index 00000000..fb3258ca --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ticket/service/TicketCodeVault.java @@ -0,0 +1,77 @@ +package com.swyp.picke.domain.ticket.service; + +import com.swyp.picke.global.common.exception.CustomException; +import com.swyp.picke.global.common.exception.ErrorCode; +import java.nio.ByteBuffer; +import java.security.GeneralSecurityException; +import java.security.SecureRandom; +import java.util.Base64; +import javax.crypto.Cipher; +import javax.crypto.spec.GCMParameterSpec; +import javax.crypto.spec.SecretKeySpec; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +/** 이용권 코드를 AES-GCM으로 저장·복호화합니다. 키는 Base64 인코딩한 32바이트 값을 환경변수로 받습니다. */ +@Component +public class TicketCodeVault { + private static final int NONCE_BYTES = 12; + private static final int TAG_BITS = 128; + private static final SecureRandom RANDOM = new SecureRandom(); + + private final byte[] keyBytes; + + public TicketCodeVault(@Value("${picke.ticket.code-encryption-key:}") String encodedKey) { + byte[] decoded = new byte[0]; + if (encodedKey != null && !encodedKey.isBlank()) { + try { + decoded = Base64.getDecoder().decode(encodedKey.trim()); + } catch (IllegalArgumentException ignored) { + // Report one consistent configuration error when the vault is used. + } + } + this.keyBytes = decoded.length == 32 ? decoded : new byte[0]; + } + + public String encrypt(String code) { + byte[] nonce = new byte[NONCE_BYTES]; + RANDOM.nextBytes(nonce); + byte[] encrypted = crypt(Cipher.ENCRYPT_MODE, code, nonce); + return Base64.getEncoder().encodeToString(ByteBuffer.allocate(nonce.length + encrypted.length) + .put(nonce).put(encrypted).array()); + } + + public String decrypt(String encryptedCode) { + byte[] payload; + try { + payload = Base64.getDecoder().decode(encryptedCode); + } catch (IllegalArgumentException e) { + throw new CustomException(ErrorCode.TICKET_CODE_STORAGE_UNAVAILABLE); + } + if (payload.length <= NONCE_BYTES) { + throw new CustomException(ErrorCode.TICKET_CODE_STORAGE_UNAVAILABLE); + } + byte[] nonce = new byte[NONCE_BYTES]; + byte[] ciphertext = new byte[payload.length - NONCE_BYTES]; + System.arraycopy(payload, 0, nonce, 0, NONCE_BYTES); + System.arraycopy(payload, NONCE_BYTES, ciphertext, 0, ciphertext.length); + return new String(crypt(Cipher.DECRYPT_MODE, ciphertext, nonce), java.nio.charset.StandardCharsets.UTF_8); + } + + private byte[] crypt(int mode, String value, byte[] nonce) { + return crypt(mode, value.getBytes(java.nio.charset.StandardCharsets.UTF_8), nonce); + } + + private byte[] crypt(int mode, byte[] value, byte[] nonce) { + if (keyBytes.length != 32) { + throw new CustomException(ErrorCode.TICKET_CODE_STORAGE_UNAVAILABLE); + } + try { + Cipher cipher = Cipher.getInstance("AES/GCM/NoPadding"); + cipher.init(mode, new SecretKeySpec(keyBytes, "AES"), new GCMParameterSpec(TAG_BITS, nonce)); + return cipher.doFinal(value); + } catch (GeneralSecurityException e) { + throw new CustomException(ErrorCode.TICKET_CODE_STORAGE_UNAVAILABLE); + } + } +} diff --git a/src/main/java/com/swyp/picke/domain/ticket/util/FundingCsvParser.java b/src/main/java/com/swyp/picke/domain/ticket/util/FundingCsvParser.java index 51dc64d2..71c54054 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/util/FundingCsvParser.java +++ b/src/main/java/com/swyp/picke/domain/ticket/util/FundingCsvParser.java @@ -170,8 +170,8 @@ private static int parseQuantity(String raw) { } catch (NumberFormatException e) { throw new RowException("quantity 가 숫자가 아닙니다: " + raw); } - if (q < 1) { - throw new RowException("quantity 는 1 이상이어야 합니다."); + if (q < 1 || q > 7) { + throw new RowException("quantity 는 1~7이어야 합니다."); } return q; } diff --git a/src/main/java/com/swyp/picke/domain/ticket/util/TicketCodeGenerator.java b/src/main/java/com/swyp/picke/domain/ticket/util/TicketCodeGenerator.java index bc6bf7fa..f6d6bb81 100644 --- a/src/main/java/com/swyp/picke/domain/ticket/util/TicketCodeGenerator.java +++ b/src/main/java/com/swyp/picke/domain/ticket/util/TicketCodeGenerator.java @@ -5,7 +5,7 @@ /** * 이용권 코드 생성. SecureRandom 130bit → Crockford Base32 26자 (0/O, 1/I/L 혼동 문자 제외). * 표시 형식은 {@code XXXXXX-XXXXX-XXXXX-XXXXX-XXXXX} (6-5-5-5-5). 하이픈·소문자는 {@link TicketCodeHasher#normalize} 가 흡수한다. - * 원문은 발급 응답에서 1회만 노출하고 저장·로그에 남기지 않는다. + * 원문은 관리자 발급/조회 응답으로만 노출하고 서버 로그에는 남기지 않는다. DB에는 별도 키로 암호화해 보관한다. */ public final class TicketCodeGenerator { diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index c4c2151c..f762a054 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -23,7 +23,6 @@ spring: dialect: org.hibernate.dialect.PostgreSQLDialect jdbc: time_zone: Asia/Seoul - jackson: time-zone: Asia/Seoul @@ -144,6 +143,8 @@ admin: picke: baseUrl: ${PICKE_BASE_URL:https://picke.store} + ticket: + code-encryption-key: ${TICKET_CODE_ENCRYPTION_KEY:} ad: host: ${AD_HOST:ad.picke.store} base-url: ${AD_BASE_URL:https://ad.picke.store} diff --git a/src/main/resources/db/migration/V20261025_01__funding_plan_and_ticket_code_vault.sql b/src/main/resources/db/migration/V20261025_01__funding_plan_and_ticket_code_vault.sql new file mode 100644 index 00000000..2e82db1c --- /dev/null +++ b/src/main/resources/db/migration/V20261025_01__funding_plan_and_ticket_code_vault.sql @@ -0,0 +1,11 @@ +ALTER TABLE funding_campaigns + ADD COLUMN IF NOT EXISTS tumblbug_plan VARCHAR(20) NOT NULL DEFAULT 'RUN'; + +-- New codes are decryptable only by the application with TICKET_CODE_ENCRYPTION_KEY. +-- Existing rows have only a one-way hash and intentionally remain null. +ALTER TABLE class_tickets + ADD COLUMN IF NOT EXISTS encrypted_code VARCHAR(160); + +-- One funding order can produce one code per purchased quantity. +DROP INDEX IF EXISTS ux_class_tickets_live_order; +ALTER TABLE class_tickets DROP CONSTRAINT IF EXISTS uk_class_tickets_funding_order;