diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8d1f14d..631f8a1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,9 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(git push)", - "Bash(git pull:*)" + "Bash(git pull:*)", + "Bash(dir:*)", + "Bash(./gradlew participation-service:compileJava:*)" ], "deny": [], "ask": [] diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock index 837e5b9..4a61e20 100644 Binary files a/.gradle/8.10/checksums/checksums.lock and b/.gradle/8.10/checksums/checksums.lock differ diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin index 04c6d00..d7041a7 100644 Binary files a/.gradle/8.10/checksums/md5-checksums.bin and b/.gradle/8.10/checksums/md5-checksums.bin differ diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin index 19a5410..ec94342 100644 Binary files a/.gradle/8.10/checksums/sha1-checksums.bin and b/.gradle/8.10/checksums/sha1-checksums.bin differ diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin index 2177cdd..d409ba4 100644 Binary files a/.gradle/8.10/executionHistory/executionHistory.bin and b/.gradle/8.10/executionHistory/executionHistory.bin differ diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock index 0ce4c96..ce0d272 100644 Binary files a/.gradle/8.10/executionHistory/executionHistory.lock and b/.gradle/8.10/executionHistory/executionHistory.lock differ diff --git a/.gradle/8.10/fileHashes/fileHashes.bin b/.gradle/8.10/fileHashes/fileHashes.bin index 8088fbb..e20a4f0 100644 Binary files a/.gradle/8.10/fileHashes/fileHashes.bin and b/.gradle/8.10/fileHashes/fileHashes.bin differ diff --git a/.gradle/8.10/fileHashes/fileHashes.lock b/.gradle/8.10/fileHashes/fileHashes.lock index 340e0dd..af5d5a0 100644 Binary files a/.gradle/8.10/fileHashes/fileHashes.lock and b/.gradle/8.10/fileHashes/fileHashes.lock differ diff --git a/.gradle/8.10/fileHashes/resourceHashesCache.bin b/.gradle/8.10/fileHashes/resourceHashesCache.bin index 3d21896..aaa316e 100644 Binary files a/.gradle/8.10/fileHashes/resourceHashesCache.bin and b/.gradle/8.10/fileHashes/resourceHashesCache.bin differ diff --git a/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 0350ff2..c1f212d 100644 Binary files a/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index 4ed6f06..6bb372f 100644 Binary files a/.gradle/buildOutputCleanup/outputFiles.bin and b/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe index ac4beb4..f13d4e3 100644 Binary files a/.gradle/file-system.probe and b/.gradle/file-system.probe differ diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml new file mode 100644 index 0000000..624b419 --- /dev/null +++ b/.run/ParticipationServiceApplication.run.xml @@ -0,0 +1,71 @@ + + + + diff --git a/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java new file mode 100644 index 0000000..e82842f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java @@ -0,0 +1,25 @@ +package com.kt.event.participation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * Participation Service Application + * - 이벤트 참여 및 당첨자 관리 서비스 + * - Port: 8084 + * - Database: PostgreSQL (participation_db) + * - Cache: Redis + * - Messaging: Kafka (participant-events topic) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@SpringBootApplication +@EnableJpaAuditing +public class ParticipationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ParticipationServiceApplication.class, args); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java new file mode 100644 index 0000000..e1e906f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java @@ -0,0 +1,38 @@ +package com.kt.event.participation.application.dto; + +import java.time.LocalDateTime; + +/** + * API 오류 응답 DTO + */ +public class ErrorResponse { + + private boolean success; + private String errorCode; + private String message; + private LocalDateTime timestamp; + + public ErrorResponse(String errorCode, String message) { + this.success = false; + this.errorCode = errorCode; + this.message = message; + this.timestamp = LocalDateTime.now(); + } + + // Getters + public boolean isSuccess() { + return success; + } + + public String getErrorCode() { + return errorCode; + } + + public String getMessage() { + return message; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java new file mode 100644 index 0000000..b37245a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java @@ -0,0 +1,76 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 참여자 정보 DTO + * 참여자 목록 조회 시 사용되는 기본 참여자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantDto { + + /** + * 참여자 ID + */ + private String participantId; + + /** + * 참여자 이름 + */ + private String name; + + /** + * 마스킹된 전화번호 + * 예: 010-****-5678 + */ + private String maskedPhoneNumber; + + /** + * 참여자 이메일 + */ + private String email; + + /** + * 유입 경로 + * 예: ONLINE, STORE_VISIT + */ + private String entryPath; + + /** + * 참여 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime participatedAt; + + /** + * 당첨 여부 + */ + private Boolean isWinner; + + /** + * 당첨 일시 + * 당첨되지 않은 경우 null + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime wonAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 수 + */ + private Integer bonusEntries; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java new file mode 100644 index 0000000..1833db9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java @@ -0,0 +1,54 @@ +package com.kt.event.participation.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 참여자 목록 응답 DTO + * 페이징된 참여자 목록과 페이지 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantListResponse { + + /** + * 참여자 목록 + */ + private List participants; + + /** + * 전체 참여자 수 + */ + private Integer totalElements; + + /** + * 전체 페이지 수 + */ + private Integer totalPages; + + /** + * 현재 페이지 번호 (0부터 시작) + */ + private Integer currentPage; + + /** + * 페이지 크기 + */ + private Integer pageSize; + + /** + * 다음 페이지 존재 여부 + */ + private Boolean hasNext; + + /** + * 이전 페이지 존재 여부 + */ + private Boolean hasPrevious; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java new file mode 100644 index 0000000..0a106cb --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java @@ -0,0 +1,67 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 이벤트 참여 요청 DTO + * 고객이 이벤트에 참여할 때 전달하는 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationRequest { + + /** + * 참여자 이름 + * 필수 입력, 2-50자 제한 + */ + @NotBlank(message = "이름은 필수 입력입니다") + @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다") + private String name; + + /** + * 참여자 전화번호 + * 필수 입력, 하이픈 포함 형식 (예: 010-1234-5678) + */ + @NotBlank(message = "전화번호는 필수 입력입니다") + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다 (예: 010-1234-5678)") + private String phoneNumber; + + /** + * 참여자 이메일 + * 선택 입력, 이메일 형식 검증 + */ + @Email(message = "이메일 형식이 올바르지 않습니다") + private String email; + + /** + * 마케팅 정보 수신 동의 + * 선택, 기본값 false + */ + @Builder.Default + private Boolean agreeMarketing = false; + + /** + * 개인정보 수집 및 이용 동의 + * 필수 동의 + */ + @NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다") + private Boolean agreePrivacy; + + /** + * 매장 방문 여부 + * 매장 방문 시 보너스 응모권 추가 제공 + * 기본값 false + */ + @Builder.Default + private Boolean storeVisited = false; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java new file mode 100644 index 0000000..69b5125 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java @@ -0,0 +1,64 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 참여 응답 DTO + * 참여 완료 후 반환되는 참여자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationResponse { + + /** + * 참여자 ID + * 고유 식별자 (예: prt_20250123_001) + */ + private String participantId; + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 참여자 이름 + */ + private String name; + + /** + * 참여자 전화번호 + */ + private String phoneNumber; + + /** + * 참여자 이메일 + */ + private String email; + + /** + * 참여 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime participatedAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 수 + * 기본 1회, 매장 방문 시 +1 추가 + */ + private Integer bonusEntries; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java new file mode 100644 index 0000000..b9218db --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java @@ -0,0 +1,35 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 당첨자 추첨 요청 DTO + * 당첨자 추첨 시 필요한 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDrawRequest { + + /** + * 당첨자 수 + * 필수 입력, 최소 1명 이상 + */ + @NotNull(message = "당첨자 수는 필수 입력입니다") + @Min(value = 1, message = "당첨자 수는 최소 1명 이상이어야 합니다") + private Integer winnerCount; + + /** + * 매장 방문 보너스 적용 여부 + * 매장 방문자에게 가중치 부여 + * 기본값 true + */ + @Builder.Default + private Boolean visitBonusApplied = true; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java new file mode 100644 index 0000000..50de1fe --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java @@ -0,0 +1,59 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 당첨자 추첨 응답 DTO + * 추첨 완료 후 반환되는 당첨자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDrawResponse { + + /** + * 당첨자 목록 + */ + private List winners; + + /** + * 추첨 로그 ID + * 추첨 이력 추적용 고유 식별자 + */ + private String drawLogId; + + /** + * 응답 메시지 + */ + private String message; + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 전체 참여자 수 + */ + private Integer totalParticipants; + + /** + * 당첨자 수 + */ + private Integer winnerCount; + + /** + * 추첨 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime drawnAt; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java new file mode 100644 index 0000000..cf05044 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java @@ -0,0 +1,70 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 당첨자 정보 DTO + * 당첨자 목록 및 추첨 결과에 사용되는 당첨자 기본 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDto { + + /** + * 참여자 ID + */ + private String participantId; + + /** + * 당첨자 이름 + */ + private String name; + + /** + * 마스킹된 전화번호 + * 예: 010-****-5678 + */ + private String maskedPhoneNumber; + + /** + * 당첨자 이메일 + */ + private String email; + + /** + * 응모 번호 + * 추첨 시 사용된 번호 + */ + private Integer applicationNumber; + + /** + * 당첨 순위 + * 1부터 시작 + */ + private Integer rank; + + /** + * 당첨 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime wonAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 적용 여부 + */ + private Boolean bonusApplied; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java b/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java new file mode 100644 index 0000000..853ec74 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java @@ -0,0 +1,117 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.domain.participant.Participant; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +/** + * 당첨자 추첨 알고리즘 + * - Fisher-Yates Shuffle 알고리즘 사용 + * - 암호학적 난수 생성 (Crypto.randomBytes 대신 SecureRandom 사용) + * - 매장 방문 가산점 적용 + * + * 시간 복잡도: O(n log n) + * 공간 복잡도: O(n) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Component +public class LotteryAlgorithm { + + private static final SecureRandom secureRandom = new SecureRandom(); + + /** + * 당첨자 추첨 실행 + * + * @param participants 전체 참여자 목록 + * @param winnerCount 당첨 인원 + * @param visitBonusApplied 매장 방문 가산점 적용 여부 + * @param visitBonusWeight 매장 방문 가중치 (기본 2.0) + * @return 당첨자 목록 + */ + public List executeLottery( + List participants, + int winnerCount, + boolean visitBonusApplied, + double visitBonusWeight + ) { + log.info("Starting lottery execution - Total participants: {}, Winner count: {}, Visit bonus: {}", + participants.size(), winnerCount, visitBonusApplied); + + // Step 1: 가산점 적용 (매장 방문 시) + List weightedParticipants = applyWeights(participants, visitBonusApplied, visitBonusWeight); + + // Step 2: Fisher-Yates Shuffle + List shuffled = fisherYatesShuffle(weightedParticipants); + + // Step 3: 상위 N명 선정 + List winners = shuffled.subList(0, Math.min(winnerCount, shuffled.size())); + + log.info("Lottery execution completed - Winners selected: {}", winners.size()); + return winners; + } + + /** + * Step 1: 가산점 적용 + * - 매장 방문 고객은 가중치만큼 참여자 목록에 중복 추가 + * + * @param participants 참여자 목록 + * @param visitBonusApplied 가산점 적용 여부 + * @param visitBonusWeight 가중치 + * @return 가중치 적용된 참여자 목록 + */ + private List applyWeights(List participants, boolean visitBonusApplied, double visitBonusWeight) { + if (!visitBonusApplied) { + return new ArrayList<>(participants); + } + + List weighted = new ArrayList<>(); + for (Participant p : participants) { + // 매장 방문 고객은 가중치만큼 추가 + if (p.getStoreVisited() != null && p.getStoreVisited()) { + int bonusEntries = (int) visitBonusWeight; + for (int i = 0; i < bonusEntries; i++) { + weighted.add(p); + } + } else { + // 비방문 고객은 1회 추가 + weighted.add(p); + } + } + + log.debug("Applied visit bonus - Original size: {}, Weighted size: {}", participants.size(), weighted.size()); + return weighted; + } + + /** + * Step 2: Fisher-Yates Shuffle 알고리즘 + * - 암호학적 난수 생성 (SecureRandom) + * - 시간 복잡도: O(n) + * + * @param participants 참여자 목록 + * @return 셔플된 참여자 목록 + */ + private List fisherYatesShuffle(List participants) { + List shuffled = new ArrayList<>(participants); + int n = shuffled.size(); + + // Fisher-Yates shuffle + for (int i = n - 1; i > 0; i--) { + // 암호학적으로 안전한 난수 생성 (0 ~ i 범위) + int j = secureRandom.nextInt(i + 1); + + // Swap + Participant temp = shuffled.get(i); + shuffled.set(i, shuffled.get(j)); + shuffled.set(j, temp); + } + + return shuffled; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java new file mode 100644 index 0000000..6e07e3c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java @@ -0,0 +1,403 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.application.dto.*; +import com.kt.event.participation.common.exception.*; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.infrastructure.kafka.KafkaProducerService; +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import com.kt.event.participation.infrastructure.redis.RedisCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +/** + * 이벤트 참여 서비스 + * - 참여자 등록 및 조회 + * - 중복 참여 검증 (Redis Cache + DB) + * - Kafka 이벤트 발행 + * + * 비즈니스 원칙: + * - 중복 참여 방지: 이벤트ID + 전화번호 조합으로 중복 검증 + * - 응모 번호 생성: EVT-{timestamp}-{random} 형식 + * - 캐시 우선: Redis 캐시로 성능 최적화 (Best Effort) + * - 비동기 이벤트: Kafka로 참여 이벤트 발행 (Best Effort) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ParticipationService { + + private final ParticipantRepository participantRepository; + private final RedisCacheService redisCacheService; + private final KafkaProducerService kafkaProducerService; + + @Value("${app.participation.draw-days-after-end:3}") + private int drawDaysAfterEnd; + + @Value("${app.participation.visit-bonus-weight:2.0}") + private double visitBonusWeight; + + @Value("${app.cache.duplicate-check-ttl:604800}") + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") + private long participantListTtl; + + private static final Random random = new Random(); + + /** + * 이벤트 참여자 등록 + * + * Flow: + * 1. 중복 참여 검증 (Redis Cache → DB) + * 2. 응모 번호 생성 (EVT-{timestamp}-{random}) + * 3. 참여자 저장 + * 4. 중복 체크 캐시 저장 (TTL: 7일) + * 5. Kafka 이벤트 발행 (Best Effort) + * 6. 응답 반환 (추첨일: 이벤트 종료 + 3일) + * + * @param eventId 이벤트 ID + * @param request 참여 요청 정보 + * @return 참여 응답 (응모 번호, 참여 일시, 추첨일 등) + * @throws DuplicateParticipationException 중복 참여 시 + */ + @Transactional + public ParticipationResponse registerParticipant(String eventId, ParticipationRequest request) { + log.info("Registering participant - eventId: {}, name: {}, phone: {}", + eventId, request.getName(), maskPhoneNumber(request.getPhoneNumber())); + + // Step 1: 중복 참여 검증 (Cache → DB) + validateDuplicateParticipation(eventId, request.getPhoneNumber()); + + // Step 2: 응모 번호 생성 + String applicationNumber = generateApplicationNumber(); + + // Step 3: 참여자 엔티티 생성 및 저장 + Participant participant = Participant.builder() + .eventId(eventId) + .name(request.getName()) + .phoneNumber(request.getPhoneNumber()) + .email(request.getEmail()) + .entryPath(determineEntryPath(request)) + .applicationNumber(applicationNumber) + .participatedAt(LocalDateTime.now()) + .storeVisited(request.getStoreVisited() != null ? request.getStoreVisited() : false) + .agreeMarketing(request.getAgreeMarketing() != null ? request.getAgreeMarketing() : false) + .agreePrivacy(request.getAgreePrivacy()) + .isWinner(false) + .bonusEntries(1) + .build(); + + // 매장 방문 보너스 응모권 적용 + participant.applyVisitBonus(visitBonusWeight); + + Participant saved = participantRepository.save(participant); + + log.info("Participant registered successfully - participantId: {}, applicationNumber: {}", + saved.getParticipantId(), saved.getApplicationNumber()); + + // Step 4: 중복 체크 캐시 저장 (Best Effort) + try { + redisCacheService.cacheDuplicateCheck( + Long.parseLong(eventId), + request.getPhoneNumber(), + duplicateCheckTtl + ); + } catch (Exception e) { + log.warn("Failed to cache duplicate check - eventId: {}, phone: {}", + eventId, maskPhoneNumber(request.getPhoneNumber()), e); + } + + // Step 5: Kafka 이벤트 발행 (Best Effort) + publishParticipantRegisteredEvent(saved); + + // Step 6: 응답 반환 + return ParticipationResponse.builder() + .participantId(String.valueOf(saved.getParticipantId())) + .eventId(saved.getEventId()) + .name(saved.getName()) + .phoneNumber(saved.getPhoneNumber()) + .email(saved.getEmail()) + .participatedAt(saved.getParticipatedAt()) + .storeVisited(saved.getStoreVisited()) + .bonusEntries(saved.getBonusEntries()) + .build(); + } + + /** + * 이벤트 참여자 목록 조회 (필터링 + 페이징) + * + * Flow: + * 1. 캐시 조회 (Cache Hit → 즉시 반환) + * 2. Cache Miss → DB 조회 + * 3. 전화번호 마스킹 + * 4. 캐시 저장 (TTL: 10분) + * 5. 응답 반환 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 필터 (nullable) + * @param isWinner 당첨 여부 필터 (nullable) + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + public ParticipantListResponse getParticipantList( + String eventId, + String entryPath, + Boolean isWinner, + Pageable pageable) { + + log.info("Fetching participant list - eventId: {}, entryPath: {}, isWinner: {}, page: {}", + eventId, entryPath, isWinner, pageable.getPageNumber()); + + // Step 1: 캐시 키 생성 + String cacheKey = buildCacheKey(eventId, entryPath, isWinner, pageable); + + // Step 2: 캐시 조회 (Cache Hit → 즉시 반환) + try { + var cachedData = redisCacheService.getParticipantList(cacheKey); + if (cachedData.isPresent()) { + log.debug("Cache hit - cacheKey: {}", cacheKey); + return (ParticipantListResponse) cachedData.get(); + } + } catch (Exception e) { + log.warn("Failed to retrieve from cache - cacheKey: {}", cacheKey, e); + } + + // Step 3: Cache Miss → DB 조회 + Page participantPage = participantRepository.findParticipants( + eventId, entryPath, isWinner, pageable); + + // Step 4: DTO 변환 및 전화번호 마스킹 + List participants = participantPage.getContent().stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + + ParticipantListResponse response = ParticipantListResponse.builder() + .participants(participants) + .totalElements((int) participantPage.getTotalElements()) + .totalPages(participantPage.getTotalPages()) + .currentPage(participantPage.getNumber()) + .pageSize(participantPage.getSize()) + .hasNext(participantPage.hasNext()) + .hasPrevious(participantPage.hasPrevious()) + .build(); + + // Step 5: 캐시 저장 (Best Effort) + try { + redisCacheService.cacheParticipantList(cacheKey, response, participantListTtl); + } catch (Exception e) { + log.warn("Failed to cache participant list - cacheKey: {}", cacheKey, e); + } + + log.info("Participant list fetched successfully - eventId: {}, totalElements: {}", + eventId, response.getTotalElements()); + + return response; + } + + /** + * 참여자 검색 (이름 또는 전화번호) + * + * @param eventId 이벤트 ID + * @param keyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 검색된 참여자 목록 (페이징) + */ + public ParticipantListResponse searchParticipants( + String eventId, + String keyword, + Pageable pageable) { + + log.info("Searching participants - eventId: {}, keyword: {}, page: {}", + eventId, keyword, pageable.getPageNumber()); + + Page participantPage = participantRepository.searchParticipants( + eventId, keyword, pageable); + + List participants = participantPage.getContent().stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + + ParticipantListResponse response = ParticipantListResponse.builder() + .participants(participants) + .totalElements((int) participantPage.getTotalElements()) + .totalPages(participantPage.getTotalPages()) + .currentPage(participantPage.getNumber()) + .pageSize(participantPage.getSize()) + .hasNext(participantPage.hasNext()) + .hasPrevious(participantPage.hasPrevious()) + .build(); + + log.info("Participants searched successfully - eventId: {}, keyword: {}, totalElements: {}", + eventId, keyword, response.getTotalElements()); + + return response; + } + + // === Private Helper Methods === + + /** + * 중복 참여 검증 + * + * Flow: + * 1. Redis Cache 조회 (Cache Hit → 중복 예외) + * 2. Cache Miss → DB 조회 + * 3. DB에 존재 → 중복 예외 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @throws DuplicateParticipationException 중복 참여 시 + */ + private void validateDuplicateParticipation(String eventId, String phoneNumber) { + // Step 1: Cache 조회 + try { + Boolean isDuplicate = redisCacheService.checkDuplicateParticipation( + Long.parseLong(eventId), phoneNumber); + if (Boolean.TRUE.equals(isDuplicate)) { + log.warn("Duplicate participation detected from cache - eventId: {}, phone: {}", + eventId, maskPhoneNumber(phoneNumber)); + throw new DuplicateParticipationException( + String.format("이미 참여한 이벤트입니다. (이벤트: %s, 전화번호: %s)", eventId, maskPhoneNumber(phoneNumber))); + } + } catch (DuplicateParticipationException e) { + throw e; + } catch (Exception e) { + log.warn("Failed to check duplicate from cache - eventId: {}, phone: {}", + eventId, maskPhoneNumber(phoneNumber), e); + } + + // Step 2: DB 조회 + participantRepository.findByEventIdAndPhoneNumber(eventId, phoneNumber) + .ifPresent(participant -> { + log.warn("Duplicate participation detected from DB - eventId: {}, phone: {}, participantId: {}", + eventId, maskPhoneNumber(phoneNumber), participant.getParticipantId()); + throw new DuplicateParticipationException( + String.format("이미 참여한 이벤트입니다. (이벤트: %s, 전화번호: %s)", eventId, maskPhoneNumber(phoneNumber))); + }); + } + + /** + * 응모 번호 생성 + * + * 형식: EVT-{timestamp}-{random} + * 예: EVT-20250123143022-A7B9 + * + * @return 응모 번호 + */ + private String generateApplicationNumber() { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String randomSuffix = String.format("%04X", random.nextInt(0x10000)); + return String.format("EVT-%s-%s", timestamp, randomSuffix); + } + + /** + * 참여 경로 결정 + * + * @param request 참여 요청 + * @return 참여 경로 + */ + private String determineEntryPath(ParticipationRequest request) { + // TODO: 실제로는 요청 헤더나 파라미터에서 참여 경로를 추출 + // 현재는 매장 방문 여부로 간단히 결정 + if (Boolean.TRUE.equals(request.getStoreVisited())) { + return "STORE_VISIT"; + } + return "WEB"; + } + + /** + * Participant 엔티티를 DTO로 변환 + * + * @param participant 참여자 엔티티 + * @return 참여자 DTO (전화번호 마스킹 처리됨) + */ + private ParticipantDto convertToDto(Participant participant) { + return ParticipantDto.builder() + .participantId(String.valueOf(participant.getParticipantId())) + .name(participant.getName()) + .maskedPhoneNumber(participant.getMaskedPhoneNumber()) + .email(participant.getEmail()) + .entryPath(participant.getEntryPath()) + .participatedAt(participant.getParticipatedAt()) + .isWinner(participant.getIsWinner()) + .wonAt(participant.getWonAt()) + .storeVisited(participant.getStoreVisited()) + .bonusEntries(participant.getBonusEntries()) + .build(); + } + + /** + * 전화번호 마스킹 + * + * @param phoneNumber 전화번호 + * @return 마스킹된 전화번호 (예: 010-****-5678) + */ + private String maskPhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.length() < 13) { + return phoneNumber; + } + return phoneNumber.substring(0, 4) + "****" + phoneNumber.substring(8); + } + + /** + * 캐시 키 생성 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 + * @param isWinner 당첨 여부 + * @param pageable 페이징 정보 + * @return 캐시 키 + */ + private String buildCacheKey(String eventId, String entryPath, Boolean isWinner, Pageable pageable) { + return String.format("%s:entryPath=%s:isWinner=%s:page=%d:size=%d", + eventId, + entryPath != null ? entryPath : "all", + isWinner != null ? isWinner : "all", + pageable.getPageNumber(), + pageable.getPageSize() + ); + } + + /** + * Kafka 참여자 등록 이벤트 발행 + * + * @param participant 참여자 엔티티 + */ + private void publishParticipantRegisteredEvent(Participant participant) { + try { + ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() + .participantId(participant.getParticipantId()) + .eventId(Long.parseLong(participant.getEventId())) + .phoneNumber(participant.getPhoneNumber()) + .entryPath(participant.getEntryPath()) + .registeredAt(participant.getParticipatedAt()) + .build(); + + kafkaProducerService.publishParticipantRegistered(event); + + log.info("Participant registered event published - participantId: {}, eventId: {}", + participant.getParticipantId(), participant.getEventId()); + + } catch (Exception e) { + log.error("Failed to publish participant registered event - participantId: {}, eventId: {}", + participant.getParticipantId(), participant.getEventId(), e); + // 이벤트 발행 실패는 참여 등록 실패로 이어지지 않음 (Best Effort) + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java new file mode 100644 index 0000000..d4698c6 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java @@ -0,0 +1,312 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.application.dto.WinnerDrawRequest; +import com.kt.event.participation.application.dto.WinnerDrawResponse; +import com.kt.event.participation.application.dto.WinnerDto; +import com.kt.event.participation.common.exception.AlreadyDrawnException; +import com.kt.event.participation.common.exception.InsufficientParticipantsException; +import com.kt.event.participation.domain.draw.DrawLog; +import com.kt.event.participation.domain.draw.DrawLogRepository; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 당첨자 추첨 서비스 + * - 당첨자 추첨 실행 + * - 추첨 이력 관리 + * - 당첨자 조회 + * + * 비즈니스 원칙: + * - 중복 추첨 방지: 이벤트별 1회만 추첨 가능 + * - 추첨 알고리즘: Fisher-Yates Shuffle (공정성 보장) + * - 매장 방문 가산점: 설정에 따라 가중치 부여 + * - 트랜잭션 보장: 당첨자 업데이트와 추첨 로그 저장은 원자성 보장 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WinnerDrawService { + + private final ParticipantRepository participantRepository; + private final DrawLogRepository drawLogRepository; + private final LotteryAlgorithm lotteryAlgorithm; + + @Value("${app.participation.visit-bonus-weight:2.0}") + private double visitBonusWeight; + + /** + * 당첨자 추첨 실행 + * + * Flow: + * 1. 중복 추첨 검증 (이벤트별 1회만 가능) + * 2. 미당첨 참여자 조회 + * 3. 참여자 수 검증 (당첨 인원보다 적으면 예외) + * 4. 추첨 알고리즘 실행 (Fisher-Yates Shuffle) + * 5. 당첨자 업데이트 (트랜잭션) + * 6. 추첨 로그 저장 + * 7. 응답 반환 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 정보 + * @return 추첨 결과 (당첨자 목록, 추첨 로그 ID 등) + * @throws AlreadyDrawnException 이미 추첨이 완료된 경우 + * @throws InsufficientParticipantsException 참여자가 부족한 경우 + */ + @Transactional + public WinnerDrawResponse drawWinners(String eventId, WinnerDrawRequest request) { + log.info("Starting winner draw - eventId: {}, winnerCount: {}, visitBonusApplied: {}", + eventId, request.getWinnerCount(), request.getVisitBonusApplied()); + + // Step 1: 중복 추첨 검증 + validateDuplicateDraw(eventId); + + // Step 2: 미당첨 참여자 조회 + List participants = participantRepository + .findByEventIdAndIsWinnerOrderByParticipatedAtAsc(eventId, false); + + log.info("Eligible participants for draw - eventId: {}, count: {}", eventId, participants.size()); + + // Step 3: 참여자 수 검증 + if (participants.size() < request.getWinnerCount()) { + String errorMsg = String.format( + "참여자가 부족합니다. (필요: %d명, 현재: %d명)", + request.getWinnerCount(), participants.size()); + log.error("Insufficient participants - eventId: {}, {}", eventId, errorMsg); + throw new InsufficientParticipantsException(errorMsg); + } + + // Step 4: 추첨 알고리즘 실행 + List winners; + try { + winners = lotteryAlgorithm.executeLottery( + participants, + request.getWinnerCount(), + request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true, + visitBonusWeight + ); + + log.info("Lottery algorithm executed successfully - eventId: {}, winnersCount: {}", + eventId, winners.size()); + + } catch (Exception e) { + log.error("Failed to execute lottery algorithm - eventId: {}", eventId, e); + saveFailedDrawLog(eventId, request, participants.size(), e.getMessage()); + throw new RuntimeException("추첨 실행 중 오류가 발생했습니다: " + e.getMessage(), e); + } + + // Step 5: 당첨자 업데이트 (트랜잭션) + LocalDateTime drawnAt = LocalDateTime.now(); + winners.forEach(winner -> { + winner.markAsWinner(); + participantRepository.save(winner); + }); + + log.info("Winners marked and saved - eventId: {}, count: {}", eventId, winners.size()); + + // Step 6: 추첨 로그 저장 + DrawLog drawLog = saveSuccessDrawLog(eventId, request, participants.size(), winners.size(), drawnAt); + + // Step 7: 응답 반환 + List winnerDtos = winners.stream() + .map(this::convertToWinnerDto) + .collect(Collectors.toList()); + + WinnerDrawResponse response = WinnerDrawResponse.builder() + .winners(winnerDtos) + .drawLogId(String.valueOf(drawLog.getDrawLogId())) + .message(String.format("추첨이 완료되었습니다. 총 %d명의 당첨자가 선정되었습니다.", winners.size())) + .eventId(eventId) + .totalParticipants(participants.size()) + .winnerCount(winners.size()) + .drawnAt(drawnAt) + .build(); + + log.info("Winner draw completed successfully - eventId: {}, drawLogId: {}, winnersCount: {}", + eventId, drawLog.getDrawLogId(), winners.size()); + + return response; + } + + /** + * 당첨자 목록 조회 + * + * @param eventId 이벤트 ID + * @return 당첨자 목록 (전화번호 마스킹 처리됨) + */ + public List getWinners(String eventId) { + log.info("Fetching winners - eventId: {}", eventId); + + List winners = participantRepository + .findByEventIdAndIsWinnerOrderByWonAtDesc(eventId, true); + + List winnerDtos = winners.stream() + .map(this::convertToWinnerDto) + .collect(Collectors.toList()); + + log.info("Winners fetched successfully - eventId: {}, count: {}", eventId, winnerDtos.size()); + + return winnerDtos; + } + + // === Private Helper Methods === + + /** + * 중복 추첨 검증 + * + * 이벤트별 1회만 추첨 가능 + * 이미 추첨이 완료된 경우 예외 발생 + * + * @param eventId 이벤트 ID + * @throws AlreadyDrawnException 이미 추첨이 완료된 경우 + */ + private void validateDuplicateDraw(String eventId) { + drawLogRepository.findByEventId(eventId) + .ifPresent(drawLog -> { + if (Boolean.TRUE.equals(drawLog.getIsSuccess())) { + String errorMsg = String.format( + "이미 추첨이 완료되었습니다. (추첨일시: %s, 당첨자: %d명)", + drawLog.getDrawnAt(), drawLog.getWinnerCount()); + log.warn("Duplicate draw detected - eventId: {}, drawLogId: {}, drawnAt: {}", + eventId, drawLog.getDrawLogId(), drawLog.getDrawnAt()); + throw new AlreadyDrawnException(errorMsg); + } + }); + } + + /** + * 추첨 성공 로그 저장 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 + * @param totalParticipants 전체 참여자 수 + * @param winnerCount 당첨자 수 + * @param drawnAt 추첨 일시 + * @return 저장된 추첨 로그 + */ + private DrawLog saveSuccessDrawLog( + String eventId, + WinnerDrawRequest request, + int totalParticipants, + int winnerCount, + LocalDateTime drawnAt) { + + DrawLog drawLog = DrawLog.builder() + .eventId(eventId) + .drawMethod("RANDOM") + .algorithm("FISHER_YATES_SHUFFLE") + .visitBonusApplied(request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true) + .winnerCount(winnerCount) + .totalParticipants(totalParticipants) + .drawnAt(drawnAt) + .drawnBy("SYSTEM") // TODO: 실제로는 인증된 사용자 ID 사용 + .isSuccess(true) + .errorMessage(null) + .settings(buildSettingsJson(request)) + .build(); + + DrawLog saved = drawLogRepository.save(drawLog); + + log.info("Draw log saved successfully - drawLogId: {}, eventId: {}, isSuccess: true", + saved.getDrawLogId(), eventId); + + return saved; + } + + /** + * 추첨 실패 로그 저장 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 + * @param totalParticipants 전체 참여자 수 + * @param errorMessage 에러 메시지 + */ + private void saveFailedDrawLog( + String eventId, + WinnerDrawRequest request, + int totalParticipants, + String errorMessage) { + + try { + DrawLog drawLog = DrawLog.builder() + .eventId(eventId) + .drawMethod("RANDOM") + .algorithm("FISHER_YATES_SHUFFLE") + .visitBonusApplied(request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true) + .winnerCount(0) + .totalParticipants(totalParticipants) + .drawnAt(LocalDateTime.now()) + .drawnBy("SYSTEM") + .isSuccess(false) + .errorMessage(errorMessage) + .settings(buildSettingsJson(request)) + .build(); + + DrawLog saved = drawLogRepository.save(drawLog); + + log.warn("Failed draw log saved - drawLogId: {}, eventId: {}, error: {}", + saved.getDrawLogId(), eventId, errorMessage); + + } catch (Exception e) { + log.error("Failed to save failed draw log - eventId: {}", eventId, e); + } + } + + /** + * 추첨 설정 JSON 생성 + * + * @param request 추첨 요청 + * @return JSON 문자열 + */ + private String buildSettingsJson(WinnerDrawRequest request) { + return String.format( + "{\"winnerCount\":%d,\"visitBonusApplied\":%b,\"visitBonusWeight\":%.1f}", + request.getWinnerCount(), + request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true, + visitBonusWeight + ); + } + + /** + * Participant 엔티티를 WinnerDto로 변환 + * + * @param participant 참여자 엔티티 + * @return 당첨자 DTO (전화번호 마스킹 처리됨) + */ + private WinnerDto convertToWinnerDto(Participant participant) { + return WinnerDto.builder() + .participantId(String.valueOf(participant.getParticipantId())) + .name(participant.getName()) + .maskedPhoneNumber(participant.getMaskedPhoneNumber()) + .email(participant.getEmail()) + .applicationNumber(parseApplicationNumber(participant.getApplicationNumber())) + .wonAt(participant.getWonAt()) + .storeVisited(participant.getStoreVisited()) + .bonusApplied(participant.getBonusEntries() > 1) + .build(); + } + + /** + * 응모 번호를 Integer로 파싱 + * 형식: EVT-20250123143022-A7B9 + * + * @param applicationNumber 응모 번호 문자열 + * @return 해시된 정수값 + */ + private Integer parseApplicationNumber(String applicationNumber) { + // 응모 번호 문자열을 해시하여 정수로 변환 + return applicationNumber != null ? applicationNumber.hashCode() : 0; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java new file mode 100644 index 0000000..4fb3756 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이미 추첨 완료 예외 + * 이미 추첨이 완료된 이벤트에 대해 다시 추첨하려고 할 때 발생 + */ +public class AlreadyDrawnException extends ParticipationException { + + private static final String ERROR_CODE = "ALREADY_DRAWN"; + + public AlreadyDrawnException(String message) { + super(ERROR_CODE, message); + } + + public AlreadyDrawnException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java new file mode 100644 index 0000000..56bf3bb --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 중복 참여 예외 + * 사용자가 이미 참여한 이벤트에 다시 참여하려고 할 때 발생 + */ +public class DuplicateParticipationException extends ParticipationException { + + private static final String ERROR_CODE = "DUPLICATE_PARTICIPATION"; + + public DuplicateParticipationException(String message) { + super(ERROR_CODE, message); + } + + public DuplicateParticipationException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java new file mode 100644 index 0000000..cfcf4db --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이벤트 진행 불가 상태 예외 + * 이벤트가 ACTIVE 상태가 아니어서 참여할 수 없을 때 발생 + */ +public class EventNotActiveException extends ParticipationException { + + private static final String ERROR_CODE = "EVENT_NOT_ACTIVE"; + + public EventNotActiveException(String message) { + super(ERROR_CODE, message); + } + + public EventNotActiveException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java new file mode 100644 index 0000000..6381b8d --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이벤트 없음 예외 + * 요청한 이벤트 ID가 존재하지 않을 때 발생 + */ +public class EventNotFoundException extends ParticipationException { + + private static final String ERROR_CODE = "EVENT_NOT_FOUND"; + + public EventNotFoundException(String message) { + super(ERROR_CODE, message); + } + + public EventNotFoundException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9a1892a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,110 @@ +package com.kt.event.participation.common.exception; + +import com.kt.event.participation.application.dto.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * 전역 예외 처리 핸들러 + * 모든 컨트롤러에서 발생하는 예외를 처리 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 중복 참여 예외 처리 + */ + @ExceptionHandler(DuplicateParticipationException.class) + public ResponseEntity handleDuplicateParticipation(DuplicateParticipationException ex) { + logger.warn("중복 참여 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /** + * 이벤트 없음 예외 처리 + */ + @ExceptionHandler(EventNotFoundException.class) + public ResponseEntity handleEventNotFound(EventNotFoundException ex) { + logger.warn("이벤트 없음 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + /** + * 이벤트 진행 불가 상태 예외 처리 + */ + @ExceptionHandler(EventNotActiveException.class) + public ResponseEntity handleEventNotActive(EventNotActiveException ex) { + logger.warn("이벤트 비활성 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 이미 추첨 완료 예외 처리 + */ + @ExceptionHandler(AlreadyDrawnException.class) + public ResponseEntity handleAlreadyDrawn(AlreadyDrawnException ex) { + logger.warn("이미 추첨 완료 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /** + * 참여자 수 부족 예외 처리 + */ + @ExceptionHandler(InsufficientParticipantsException.class) + public ResponseEntity handleInsufficientParticipants(InsufficientParticipantsException ex) { + logger.warn("참여자 수 부족 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 기본 커스텀 예외 처리 + */ + @ExceptionHandler(ParticipationException.class) + public ResponseEntity handleParticipationException(ParticipationException ex) { + logger.warn("참여 서비스 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 유효성 검증 실패 예외 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + logger.warn("유효성 검증 실패: {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse("VALIDATION_ERROR", errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 처리되지 않은 모든 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + logger.error("서버 내부 오류: ", ex); + ErrorResponse errorResponse = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "서버 내부 오류가 발생했습니다." + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java new file mode 100644 index 0000000..1ab1b7a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 참여자 수 부족 예외 + * 추첨을 진행하기에 참여자 수가 부족할 때 발생 + */ +public class InsufficientParticipantsException extends ParticipationException { + + private static final String ERROR_CODE = "INSUFFICIENT_PARTICIPANTS"; + + public InsufficientParticipantsException(String message) { + super(ERROR_CODE, message); + } + + public InsufficientParticipantsException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java new file mode 100644 index 0000000..b48138e --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java @@ -0,0 +1,24 @@ +package com.kt.event.participation.common.exception; + +/** + * 참여 서비스 기본 예외 클래스 + * 모든 커스텀 예외의 부모 클래스 + */ +public class ParticipationException extends RuntimeException { + + private final String errorCode; + + public ParticipationException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public ParticipationException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java b/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java new file mode 100644 index 0000000..664df84 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java @@ -0,0 +1,37 @@ +package com.kt.event.participation.domain.common; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 공통 Base Entity + * - 생성일시, 수정일시 자동 관리 + * - JPA Auditing 활용 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + /** + * 생성일시 + */ + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정일시 + */ + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java new file mode 100644 index 0000000..cc44733 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java @@ -0,0 +1,134 @@ +package com.kt.event.participation.domain.draw; + +import com.kt.event.participation.domain.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 추첨 로그 엔티티 + * - 추첨 이력 관리 + * - 감사 추적 (Audit Trail) + * - 추첨 알고리즘 및 설정 기록 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Entity +@Table(name = "draw_logs", + indexes = { + @Index(name = "idx_draw_log_event", columnList = "event_id"), + @Index(name = "idx_draw_log_drawn_at", columnList = "drawn_at DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class DrawLog extends BaseEntity { + + /** + * 추첨 로그 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "draw_log_id") + private Long drawLogId; + + /** + * 이벤트 ID + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 추첨 방법 + * - RANDOM (무작위 추첨) + * - FCFS (선착순) + */ + @Column(name = "draw_method", nullable = false, length = 20) + @Builder.Default + private String drawMethod = "RANDOM"; + + /** + * 추첨 알고리즘 + * - FISHER_YATES_SHUFFLE (Fisher-Yates 셔플) + * - CRYPTO_RANDOM (암호학적 난수 기반) + */ + @Column(name = "algorithm", nullable = false, length = 50) + @Builder.Default + private String algorithm = "FISHER_YATES_SHUFFLE"; + + /** + * 매장 방문 가산점 적용 여부 + */ + @Column(name = "visit_bonus_applied", nullable = false) + @Builder.Default + private Boolean visitBonusApplied = false; + + /** + * 당첨 인원 + */ + @Column(name = "winner_count", nullable = false) + private Integer winnerCount; + + /** + * 전체 참여자 수 (추첨 시점 기준) + */ + @Column(name = "total_participants", nullable = false) + private Integer totalParticipants; + + /** + * 추첨 일시 + */ + @Column(name = "drawn_at", nullable = false) + private LocalDateTime drawnAt; + + /** + * 추첨 실행자 (사장님 ID) + */ + @Column(name = "drawn_by", length = 50) + private String drawnBy; + + /** + * 추첨 성공 여부 + */ + @Column(name = "is_success", nullable = false) + @Builder.Default + private Boolean isSuccess = true; + + /** + * 에러 메시지 (실패 시) + */ + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + /** + * 추첨 설정 (JSON) + * - 추가 설정 정보 저장 + */ + @Column(name = "settings", columnDefinition = "TEXT") + private String settings; + + // === Business Methods === + + /** + * 추첨 실패 처리 + * @param errorMessage 에러 메시지 + */ + public void markAsFailed(String errorMessage) { + this.isSuccess = false; + this.errorMessage = errorMessage; + } + + /** + * 추첨 성공 처리 + * @param winnerCount 당첨 인원 + */ + public void markAsSuccess(int winnerCount) { + this.isSuccess = true; + this.winnerCount = winnerCount; + this.drawnAt = LocalDateTime.now(); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java new file mode 100644 index 0000000..d3a66e9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java @@ -0,0 +1,46 @@ +package com.kt.event.participation.domain.draw; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 추첨 로그 Repository + * - 추첨 이력 CRUD + * - 중복 추첨 검증 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Repository +public interface DrawLogRepository extends JpaRepository { + + /** + * 이벤트별 추첨 로그 조회 + * - 중복 추첨 방지를 위해 사용 + * + * @param eventId 이벤트 ID + * @return 추첨 로그 (존재하지 않으면 empty) + */ + Optional findByEventId(String eventId); + + /** + * 이벤트별 추첨 이력 전체 조회 + * - 추첨 일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @return 추첨 로그 목록 + */ + List findByEventIdOrderByDrawnAtDesc(String eventId); + + /** + * 성공한 추첨 이력 조회 + * + * @param eventId 이벤트 ID + * @param isSuccess 성공 여부 (true) + * @return 추첨 로그 목록 + */ + List findByEventIdAndIsSuccessOrderByDrawnAtDesc(String eventId, Boolean isSuccess); +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java new file mode 100644 index 0000000..fa328e8 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java @@ -0,0 +1,161 @@ +package com.kt.event.participation.domain.participant; + +import com.kt.event.participation.domain.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 이벤트 참여자 엔티티 + * - 이벤트 참여 정보 관리 + * - 중복 참여 방지 (eventId + phoneNumber 복합 유니크) + * - 당첨자 정보 포함 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Entity +@Table(name = "participants", + uniqueConstraints = { + @UniqueConstraint(name = "uk_participant_event_phone", columnNames = {"event_id", "phone_number"}) + }, + indexes = { + @Index(name = "idx_participant_event_filters", columnList = "event_id, entry_path, is_winner, participated_at DESC"), + @Index(name = "idx_participant_phone", columnList = "phone_number"), + @Index(name = "idx_participant_event_winner", columnList = "event_id, is_winner") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Participant extends BaseEntity { + + /** + * 참여자 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + private Long participantId; + + /** + * 이벤트 ID (외래키는 다른 서비스이므로 논리적 연관만) + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 참여자 이름 + */ + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** + * 전화번호 (중복 참여 방지 키) + */ + @Column(name = "phone_number", nullable = false, length = 20) + private String phoneNumber; + + /** + * 이메일 + */ + @Column(name = "email", length = 255) + private String email; + + /** + * 참여 경로 + * - SNS, STORE_VISIT, BLOG, TV, etc. + */ + @Column(name = "entry_path", nullable = false, length = 50) + private String entryPath; + + /** + * 응모 번호 + * - 형식: EVT-{timestamp}-{random} + */ + @Column(name = "application_number", nullable = false, unique = true, length = 50) + private String applicationNumber; + + /** + * 참여 일시 + */ + @Column(name = "participated_at", nullable = false) + private LocalDateTime participatedAt; + + /** + * 매장 방문 여부 + */ + @Column(name = "store_visited", nullable = false) + @Builder.Default + private Boolean storeVisited = false; + + /** + * 마케팅 수신 동의 + */ + @Column(name = "agree_marketing", nullable = false) + @Builder.Default + private Boolean agreeMarketing = false; + + /** + * 개인정보 수집/이용 동의 + */ + @Column(name = "agree_privacy", nullable = false) + @Builder.Default + private Boolean agreePrivacy = true; + + /** + * 당첨 여부 + */ + @Column(name = "is_winner", nullable = false) + @Builder.Default + private Boolean isWinner = false; + + /** + * 당첨 일시 + */ + @Column(name = "won_at") + private LocalDateTime wonAt; + + /** + * 보너스 응모권 수 + * - 매장 방문 시 추가 응모권 부여 + */ + @Column(name = "bonus_entries", nullable = false) + @Builder.Default + private Integer bonusEntries = 1; + + // === Business Methods === + + /** + * 당첨자로 변경 + */ + public void markAsWinner() { + this.isWinner = true; + this.wonAt = LocalDateTime.now(); + } + + /** + * 매장 방문 여부에 따라 보너스 응모권 부여 + * @param visitBonusWeight 매장 방문 가중치 (기본 2.0) + */ + public void applyVisitBonus(double visitBonusWeight) { + if (this.storeVisited != null && this.storeVisited) { + this.bonusEntries = (int) visitBonusWeight; + } else { + this.bonusEntries = 1; + } + } + + /** + * 전화번호 마스킹 + * @return 마스킹된 전화번호 (예: 010-****-5678) + */ + public String getMaskedPhoneNumber() { + if (phoneNumber == null || phoneNumber.length() < 13) { + return phoneNumber; + } + return phoneNumber.substring(0, 4) + "****" + phoneNumber.substring(8); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java new file mode 100644 index 0000000..17e085c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java @@ -0,0 +1,132 @@ +package com.kt.event.participation.domain.participant; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 참여자 Repository + * - 참여자 CRUD 및 조회 기능 + * - 중복 참여 검증 + * - 당첨자 관리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Repository +public interface ParticipantRepository extends JpaRepository { + + /** + * 중복 참여 검증 + * - 이벤트ID + 전화번호로 중복 참여 확인 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 참여자 정보 (존재하지 않으면 empty) + */ + Optional findByEventIdAndPhoneNumber(String eventId, String phoneNumber); + + /** + * 이벤트별 참여자 목록 조회 (페이징) + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + Page findByEventIdOrderByParticipatedAtDesc(String eventId, Pageable pageable); + + /** + * 이벤트별 참여자 목록 조회 (필터링 + 페이징) + * - 참여 경로 필터 + * - 당첨 여부 필터 + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 (nullable) + * @param isWinner 당첨 여부 (nullable) + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + @Query("SELECT p FROM Participant p WHERE p.eventId = :eventId " + + "AND (:entryPath IS NULL OR p.entryPath = :entryPath) " + + "AND (:isWinner IS NULL OR p.isWinner = :isWinner) " + + "ORDER BY p.participatedAt DESC") + Page findParticipants( + @Param("eventId") String eventId, + @Param("entryPath") String entryPath, + @Param("isWinner") Boolean isWinner, + Pageable pageable + ); + + /** + * 이벤트별 참여자 검색 (이름 또는 전화번호) + * - LIKE 검색 지원 + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param searchKeyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + @Query("SELECT p FROM Participant p WHERE p.eventId = :eventId " + + "AND (p.name LIKE %:searchKeyword% OR p.phoneNumber LIKE %:searchKeyword%) " + + "ORDER BY p.participatedAt DESC") + Page searchParticipants( + @Param("eventId") String eventId, + @Param("searchKeyword") String searchKeyword, + Pageable pageable + ); + + /** + * 이벤트별 미당첨 참여자 전체 조회 + * - 추첨 알고리즘에서 사용 + * - 참여일시 오름차순 정렬 (공정성) + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (false) + * @return 미당첨 참여자 전체 목록 + */ + List findByEventIdAndIsWinnerOrderByParticipatedAtAsc(String eventId, Boolean isWinner); + + /** + * 이벤트별 당첨자 목록 조회 + * - 당첨 일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (true) + * @return 당첨자 목록 + */ + List findByEventIdAndIsWinnerOrderByWonAtDesc(String eventId, Boolean isWinner); + + /** + * 이벤트별 전체 참여자 수 조회 + * + * @param eventId 이벤트 ID + * @return 전체 참여자 수 + */ + Long countByEventId(String eventId); + + /** + * 이벤트별 당첨자 수 조회 + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (true) + * @return 당첨자 수 + */ + Long countByEventIdAndIsWinner(String eventId, Boolean isWinner); + + /** + * 응모 번호로 참여자 조회 + * + * @param applicationNumber 응모 번호 + * @return 참여자 정보 (존재하지 않으면 empty) + */ + Optional findByApplicationNumber(String applicationNumber); +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java new file mode 100644 index 0000000..b69bee6 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java @@ -0,0 +1,59 @@ +package com.kt.event.participation.infrastructure.kafka; + +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; + +/** + * Kafka Producer 서비스 + * + * 참가자 등록 이벤트를 Kafka 토픽에 발행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaProducerService { + + private final KafkaTemplate kafkaTemplate; + + @Value("${spring.kafka.topics.participant-registered}") + private String participantTopic; + + /** + * 참가자 등록 이벤트 발행 + * + * @param event 참가자 등록 이벤트 + */ + public void publishParticipantRegistered(ParticipantRegisteredEvent event) { + try { + log.info("Publishing participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId()); + + // Kafka 메시지 전송 (비동기) + CompletableFuture> future = + kafkaTemplate.send(participantTopic, String.valueOf(event.getEventId()), event); + + // 전송 결과 처리 + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("Participant registered event published successfully: eventId={}, participantId={}, offset={}", + event.getEventId(), event.getParticipantId(), result.getRecordMetadata().offset()); + } else { + log.error("Failed to publish participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId(), ex); + } + }); + + } catch (Exception e) { + log.error("Error publishing participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId(), e); + // 이벤트 발행 실패는 참가자 등록 실패로 이어지지 않음 (Best Effort) + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java new file mode 100644 index 0000000..cba5ede --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java @@ -0,0 +1,22 @@ +package com.kt.event.participation.infrastructure.kafka.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Kafka 설정 + * + * Spring Boot의 Auto Configuration을 사용하므로 별도 Bean 설정 불필요 + * application.yml에서 설정 관리: + * - spring.kafka.bootstrap-servers + * - spring.kafka.producer.key-serializer + * - spring.kafka.producer.value-serializer + * - spring.kafka.producer.acks + * - spring.kafka.producer.retries + */ +@EnableKafka +@Configuration +public class KafkaConfig { + // Spring Boot Auto Configuration 사용 + // 필요 시 KafkaTemplate Bean 커스터마이징 가능 +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java new file mode 100644 index 0000000..e340d76 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java @@ -0,0 +1,46 @@ +package com.kt.event.participation.infrastructure.kafka.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Kafka Event: 참가자 등록 이벤트 + * + * 참가자가 이벤트에 등록되었을 때 발행되는 이벤트 + * 이벤트 알림 서비스 등에서 소비하여 SMS/카카오톡 발송 등의 작업 수행 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantRegisteredEvent { + + /** + * 참가자 ID (PK) + */ + private Long participantId; + + /** + * 이벤트 ID + */ + private Long eventId; + + /** + * 참가자 전화번호 + */ + private String phoneNumber; + + /** + * 유입 경로 (QR, LINK, DIRECT 등) + */ + private String entryPath; + + /** + * 등록 일시 + */ + private LocalDateTime registeredAt; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java new file mode 100644 index 0000000..026a5a9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java @@ -0,0 +1,166 @@ +package com.kt.event.participation.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Redis 캐시 서비스 + * + * 중복 참여 체크 및 참가자 목록 캐싱 기능 제공 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisCacheService { + + private final RedisTemplate redisTemplate; + + @Value("${app.cache.duplicate-check-ttl:604800}") // 기본 7일 + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") // 기본 10분 + private long participantListTtl; + + private static final String DUPLICATE_CHECK_PREFIX = "duplicate:"; + private static final String PARTICIPANT_LIST_PREFIX = "participants:"; + + /** + * 중복 참여 여부 확인 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 중복 참여 여부 (true: 중복, false: 중복 아님) + */ + public Boolean checkDuplicateParticipation(Long eventId, String phoneNumber) { + try { + String key = buildDuplicateCheckKey(eventId, phoneNumber); + Boolean exists = redisTemplate.hasKey(key); + + log.debug("Duplicate participation check: eventId={}, phoneNumber={}, isDuplicate={}", + eventId, phoneNumber, exists); + + return Boolean.TRUE.equals(exists); + + } catch (Exception e) { + log.error("Error checking duplicate participation: eventId={}, phoneNumber={}", + eventId, phoneNumber, e); + // Redis 장애 시 중복 체크 실패로 처리하지 않음 (DB에서 확인) + return false; + } + } + + /** + * 중복 참여 정보 캐싱 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @param ttl TTL (초 단위, null일 경우 기본값 사용) + */ + public void cacheDuplicateCheck(Long eventId, String phoneNumber, Long ttl) { + try { + String key = buildDuplicateCheckKey(eventId, phoneNumber); + long effectiveTtl = (ttl != null) ? ttl : duplicateCheckTtl; + + redisTemplate.opsForValue().set(key, "1", effectiveTtl, TimeUnit.SECONDS); + + log.debug("Cached duplicate check: eventId={}, phoneNumber={}, ttl={}", + eventId, phoneNumber, effectiveTtl); + + } catch (Exception e) { + log.error("Error caching duplicate check: eventId={}, phoneNumber={}", + eventId, phoneNumber, e); + // 캐싱 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 참가자 목록 캐싱 + * + * @param key 캐시 키 + * @param data 캐싱할 데이터 + * @param ttl TTL (초 단위, null일 경우 기본값 사용) + */ + public void cacheParticipantList(String key, Object data, Long ttl) { + try { + String cacheKey = buildParticipantListKey(key); + long effectiveTtl = (ttl != null) ? ttl : participantListTtl; + + redisTemplate.opsForValue().set(cacheKey, data, effectiveTtl, TimeUnit.SECONDS); + + log.debug("Cached participant list: key={}, ttl={}", cacheKey, effectiveTtl); + + } catch (Exception e) { + log.error("Error caching participant list: key={}", key, e); + // 캐싱 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 참가자 목록 조회 + * + * @param key 캐시 키 + * @return 캐싱된 데이터 (Optional) + */ + public Optional getParticipantList(String key) { + try { + String cacheKey = buildParticipantListKey(key); + Object data = redisTemplate.opsForValue().get(cacheKey); + + log.debug("Retrieved participant list from cache: key={}, found={}", + cacheKey, data != null); + + return Optional.ofNullable(data); + + } catch (Exception e) { + log.error("Error retrieving participant list from cache: key={}", key, e); + return Optional.empty(); + } + } + + /** + * 참가자 목록 캐시 무효화 + * + * @param eventId 이벤트 ID + */ + public void invalidateParticipantListCache(Long eventId) { + try { + // 이벤트 ID로 시작하는 모든 참가자 목록 캐시 삭제 + String pattern = buildParticipantListKey(eventId + "*"); + redisTemplate.keys(pattern).forEach(key -> { + redisTemplate.delete(key); + log.debug("Invalidated participant list cache: key={}", key); + }); + + } catch (Exception e) { + log.error("Error invalidating participant list cache: eventId={}", eventId, e); + // 캐시 무효화 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 중복 체크 캐시 키 생성 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 캐시 키 + */ + private String buildDuplicateCheckKey(Long eventId, String phoneNumber) { + return DUPLICATE_CHECK_PREFIX + eventId + ":" + phoneNumber; + } + + /** + * 참가자 목록 캐시 키 생성 + * + * @param key 키 + * @return 캐시 키 + */ + private String buildParticipantListKey(String key) { + return PARTICIPANT_LIST_PREFIX + key; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java new file mode 100644 index 0000000..3e109c0 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java @@ -0,0 +1,104 @@ +package com.kt.event.participation.infrastructure.redis.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +/** + * Redis 설정 + * + * Redis 캐시 및 RedisTemplate 설정 + */ +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${app.cache.duplicate-check-ttl:604800}") // 기본 7일 + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") // 기본 10분 + private long participantListTtl; + + /** + * RedisTemplate 설정 + * + * @param connectionFactory Redis 연결 팩토리 + * @return RedisTemplate + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key Serializer: String + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + // Value Serializer: JSON + GenericJackson2JsonRedisSerializer jsonSerializer = createJsonSerializer(); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } + + /** + * CacheManager 설정 + * + * @param connectionFactory Redis 연결 팩토리 + * @return CacheManager + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + // 기본 캐시 설정 (participant-list 용) + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(participantListTtl)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + createJsonSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .build(); + } + + /** + * JSON Serializer 생성 + * + * @return GenericJackson2JsonRedisSerializer + */ + private GenericJackson2JsonRedisSerializer createJsonSerializer() { + ObjectMapper objectMapper = new ObjectMapper(); + + // Java 8 날짜/시간 타입 지원 + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // 타입 정보 포함 (역직렬화 시 타입 안전성 보장) + objectMapper.activateDefaultTyping( + objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + return new GenericJackson2JsonRedisSerializer(objectMapper); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java new file mode 100644 index 0000000..5a9b95e --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java @@ -0,0 +1,181 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.participation.application.dto.ParticipantListResponse; +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.ParticipationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 이벤트 참여 컨트롤러 + * - 이벤트 참여 등록 + * - 참여자 목록 조회 (필터링 + 페이징) + * - 참여자 검색 + * + * RESTful API 설계 원칙: + * - Base Path: /events/{eventId} + * - HTTP Method 사용: POST (등록), GET (조회) + * - HTTP Status Code: 201 (생성), 200 (조회), 400 (잘못된 요청), 404 (찾을 수 없음) + * - Request Validation: @Valid 사용하여 요청 검증 + * - Error Handling: GlobalExceptionHandler에서 처리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/events/{eventId}") +@RequiredArgsConstructor +public class ParticipationController { + + private final ParticipationService participationService; + + /** + * 이벤트 참여 + * + *

고객이 이벤트에 참여합니다.

+ * + *

비즈니스 로직:

+ *
    + *
  • 중복 참여 검증 (전화번호 기반)
  • + *
  • 이벤트 진행 상태 검증
  • + *
  • 응모 번호 생성 (EVT-{timestamp}-{random})
  • + *
  • Kafka 이벤트 발행 (ParticipantRegistered)
  • + *
+ * + *

Response:

+ *
    + *
  • 201 Created: 참여 성공
  • + *
  • 400 Bad Request: 유효하지 않은 요청, 중복 참여
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
  • 409 Conflict: 이벤트 진행 불가 상태
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param request 참여 요청 정보 (이름, 전화번호, 이메일, 개인정보 동의 등) + * @return 참여 응답 (참여자 ID, 응모 번호, 참여 일시 등) + */ + @PostMapping("/participate") + public ResponseEntity participateEvent( + @PathVariable("eventId") String eventId, + @Valid @RequestBody ParticipationRequest request) { + + log.info("POST /events/{}/participate - name: {}, storeVisited: {}", + eventId, request.getName(), request.getStoreVisited()); + + ParticipationResponse response = participationService.registerParticipant(eventId, request); + + log.info("Event participation successful - eventId: {}, participantId: {}", + eventId, response.getParticipantId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 참여자 목록 조회 + * + *

이벤트의 참여자 목록을 조회합니다.

+ * + *

기능:

+ *
    + *
  • 페이징 지원 (기본: page=0, size=20)
  • + *
  • 참여일시 기준 정렬 (최신순)
  • + *
  • 매장 방문 여부 필터링 (선택)
  • + *
  • 당첨 여부 필터링 (선택)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 조회 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (기본값: 20, 최대: 100) + * @param storeVisited 매장 방문 여부 필터 (nullable) + * @param isWinner 당첨 여부 필터 (nullable) + * @return 참여자 목록 (페이징 정보 포함) + */ + @GetMapping("/participants") + public ResponseEntity getParticipants( + @PathVariable("eventId") String eventId, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size, + @RequestParam(value = "storeVisited", required = false) Boolean storeVisited, + @RequestParam(value = "isWinner", required = false) Boolean isWinner) { + + log.info("GET /events/{}/participants - page: {}, size: {}, storeVisited: {}, isWinner: {}", + eventId, page, size, storeVisited, isWinner); + + // 페이지 크기 제한 (최대 100) + int validatedSize = Math.min(size, 100); + + Pageable pageable = PageRequest.of(page, validatedSize); + + // 참여 경로는 API 스펙에 없으므로 null로 전달 + ParticipantListResponse response = participationService.getParticipantList( + eventId, null, isWinner, pageable); + + log.info("Participant list fetched successfully - eventId: {}, totalElements: {}", + eventId, response.getTotalElements()); + + return ResponseEntity.ok(response); + } + + /** + * 참여자 검색 + * + *

이벤트의 참여자를 이름 또는 전화번호로 검색합니다.

+ * + *

기능:

+ *
    + *
  • 이름 또는 전화번호로 검색 (부분 일치)
  • + *
  • 페이징 지원 (기본: page=0, size=20)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 검색 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param keyword 검색 키워드 (이름 또는 전화번호) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (기본값: 20, 최대: 100) + * @return 검색된 참여자 목록 (페이징 정보 포함) + */ + @GetMapping("/participants/search") + public ResponseEntity searchParticipants( + @PathVariable("eventId") String eventId, + @RequestParam("keyword") String keyword, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + + log.info("GET /events/{}/participants/search - keyword: {}, page: {}, size: {}", + eventId, keyword, page, size); + + // 페이지 크기 제한 (최대 100) + int validatedSize = Math.min(size, 100); + + Pageable pageable = PageRequest.of(page, validatedSize); + + ParticipantListResponse response = participationService.searchParticipants( + eventId, keyword, pageable); + + log.info("Participants searched successfully - eventId: {}, keyword: {}, totalElements: {}", + eventId, keyword, response.getTotalElements()); + + return ResponseEntity.ok(response); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java new file mode 100644 index 0000000..6de791c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java @@ -0,0 +1,114 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.participation.application.dto.WinnerDrawRequest; +import com.kt.event.participation.application.dto.WinnerDrawResponse; +import com.kt.event.participation.application.dto.WinnerDto; +import com.kt.event.participation.application.service.WinnerDrawService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 당첨자 추첨 및 관리 컨트롤러 + * - 당첨자 추첨 실행 + * - 당첨자 목록 조회 + * + * RESTful API 설계 원칙: + * - Base Path: /events/{eventId} + * - HTTP Method 사용: POST (추첨), GET (조회) + * - HTTP Status Code: 200 (성공), 400 (잘못된 요청), 404 (찾을 수 없음), 409 (중복 추첨) + * - Request Validation: @Valid 사용하여 요청 검증 + * - Error Handling: GlobalExceptionHandler에서 처리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/events/{eventId}") +@RequiredArgsConstructor +public class WinnerController { + + private final WinnerDrawService winnerDrawService; + + /** + * 당첨자 추첨 + * + *

이벤트 당첨자를 추첨합니다.

+ * + *

비즈니스 로직:

+ *
    + *
  • 중복 추첨 검증 (이벤트별 1회만 가능)
  • + *
  • 참여자 수 검증 (당첨자 수보다 많아야 함)
  • + *
  • Fisher-Yates Shuffle 알고리즘 사용
  • + *
  • 매장 방문 보너스 가중치 적용 (선택)
  • + *
  • 추첨 로그 저장 (감사 추적)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 추첨 성공
  • + *
  • 400 Bad Request: 유효하지 않은 요청 (당첨자 수가 참여자 수보다 많음)
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
  • 409 Conflict: 이미 추첨 완료
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param request 추첨 요청 정보 (당첨자 수, 매장 방문 보너스 적용 여부) + * @return 추첨 결과 (당첨자 목록, 추첨 일시, 추첨 로그 ID 등) + */ + @PostMapping("/draw-winners") + public ResponseEntity drawWinners( + @PathVariable("eventId") String eventId, + @Valid @RequestBody WinnerDrawRequest request) { + + log.info("POST /events/{}/draw-winners - winnerCount: {}, visitBonusApplied: {}", + eventId, request.getWinnerCount(), request.getVisitBonusApplied()); + + WinnerDrawResponse response = winnerDrawService.drawWinners(eventId, request); + + log.info("Winners drawn successfully - eventId: {}, drawLogId: {}, winnerCount: {}", + eventId, response.getDrawLogId(), response.getWinnerCount()); + + return ResponseEntity.ok(response); + } + + /** + * 당첨자 목록 조회 + * + *

이벤트의 당첨자 목록을 조회합니다.

+ * + *

기능:

+ *
    + *
  • 당첨 순위별 정렬 (당첨 일시 내림차순)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
  • 응모 번호 포함
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 조회 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음 또는 당첨자가 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @return 당첨자 목록 (당첨 순위, 이름, 마스킹된 전화번호, 당첨 일시 등) + */ + @GetMapping("/winners") + public ResponseEntity> getWinners( + @PathVariable("eventId") String eventId) { + + log.info("GET /events/{}/winners", eventId); + + List winners = winnerDrawService.getWinners(eventId); + + log.info("Winners fetched successfully - eventId: {}, count: {}", + eventId, winners.size()); + + return ResponseEntity.ok(winners); + } +} diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml new file mode 100644 index 0000000..7fc673d --- /dev/null +++ b/participation-service/src/main/resources/application.yml @@ -0,0 +1,88 @@ +spring: + application: + name: participation-service + + datasource: + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:participation_db}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 + username: ${DB_USER:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: ${DB_POOL_SIZE:10} + minimum-idle: ${DB_MIN_IDLE:5} + connection-timeout: ${DB_CONN_TIMEOUT:30000} + idle-timeout: ${DB_IDLE_TIMEOUT:600000} + max-lifetime: ${DB_MAX_LIFETIME:1800000} + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + use_sql_comments: true + dialect: org.hibernate.dialect.MySQL8Dialect + + # Redis Configuration + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:3000} + lettuce: + pool: + max-active: ${REDIS_POOL_MAX_ACTIVE:8} + max-idle: ${REDIS_POOL_MAX_IDLE:8} + min-idle: ${REDIS_POOL_MIN_IDLE:2} + max-wait: ${REDIS_POOL_MAX_WAIT:3000} + + # Kafka Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: ${KAFKA_PRODUCER_ACKS:all} + retries: ${KAFKA_PRODUCER_RETRIES:3} + properties: + max.in.flight.requests.per.connection: 1 + enable.idempotence: true + # Topic Names + topics: + participant-registered: participant-events + +server: + port: ${SERVER_PORT:8084} + servlet: + context-path: / + error: + include-message: always + include-binding-errors: always + +# Logging +logging: + level: + root: ${LOG_LEVEL_ROOT:INFO} + com.kt.event.participation: ${LOG_LEVEL_APP:DEBUG} + org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO} + org.springframework.kafka: ${LOG_LEVEL_KAFKA:INFO} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE_PATH:./logs}/participation-service.log + max-size: ${LOG_FILE_MAX_SIZE:10MB} + max-history: ${LOG_FILE_MAX_HISTORY:30} + +# Application-specific Configuration +app: + cache: + duplicate-check-ttl: ${CACHE_DUPLICATE_TTL:604800} # 7 days in seconds + participant-list-ttl: ${CACHE_PARTICIPANT_TTL:600} # 10 minutes in seconds + lottery: + algorithm: FISHER_YATES_SHUFFLE + visit-bonus-weight: ${LOTTERY_VISIT_BONUS:2.0} # 매장 방문 고객 가중치 + security: + phone-mask-pattern: "***-****-***" # 전화번호 마스킹 패턴