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
52 changes: 52 additions & 0 deletions docs/api-specs/class-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -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": "[email protected]", "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. 에러 코드 모음
Expand Down
14 changes: 14 additions & 0 deletions docs/erd/class.puml
Original file line number Diff line number Diff line change
Expand Up @@ -108,6 +108,19 @@ entity "class_tickets" as class_tickets {
updated_at : TIMESTAMP
}

entity "ticket_register_reminders" as ticket_register_reminders {
* id : BIGINT <<PK>>
--
ticket_id : BIGINT <<FK, NEW #589>>
stage : VARCHAR(10) <<D30 / D7>>
channel : VARCHAR(20)
status : VARCHAR(20)
failure_reason : VARCHAR(255) <<nullable>>
attempted_at : TIMESTAMP
created_at : TIMESTAMP
updated_at : TIMESTAMP
}

entity "ticket_delivery_attempts" as ticket_delivery_attempts {
* id : BIGINT <<PK>>
--
Expand Down Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Expand Down Expand Up @@ -104,7 +108,29 @@ public ApiResponse<FundingOrderPageResponse> 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<RegisterReminderTargetResponse> 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<RegisterReminderResponse> recordRegisterReminder(
@PathVariable Long ticketId,
@Valid @RequestBody RegisterReminderRequest request
) {
return ApiResponse.onSuccess(adminFundingService.recordRegisterReminder(ticketId, request));
}

@Operation(summary = "펀딩·이용권 현황 요약", description = "결제 확정/발급 가능/보류/발급/미전달/전달/등록/환불/취소 카운트, 등록기한 알림 대상 수")
@GetMapping("/summary")
public ApiResponse<FundingSummaryResponse> getSummary() {
return ApiResponse.onSuccess(adminFundingService.getSummary());
Expand Down
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
Expand Up @@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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
) {}
Original file line number Diff line number Diff line change
@@ -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<Item> 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
) {}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand Down Expand Up @@ -69,6 +76,7 @@ public class AdminFundingService {
private final FundingOrderRepository fundingOrderRepository;
private final ClassTicketRepository classTicketRepository;
private final TicketDeliveryAttemptRepository ticketDeliveryAttemptRepository;
private final TicketRegisterReminderRepository ticketRegisterReminderRepository;

// ───────────── 1. 명단 가져오기 ─────────────

Expand Down Expand Up @@ -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<RegisterReminderTargetResponse.Item> 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,
Expand All @@ -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),
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
}
}
Loading
Loading