From a673bff4156b58d920e9cbf1c6b932f758f9aeaa Mon Sep 17 00:00:00 2001 From: HYH0804 Date: Wed, 28 Oct 2026 16:20:28 +0900 Subject: [PATCH] =?UTF-8?q?#589=20[Feat]=20=EB=AF=B8=EB=93=B1=EB=A1=9D=20?= =?UTF-8?q?=EC=9D=B4=EC=9A=A9=EA=B6=8C=20=EB=93=B1=EB=A1=9D=EA=B8=B0?= =?UTF-8?q?=ED=95=9C=20=EC=9E=84=EB=B0=95=20=EC=95=8C=EB=A6=BC=20=EB=8C=80?= =?UTF-8?q?=EC=83=81=20=EC=A1=B0=ED=9A=8C=C2=B7=EB=B0=9C=EC=86=A1=20?= =?UTF-8?q?=EA=B8=B0=EB=A1=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - GET /api/v1/admin/funding/register-reminders?stage=D30|D7: 전달 완료·미등록 이용권 중 등록기한 구간에 들고 해당 단계 알림을 성공 기록하지 않은 건 - POST /api/v1/admin/funding/tickets/{ticketId}/register-reminders: 운영자 발송 결과 기록 (ticket_register_reminders) - 펀딩 현황 요약에 registerReminderD30 / registerReminderD7 추가 - 마이그레이션 V20261028_03 (+ 롤백), API 명세·ERD 갱신, 통합 테스트 추가 Co-Authored-By: Claude Opus 5.5 Claude-Session: https://claude.ai/code/session_013pr5xNDiEmzxnZ2AbtHxVy --- docs/api-specs/class-api.md | 52 ++++++++++++ docs/erd/class.puml | 14 ++++ .../controller/AdminFundingController.java | 28 ++++++- .../request/RegisterReminderRequest.java | 19 +++++ .../response/FundingSummaryResponse.java | 4 +- .../response/RegisterReminderResponse.java | 17 ++++ .../RegisterReminderTargetResponse.java | 27 ++++++ .../admin/service/AdminFundingService.java | 62 +++++++++++++- .../ticket/entity/TicketRegisterReminder.java | 60 ++++++++++++++ .../ticket/enums/RegisterReminderStage.java | 31 +++++++ .../repository/ClassTicketRepository.java | 19 +++++ .../TicketRegisterReminderRepository.java | 10 +++ ...8_03__create_ticket_register_reminders.sql | 26 ++++++ ...back__create_ticket_register_reminders.sql | 3 + .../AdminFundingApiIntegrationTest.java | 82 +++++++++++++++++++ 15 files changed, 451 insertions(+), 3 deletions(-) create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java create mode 100644 src/main/java/com/swyp/picke/domain/ticket/entity/TicketRegisterReminder.java create mode 100644 src/main/java/com/swyp/picke/domain/ticket/enums/RegisterReminderStage.java create mode 100644 src/main/java/com/swyp/picke/domain/ticket/repository/TicketRegisterReminderRepository.java create mode 100644 src/main/resources/db/migration/V20261028_03__create_ticket_register_reminders.sql create mode 100644 src/main/resources/db/migration/V20261028_rollback__create_ticket_register_reminders.sql diff --git a/docs/api-specs/class-api.md b/docs/api-specs/class-api.md index c6e9160d..cbbd378b 100644 --- a/docs/api-specs/class-api.md +++ b/docs/api-specs/class-api.md @@ -1263,8 +1263,60 @@ AI가 주제 후보를 제안하면 운영자가 하나를 고르거나 직접 - 응답 필드: - 주문: `totalOrders`, `paidConfirmed`, `issuable`, `held`, `refunded`, `canceled` - 이용권: `issued`, `undelivered`, `delivered`, `registered` + - 등록기한 알림 대상 수: `registerReminderD30`, `registerReminderD7` (9.9와 같은 기준) - 이용권 수치는 살아 있는(REVOKED가 아닌) 행 기준이다. +### 9.9 등록기한 임박 알림 +PRD "등록기한 30일·7일 전 알림은 허용된 연락 수단에서만 발송". 미등록 이용권은 앱 계정에 연결되지 않아 푸시를 보낼 수 없다. 그래서 코드 전달(9.4)처럼 운영자가 후원자 연락처로 보내고 결과를 기록한다. 대상은 조회 시점에 계산하므로 별도 배치가 없다. + +**대상 조회** +- `GET /api/v1/admin/funding/register-reminders?stage=D30` + +| `stage` | 대상 등록기한 | +|---|---| +| `D30` | 지금부터 7일 초과 ~ 30일 이내 | +| `D7` | 지금부터 7일 이내 (지난 건 제외) | + +- 대상: 전달 완료(`codeStatus=DELIVERED`)·미등록(`status=UNREGISTERED`)이고, 그 단계 알림을 아직 `SUCCESS`로 기록하지 않은 이용권이다. 등록기한이 빠른 순이다. +- 30일 알림을 놓치고 7일 안으로 들어온 이용권은 `D7` 대상만 된다. +- 미전달(`ISSUED`) 이용권은 대상이 아니다. 코드 전달(9.4)부터 해야 한다. + +```json +{ + "statusCode": 200, + "data": { + "stage": "D30", + "count": 1, + "items": [ + { + "ticketId": 12, "fundingOrderId": 3, "platform": "TUMBLBUG", "orderId": "ORD-30", + "supporterName": "홍길동", "supporterEmail": "a@test.com", "supporterPhone": "010-0000-0000", + "capacity": 15, "registerBy": "2026-11-17T10:00:00", "daysLeft": 20 + } + ] + } +} +``` + +- `daysLeft`: 등록기한까지 남은 일수 (KST 날짜 기준) + +**발송 기록** +- `POST /api/v1/admin/funding/tickets/{ticketId}/register-reminders` + +```json +{ "stage": "D30", "channel": "EMAIL", "status": "SUCCESS", "failureReason": null } +``` + +- `channel`: `EMAIL` / `SMS` / `MANUAL` +- `status`: `SUCCESS`면 그 단계 대상에서 빠진다. `FAILED`는 이력만 남고 대상에 그대로 있다(재시도 가능). +- 응답: `reminderId`, `ticketId`, `stage`, `channel`, `status`, `failureReason`, `attemptedAt` + +| 상황 | 응답 | +|---|---| +| 전달 완료·미등록 이용권이 아님 (미전달·등록 완료·무효화) | 409 `TICKET_409_STATE` | +| 등록기한 경과 | 409 `TICKET_409_REG_EXP` | +| `stage`·`channel`·`status` 누락 | 400 `COMMON_400` | + --- ## 10. 에러 코드 모음 diff --git a/docs/erd/class.puml b/docs/erd/class.puml index b5b1ecd1..9ed49c77 100644 --- a/docs/erd/class.puml +++ b/docs/erd/class.puml @@ -108,6 +108,19 @@ entity "class_tickets" as class_tickets { updated_at : TIMESTAMP } +entity "ticket_register_reminders" as ticket_register_reminders { + * id : BIGINT <> + -- + ticket_id : BIGINT <> + stage : VARCHAR(10) <> + channel : VARCHAR(20) + status : VARCHAR(20) + failure_reason : VARCHAR(255) <> + attempted_at : TIMESTAMP + created_at : TIMESTAMP + updated_at : TIMESTAMP +} + entity "ticket_delivery_attempts" as ticket_delivery_attempts { * id : BIGINT <> -- @@ -278,6 +291,7 @@ entity "class_reports" as class_reports { funding_orders ||--o{ class_tickets : issues users |o--o{ class_tickets : registers class_tickets ||--o{ ticket_delivery_attempts : delivered by +class_tickets ||--o{ ticket_register_reminders : reminded by users ||--o{ classes : owns class_tickets ||--o{ classes : issued with 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 dd8e1638..e1df950f 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 @@ -1,5 +1,9 @@ package com.swyp.picke.domain.admin.controller; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderTargetResponse; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderResponse; +import com.swyp.picke.domain.admin.dto.funding.request.RegisterReminderRequest; import com.swyp.picke.domain.admin.dto.funding.request.FundingRefundRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketDeliveryRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketIssueRequest; @@ -104,7 +108,29 @@ public ApiResponse getOrders( adminFundingService.getOrders(paymentStatus, ticketStatus, codeStatus, keyword, page, size)); } - @Operation(summary = "펀딩·이용권 현황 요약", description = "결제 확정/발급 가능/보류/발급/미전달/전달/등록/환불/취소 카운트") + @Operation(summary = "등록기한 임박 알림 대상", + description = "전달 완료(DELIVERED)·미등록 이용권 중 등록기한이 D30(7일 초과~30일 이내) 또는 D7(7일 이내)에 들고, " + + "해당 단계 알림을 아직 SUCCESS 로 기록하지 않은 건을 등록기한 빠른 순으로 돌려줍니다. " + + "미등록 이용권은 앱 계정이 없어 푸시가 불가하므로, 운영자가 후원자 연락처로 보낸 뒤 결과를 기록합니다.") + @GetMapping("/register-reminders") + public ApiResponse getRegisterReminderTargets( + @RequestParam RegisterReminderStage stage + ) { + return ApiResponse.onSuccess(adminFundingService.getRegisterReminderTargets(stage)); + } + + @Operation(summary = "등록기한 임박 알림 발송 기록", + description = "운영자가 보낸 등록기한 알림 결과를 단계별로 기록합니다. SUCCESS 로 기록한 단계는 대상 목록에서 빠집니다. " + + "전달 완료·미등록 이용권이 아니면 409 TICKET_409_STATE, 등록기한이 지났으면 409 TICKET_409_REG_EXP.") + @PostMapping("/tickets/{ticketId}/register-reminders") + public ApiResponse recordRegisterReminder( + @PathVariable Long ticketId, + @Valid @RequestBody RegisterReminderRequest request + ) { + return ApiResponse.onSuccess(adminFundingService.recordRegisterReminder(ticketId, request)); + } + + @Operation(summary = "펀딩·이용권 현황 요약", description = "결제 확정/발급 가능/보류/발급/미전달/전달/등록/환불/취소 카운트, 등록기한 알림 대상 수") @GetMapping("/summary") public ApiResponse getSummary() { return ApiResponse.onSuccess(adminFundingService.getSummary()); diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java new file mode 100644 index 00000000..86e9560c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/request/RegisterReminderRequest.java @@ -0,0 +1,19 @@ +package com.swyp.picke.domain.admin.dto.funding.request; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Size; + +public record RegisterReminderRequest( + @Schema(description = "알림 단계 (D30: 등록기한 30일 전, D7: 7일 전)", example = "D30") + @NotNull RegisterReminderStage stage, + @Schema(description = "발송 수단", example = "EMAIL") + @NotNull DeliveryChannel channel, + @Schema(description = "발송 결과. SUCCESS 로 기록한 단계는 알림 대상에서 빠진다", example = "SUCCESS") + @NotNull DeliveryStatus status, + @Schema(description = "실패 사유 (FAILED 일 때)", nullable = true) + @Size(max = 255) String failureReason +) {} 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 eb511484..d766fa7e 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 @@ -13,5 +13,7 @@ public record FundingSummaryResponse( @Schema(description = "전달 완료 (codeStatus=DELIVERED, 아직 미등록)") long delivered, @Schema(description = "등록 완료 (ACTIVE + EXPIRED)") long registered, @Schema(description = "환불 주문 수") long refunded, - @Schema(description = "취소 주문 수") long canceled + @Schema(description = "취소 주문 수") long canceled, + @Schema(description = "등록기한 30일 전 알림 대상 수") long registerReminderD30, + @Schema(description = "등록기한 7일 전 알림 대상 수") long registerReminderD7 ) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java new file mode 100644 index 00000000..dba55578 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderResponse.java @@ -0,0 +1,17 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; + +public record RegisterReminderResponse( + Long reminderId, + Long ticketId, + RegisterReminderStage stage, + DeliveryChannel channel, + DeliveryStatus status, + @Schema(nullable = true) String failureReason, + LocalDateTime attemptedAt +) {} diff --git a/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java new file mode 100644 index 00000000..40c5fd38 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/admin/dto/funding/response/RegisterReminderTargetResponse.java @@ -0,0 +1,27 @@ +package com.swyp.picke.domain.admin.dto.funding.response; + +import com.swyp.picke.domain.ticket.enums.FundingPlatform; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDateTime; +import java.util.List; + +@Schema(description = "등록기한 임박 알림 대상. 전달 완료·미등록이고 이 단계 알림을 아직 성공 기록하지 않은 이용권, 등록기한 빠른 순") +public record RegisterReminderTargetResponse( + RegisterReminderStage stage, + @Schema(description = "대상 수") int count, + List items +) { + public record Item( + Long ticketId, + Long fundingOrderId, + FundingPlatform platform, + @Schema(description = "플랫폼 주문 번호") String orderId, + String supporterName, + @Schema(nullable = true) String supporterEmail, + @Schema(nullable = true) String supporterPhone, + int capacity, + LocalDateTime registerBy, + @Schema(description = "등록기한까지 남은 일수 (KST 날짜 기준)") long daysLeft + ) {} +} 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 4d36a057..dd7302f0 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 @@ -2,24 +2,30 @@ import com.swyp.picke.domain.admin.dto.funding.request.FundingRefundRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketDeliveryRequest; +import com.swyp.picke.domain.admin.dto.funding.request.RegisterReminderRequest; import com.swyp.picke.domain.admin.dto.funding.request.TicketIssueRequest; import com.swyp.picke.domain.admin.dto.funding.response.FundingImportResponse; import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderPageResponse; import com.swyp.picke.domain.admin.dto.funding.response.FundingOrderSummaryResponse; 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.RegisterReminderResponse; +import com.swyp.picke.domain.admin.dto.funding.response.RegisterReminderTargetResponse; 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.ticket.entity.ClassTicket; import com.swyp.picke.domain.ticket.entity.FundingOrder; import com.swyp.picke.domain.ticket.entity.TicketDeliveryAttempt; +import com.swyp.picke.domain.ticket.entity.TicketRegisterReminder; 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.TicketCodeStatus; import com.swyp.picke.domain.ticket.enums.TicketStatus; import com.swyp.picke.domain.ticket.repository.ClassTicketRepository; import com.swyp.picke.domain.ticket.repository.FundingOrderRepository; import com.swyp.picke.domain.ticket.repository.TicketDeliveryAttemptRepository; +import com.swyp.picke.domain.ticket.repository.TicketRegisterReminderRepository; import com.swyp.picke.domain.ticket.util.FundingCsvParser; import com.swyp.picke.domain.ticket.util.TicketCodeGenerator; import com.swyp.picke.domain.ticket.util.TicketCodeHasher; @@ -32,6 +38,7 @@ import java.security.NoSuchAlgorithmException; import java.time.LocalDateTime; import java.time.ZoneId; +import java.time.temporal.ChronoUnit; import java.util.ArrayList; import java.util.HashMap; import java.util.HexFormat; @@ -69,6 +76,7 @@ public class AdminFundingService { private final FundingOrderRepository fundingOrderRepository; private final ClassTicketRepository classTicketRepository; private final TicketDeliveryAttemptRepository ticketDeliveryAttemptRepository; + private final TicketRegisterReminderRepository ticketRegisterReminderRepository; // ───────────── 1. 명단 가져오기 ───────────── @@ -283,6 +291,55 @@ public FundingRefundResponse refund(FundingRefundRequest request) { return new FundingRefundResponse(refunded, revokedTickets, alreadyRefunded); } + // ───────────── 5-1. 등록기한 임박 알림 ───────────── + + /** + * 미등록 이용권은 앱 계정에 연결되지 않아 푸시를 보낼 수 없다. 코드 전달과 같이 운영자가 허용된 연락 수단으로 보내고 결과를 기록한다. + * 대상은 조회 시점 기준으로 계산하므로 별도 배치가 없다. + */ + public RegisterReminderTargetResponse getRegisterReminderTargets(RegisterReminderStage stage) { + LocalDateTime now = LocalDateTime.now(KST); + List items = classTicketRepository + .findRegisterReminderTargets(stage.windowStart(now), stage.windowEnd(now), stage).stream() + .map(ticket -> toReminderTarget(ticket, now)) + .toList(); + return new RegisterReminderTargetResponse(stage, items.size(), items); + } + + @Transactional + public RegisterReminderResponse recordRegisterReminder(Long ticketId, RegisterReminderRequest request) { + ClassTicket ticket = getTicket(ticketId); + if (ticket.getStatus() != TicketStatus.UNREGISTERED || ticket.getCodeStatus() != TicketCodeStatus.DELIVERED) { + throw new CustomException(ErrorCode.TICKET_STATE_CONFLICT); + } + LocalDateTime now = LocalDateTime.now(KST); + if (!ticket.isRegisterWindowOpen(now)) { + throw new CustomException(ErrorCode.TICKET_REGISTER_EXPIRED); + } + TicketRegisterReminder reminder = ticketRegisterReminderRepository.save(TicketRegisterReminder.builder() + .ticket(ticket) + .stage(request.stage()) + .channel(request.channel()) + .status(request.status()) + .failureReason(request.status() == DeliveryStatus.FAILED ? request.failureReason() : null) + .attemptedAt(now) + .build()); + return new RegisterReminderResponse(reminder.getId(), ticket.getId(), reminder.getStage(), reminder.getChannel(), + reminder.getStatus(), reminder.getFailureReason(), reminder.getAttemptedAt()); + } + + private static RegisterReminderTargetResponse.Item toReminderTarget(ClassTicket ticket, LocalDateTime now) { + FundingOrder order = ticket.getFundingOrder(); + return new RegisterReminderTargetResponse.Item(ticket.getId(), order.getId(), order.getPlatform(), + order.getOrderId(), order.getSupporterName(), order.getSupporterEmail(), order.getSupporterPhone(), + ticket.getCapacity(), ticket.getRegisterBy(), + ChronoUnit.DAYS.between(now.toLocalDate(), ticket.getRegisterBy().toLocalDate())); + } + + private long countRegisterReminderTargets(RegisterReminderStage stage, LocalDateTime now) { + return classTicketRepository.countRegisterReminderTargets(stage.windowStart(now), stage.windowEnd(now), stage); + } + // ───────────── 6. 조회 ───────────── public FundingOrderPageResponse getOrders(PaymentStatus paymentStatus, TicketStatus ticketStatus, @@ -300,6 +357,7 @@ public FundingOrderPageResponse getOrders(PaymentStatus paymentStatus, TicketSta } public FundingSummaryResponse getSummary() { + LocalDateTime now = LocalDateTime.now(KST); return new FundingSummaryResponse( fundingOrderRepository.count(), fundingOrderRepository.countByPaymentStatus(PaymentStatus.PAID_CONFIRMED), @@ -310,7 +368,9 @@ public FundingSummaryResponse getSummary() { classTicketRepository.countByCodeStatus(TicketCodeStatus.DELIVERED), classTicketRepository.countByStatusIn(List.of(TicketStatus.ACTIVE, TicketStatus.EXPIRED)), fundingOrderRepository.countByPaymentStatus(PaymentStatus.REFUNDED), - fundingOrderRepository.countByPaymentStatus(PaymentStatus.CANCELED)); + fundingOrderRepository.countByPaymentStatus(PaymentStatus.CANCELED), + countRegisterReminderTargets(RegisterReminderStage.D30, now), + countRegisterReminderTargets(RegisterReminderStage.D7, now)); } private static FundingOrderSummaryResponse toSummary(FundingOrder order, ClassTicket ticket) { diff --git a/src/main/java/com/swyp/picke/domain/ticket/entity/TicketRegisterReminder.java b/src/main/java/com/swyp/picke/domain/ticket/entity/TicketRegisterReminder.java new file mode 100644 index 00000000..f9eed74c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ticket/entity/TicketRegisterReminder.java @@ -0,0 +1,60 @@ +package com.swyp.picke.domain.ticket.entity; + +import com.swyp.picke.domain.ticket.enums.DeliveryChannel; +import com.swyp.picke.domain.ticket.enums.DeliveryStatus; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; +import com.swyp.picke.global.common.BaseEntity; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.FetchType; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import java.time.LocalDateTime; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; + +/** 미등록 이용권 등록기한 임박 알림 발송 이력 (운영자가 이메일 / SMS / 수동으로 보낸 결과). */ +@Getter +@Entity +@Table(name = "ticket_register_reminders") +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class TicketRegisterReminder extends BaseEntity { + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "ticket_id", nullable = false) + private ClassTicket ticket; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 10) + private RegisterReminderStage stage; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private DeliveryChannel channel; + + @Enumerated(EnumType.STRING) + @Column(nullable = false, length = 20) + private DeliveryStatus status; + + @Column(name = "failure_reason") + private String failureReason; + + @Column(name = "attempted_at", nullable = false) + private LocalDateTime attemptedAt; + + @Builder + private TicketRegisterReminder(ClassTicket ticket, RegisterReminderStage stage, DeliveryChannel channel, + DeliveryStatus status, String failureReason, LocalDateTime attemptedAt) { + this.ticket = ticket; + this.stage = stage; + this.channel = channel; + this.status = status; + this.failureReason = failureReason; + this.attemptedAt = attemptedAt; + } +} diff --git a/src/main/java/com/swyp/picke/domain/ticket/enums/RegisterReminderStage.java b/src/main/java/com/swyp/picke/domain/ticket/enums/RegisterReminderStage.java new file mode 100644 index 00000000..ebc73d48 --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ticket/enums/RegisterReminderStage.java @@ -0,0 +1,31 @@ +package com.swyp.picke.domain.ticket.enums; + +import java.time.LocalDateTime; + +/** + * 미등록 이용권 등록기한 임박 알림 단계 (PRD "등록기한 30일·7일 전 알림"). + * 단계마다 등록기한이 (now + 다음 단계 일수, now + 이 단계 일수] 안에 있는 이용권이 대상이다. + * 30일 알림을 놓친 채 7일 안으로 들어온 이용권은 7일 알림만 대상이 된다. + */ +public enum RegisterReminderStage { + D30(30, 7), + D7(7, 0); + + private final int daysBefore; + private final int nextStageDaysBefore; + + RegisterReminderStage(int daysBefore, int nextStageDaysBefore) { + this.daysBefore = daysBefore; + this.nextStageDaysBefore = nextStageDaysBefore; + } + + /** 대상 등록기한 구간 시작(제외) */ + public LocalDateTime windowStart(LocalDateTime now) { + return now.plusDays(nextStageDaysBefore); + } + + /** 대상 등록기한 구간 끝(포함) */ + public LocalDateTime windowEnd(LocalDateTime now) { + return now.plusDays(daysBefore); + } +} 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 749f02b6..0c1d69b7 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 @@ -1,5 +1,7 @@ package com.swyp.picke.domain.ticket.repository; +import java.time.LocalDateTime; +import com.swyp.picke.domain.ticket.enums.RegisterReminderStage; import com.swyp.picke.domain.ticket.entity.ClassTicket; import com.swyp.picke.domain.ticket.enums.TicketCodeStatus; import com.swyp.picke.domain.ticket.enums.TicketStatus; @@ -14,6 +16,23 @@ public interface ClassTicketRepository extends JpaRepository { + String REGISTER_REMINDER_TARGET_CONDITION = + "where t.status = com.swyp.picke.domain.ticket.enums.TicketStatus.UNREGISTERED " + + "and t.codeStatus = com.swyp.picke.domain.ticket.enums.TicketCodeStatus.DELIVERED " + + "and t.registerBy > :from and t.registerBy <= :to " + + "and not exists (select 1 from TicketRegisterReminder r where r.ticket = t and r.stage = :stage " + + "and r.status = com.swyp.picke.domain.ticket.enums.DeliveryStatus.SUCCESS)"; + + /** 등록기한 알림 대상: 전달 완료·미등록이고 등록기한이 (from, to] 안이며 이 단계 알림을 아직 성공 기록하지 않은 이용권 */ + @Query("select t from ClassTicket t join fetch t.fundingOrder " + REGISTER_REMINDER_TARGET_CONDITION + + " order by t.registerBy asc, t.id asc") + List findRegisterReminderTargets(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to, + @Param("stage") RegisterReminderStage stage); + + @Query("select count(t) from ClassTicket t " + REGISTER_REMINDER_TARGET_CONDITION) + long countRegisterReminderTargets(@Param("from") LocalDateTime from, @Param("to") LocalDateTime to, + @Param("stage") RegisterReminderStage stage); + Optional findByCodeHash(String codeHash); boolean existsByCodeHash(String codeHash); diff --git a/src/main/java/com/swyp/picke/domain/ticket/repository/TicketRegisterReminderRepository.java b/src/main/java/com/swyp/picke/domain/ticket/repository/TicketRegisterReminderRepository.java new file mode 100644 index 00000000..0320d00c --- /dev/null +++ b/src/main/java/com/swyp/picke/domain/ticket/repository/TicketRegisterReminderRepository.java @@ -0,0 +1,10 @@ +package com.swyp.picke.domain.ticket.repository; + +import com.swyp.picke.domain.ticket.entity.TicketRegisterReminder; +import java.util.List; +import org.springframework.data.jpa.repository.JpaRepository; + +public interface TicketRegisterReminderRepository extends JpaRepository { + + List findByTicketIdOrderByAttemptedAtDesc(Long ticketId); +} diff --git a/src/main/resources/db/migration/V20261028_03__create_ticket_register_reminders.sql b/src/main/resources/db/migration/V20261028_03__create_ticket_register_reminders.sql new file mode 100644 index 00000000..79d6dd63 --- /dev/null +++ b/src/main/resources/db/migration/V20261028_03__create_ticket_register_reminders.sql @@ -0,0 +1,26 @@ +-- #589 미등록 이용권 등록기한 임박 알림(D30·D7) 발송 이력. +-- 운영자가 허용된 연락 수단으로 보낸 결과를 기록한다. 같은 단계에 실패 후 재시도할 수 있어 단계별 UNIQUE 를 두지 않는다. +-- 멱등하게 작성되어 재실행해도 안전하다. + +CREATE TABLE IF NOT EXISTS ticket_register_reminders ( + id BIGINT GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, + ticket_id BIGINT NOT NULL, + stage VARCHAR(10) NOT NULL, + channel VARCHAR(20) NOT NULL, + status VARCHAR(20) NOT NULL, + failure_reason VARCHAR(255), + attempted_at TIMESTAMP NOT NULL, + created_at TIMESTAMP, + updated_at TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_ticket_register_reminders_ticket_stage + ON ticket_register_reminders (ticket_id, stage); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_constraint WHERE conname = 'fk_ticket_register_reminders_ticket') THEN + ALTER TABLE ticket_register_reminders ADD CONSTRAINT fk_ticket_register_reminders_ticket + FOREIGN KEY (ticket_id) REFERENCES class_tickets(id) ON DELETE CASCADE; + END IF; +END $$; diff --git a/src/main/resources/db/migration/V20261028_rollback__create_ticket_register_reminders.sql b/src/main/resources/db/migration/V20261028_rollback__create_ticket_register_reminders.sql new file mode 100644 index 00000000..36a7309a --- /dev/null +++ b/src/main/resources/db/migration/V20261028_rollback__create_ticket_register_reminders.sql @@ -0,0 +1,3 @@ +-- Rollback for V20261028_03__create_ticket_register_reminders.sql +-- 애플리케이션 코드를 #589 이전으로 되돌린 뒤 실행할 것. +DROP TABLE IF EXISTS ticket_register_reminders; 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 92633d57..e6227f3f 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 @@ -18,6 +18,7 @@ import com.swyp.picke.domain.ticket.repository.ClassTicketRepository; import com.swyp.picke.domain.ticket.repository.FundingOrderRepository; import com.swyp.picke.domain.ticket.repository.TicketDeliveryAttemptRepository; +import com.swyp.picke.domain.ticket.repository.TicketRegisterReminderRepository; import com.swyp.picke.domain.ticket.util.TicketCodeHasher; import com.swyp.picke.domain.user.entity.User; import com.swyp.picke.domain.user.enums.UserRole; @@ -25,6 +26,8 @@ import com.swyp.picke.domain.user.repository.UserRepository; import java.nio.charset.StandardCharsets; import java.time.LocalDateTime; +import java.time.ZoneId; +import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Map; import java.util.UUID; @@ -68,6 +71,8 @@ class AdminFundingApiIntegrationTest { private ClassTicketRepository classTicketRepository; @Autowired private TicketDeliveryAttemptRepository ticketDeliveryAttemptRepository; + @Autowired + private TicketRegisterReminderRepository ticketRegisterReminderRepository; private String prefix; @@ -84,6 +89,7 @@ void tearDown() { } private void cleanTickets() { + ticketRegisterReminderRepository.deleteAll(); ticketDeliveryAttemptRepository.deleteAll(); classTicketRepository.deleteAll(); fundingOrderRepository.deleteAll(); @@ -298,8 +304,84 @@ void forbiddenAndInvalidCsv() throws Exception { .andExpect(jsonPath("$.error.code").value("FUNDING_400_CSV")); } + @Test + @DisplayName("등록기한 알림: 전달 완료·미등록 이용권만 D30/D7 구간별 대상이 되고, 해당 단계 SUCCESS 기록 후 빠진다") + void registerReminders() throws Exception { + DateTimeFormatter format = DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"); + LocalDateTime now = LocalDateTime.now(ZoneId.of("Asia/Seoul")); + // 등록기한 = 결제 확정일 + 1년 → 20일 남음(D30), 3일 남음(D7), 60일 남음(대상 아님) + String in20Days = now.minusYears(1).plusDays(20).format(format); + String in3Days = now.minusYears(1).plusDays(3).format(format); + String in60Days = now.minusYears(1).plusDays(60).format(format); + upload(HEADER + + "TUMBLBUG,camp,ORD-30,SEAT_15,1,후원30,a@test.com,010-1,PAID_CONFIRMED," + in20Days + "\n" + + "TUMBLBUG,camp,ORD-7,SEAT_15,1,후원7,b@test.com,,PAID_CONFIRMED," + in3Days + "\n" + + "TUMBLBUG,camp,ORD-FAR,SEAT_15,1,후원F,,,PAID_CONFIRMED," + in60Days + "\n" + + "TUMBLBUG,camp,ORD-UND,SEAT_15,1,후원U,,,PAID_CONFIRMED," + in20Days + "\n"); + Map ticketByOrder = new java.util.HashMap<>(); + for (JsonNode issued : postJson("/api/v1/admin/funding/tickets/issue", null, adminToken()).get("issued")) { + ticketByOrder.put(issued.get("orderId").asText().substring(prefix.length()), issued.get("ticketId").asLong()); + } + for (String order : java.util.List.of("ORD-30", "ORD-7", "ORD-FAR")) { // ORD-UND 는 미전달 + postJson("/api/v1/admin/funding/tickets/" + ticketByOrder.get(order) + "/deliveries", + Map.of("channel", "EMAIL", "status", "SUCCESS"), adminToken()); + } + long d30Ticket = ticketByOrder.get("ORD-30"); + long d7Ticket = ticketByOrder.get("ORD-7"); + + JsonNode d30 = getJson("/api/v1/admin/funding/register-reminders?stage=D30"); + assertThat(d30.get("count").asInt()).isEqualTo(1); + assertThat(d30.get("items").get(0).get("ticketId").asLong()).isEqualTo(d30Ticket); + assertThat(d30.get("items").get(0).get("daysLeft").asLong()).isEqualTo(20); + assertThat(d30.get("items").get(0).get("supporterEmail").asText()).isEqualTo("a@test.com"); + JsonNode d7 = getJson("/api/v1/admin/funding/register-reminders?stage=D7"); + assertThat(d7.get("count").asInt()).isEqualTo(1); + assertThat(d7.get("items").get(0).get("ticketId").asLong()).isEqualTo(d7Ticket); + assertThat(d7.get("items").get(0).get("daysLeft").asLong()).isEqualTo(3); + + JsonNode summary = getJson("/api/v1/admin/funding/summary"); + assertThat(summary.get("registerReminderD30").asLong()).isEqualTo(1); + assertThat(summary.get("registerReminderD7").asLong()).isEqualTo(1); + + // 실패 기록은 대상에 남고, 성공 기록하면 그 단계에서만 빠진다 + JsonNode failed = postJson("/api/v1/admin/funding/tickets/" + d30Ticket + "/register-reminders", + Map.of("stage", "D30", "channel", "EMAIL", "status", "FAILED", "failureReason", "반송"), adminToken()); + assertThat(failed.get("failureReason").asText()).isEqualTo("반송"); + assertThat(getJson("/api/v1/admin/funding/register-reminders?stage=D30").get("count").asInt()).isEqualTo(1); + postJson("/api/v1/admin/funding/tickets/" + d30Ticket + "/register-reminders", + Map.of("stage", "D30", "channel", "SMS", "status", "SUCCESS"), adminToken()); + assertThat(getJson("/api/v1/admin/funding/register-reminders?stage=D30").get("count").asInt()).isZero(); + postJson("/api/v1/admin/funding/tickets/" + d7Ticket + "/register-reminders", + Map.of("stage", "D30", "channel", "EMAIL", "status", "SUCCESS"), adminToken()); + assertThat(getJson("/api/v1/admin/funding/register-reminders?stage=D7").get("count").asInt()).isEqualTo(1); + assertThat(ticketRegisterReminderRepository.findByTicketIdOrderByAttemptedAtDesc(d30Ticket)).hasSize(2); + + // 미전달 이용권은 기록 불가, 단계 누락은 400 + mockMvc.perform(post("/api/v1/admin/funding/tickets/" + ticketByOrder.get("ORD-UND") + "/register-reminders") + .header("Authorization", "Bearer " + adminToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("stage", "D30", "channel", "EMAIL", "status", "SUCCESS")))) + .andExpect(status().isConflict()) + .andExpect(jsonPath("$.error.code").value("TICKET_409_STATE")); + mockMvc.perform(post("/api/v1/admin/funding/tickets/" + d7Ticket + "/register-reminders") + .header("Authorization", "Bearer " + adminToken()) + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(Map.of("channel", "EMAIL", "status", "SUCCESS")))) + .andExpect(status().isBadRequest()); + mockMvc.perform(get("/api/v1/admin/funding/register-reminders").param("stage", "D30") + .header("Authorization", "Bearer " + userToken())) + .andExpect(status().isForbidden()); + } + // ───────────── helpers ───────────── + private JsonNode getJson(String url) throws Exception { + MvcResult result = mockMvc.perform(get(url).header("Authorization", "Bearer " + adminToken())) + .andExpect(status().isOk()) + .andReturn(); + return data(result); + } + /** order_id 에 테스트별 prefix 를 붙여 같은 DB 를 쓰는 다른 테스트와 겹치지 않게 한다 */ private JsonNode upload(String csv) throws Exception { String prefixed = csv.replace(",ORD-", "," + prefix + "ORD-");