Participation Service 백엔드 개발 완료
주요 구현 사항:
- 이벤트 참여 등록 및 중복 검증 (Redis Cache + DB)
- 참여자 목록 조회 (필터링, 검색, 페이징)
- 당첨자 추첨 (Fisher-Yates Shuffle 알고리즘)
- Kafka 이벤트 발행 (ParticipantRegistered)
- Redis 캐싱으로 성능 최적화
- 전화번호 마스킹 (개인정보 보호)
- 전역 예외 처리 및 검증
기술 스택:
- Spring Boot 3.x + JPA
- MySQL (참여자, 추첨 로그)
- Redis (캐싱, 중복 검증)
- Kafka (이벤트 발행)
API 엔드포인트:
- POST /events/{eventId}/participate
- GET /events/{eventId}/participants
- GET /events/{eventId}/participants/search
- POST /events/{eventId}/draw-winners
- GET /events/{eventId}/winners
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
+25
@@ -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);
|
||||
}
|
||||
}
|
||||
+38
@@ -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;
|
||||
}
|
||||
}
|
||||
+76
@@ -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;
|
||||
}
|
||||
+54
@@ -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<ParticipantDto> participants;
|
||||
|
||||
/**
|
||||
* 전체 참여자 수
|
||||
*/
|
||||
private Integer totalElements;
|
||||
|
||||
/**
|
||||
* 전체 페이지 수
|
||||
*/
|
||||
private Integer totalPages;
|
||||
|
||||
/**
|
||||
* 현재 페이지 번호 (0부터 시작)
|
||||
*/
|
||||
private Integer currentPage;
|
||||
|
||||
/**
|
||||
* 페이지 크기
|
||||
*/
|
||||
private Integer pageSize;
|
||||
|
||||
/**
|
||||
* 다음 페이지 존재 여부
|
||||
*/
|
||||
private Boolean hasNext;
|
||||
|
||||
/**
|
||||
* 이전 페이지 존재 여부
|
||||
*/
|
||||
private Boolean hasPrevious;
|
||||
}
|
||||
+67
@@ -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;
|
||||
}
|
||||
+64
@@ -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;
|
||||
}
|
||||
+35
@@ -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;
|
||||
}
|
||||
+59
@@ -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<WinnerDto> 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;
|
||||
}
|
||||
+70
@@ -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;
|
||||
}
|
||||
+117
@@ -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<Participant> executeLottery(
|
||||
List<Participant> 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<Participant> weightedParticipants = applyWeights(participants, visitBonusApplied, visitBonusWeight);
|
||||
|
||||
// Step 2: Fisher-Yates Shuffle
|
||||
List<Participant> shuffled = fisherYatesShuffle(weightedParticipants);
|
||||
|
||||
// Step 3: 상위 N명 선정
|
||||
List<Participant> 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<Participant> applyWeights(List<Participant> participants, boolean visitBonusApplied, double visitBonusWeight) {
|
||||
if (!visitBonusApplied) {
|
||||
return new ArrayList<>(participants);
|
||||
}
|
||||
|
||||
List<Participant> 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<Participant> fisherYatesShuffle(List<Participant> participants) {
|
||||
List<Participant> 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;
|
||||
}
|
||||
}
|
||||
+403
@@ -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<Participant> participantPage = participantRepository.findParticipants(
|
||||
eventId, entryPath, isWinner, pageable);
|
||||
|
||||
// Step 4: DTO 변환 및 전화번호 마스킹
|
||||
List<ParticipantDto> 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<Participant> participantPage = participantRepository.searchParticipants(
|
||||
eventId, keyword, pageable);
|
||||
|
||||
List<ParticipantDto> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+312
@@ -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<Participant> 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<Participant> 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<WinnerDto> 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<WinnerDto> getWinners(String eventId) {
|
||||
log.info("Fetching winners - eventId: {}", eventId);
|
||||
|
||||
List<Participant> winners = participantRepository
|
||||
.findByEventIdAndIsWinnerOrderByWonAtDesc(eventId, true);
|
||||
|
||||
List<WinnerDto> 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;
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+110
@@ -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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> 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<ErrorResponse> handleException(Exception ex) {
|
||||
logger.error("서버 내부 오류: ", ex);
|
||||
ErrorResponse errorResponse = new ErrorResponse(
|
||||
"INTERNAL_SERVER_ERROR",
|
||||
"서버 내부 오류가 발생했습니다."
|
||||
);
|
||||
return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse);
|
||||
}
|
||||
}
|
||||
+18
@@ -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);
|
||||
}
|
||||
}
|
||||
+24
@@ -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;
|
||||
}
|
||||
}
|
||||
+37
@@ -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;
|
||||
}
|
||||
+134
@@ -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();
|
||||
}
|
||||
}
|
||||
+46
@@ -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<DrawLog, Long> {
|
||||
|
||||
/**
|
||||
* 이벤트별 추첨 로그 조회
|
||||
* - 중복 추첨 방지를 위해 사용
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 추첨 로그 (존재하지 않으면 empty)
|
||||
*/
|
||||
Optional<DrawLog> findByEventId(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트별 추첨 이력 전체 조회
|
||||
* - 추첨 일시 내림차순 정렬
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 추첨 로그 목록
|
||||
*/
|
||||
List<DrawLog> findByEventIdOrderByDrawnAtDesc(String eventId);
|
||||
|
||||
/**
|
||||
* 성공한 추첨 이력 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param isSuccess 성공 여부 (true)
|
||||
* @return 추첨 로그 목록
|
||||
*/
|
||||
List<DrawLog> findByEventIdAndIsSuccessOrderByDrawnAtDesc(String eventId, Boolean isSuccess);
|
||||
}
|
||||
+161
@@ -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);
|
||||
}
|
||||
}
|
||||
+132
@@ -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<Participant, Long> {
|
||||
|
||||
/**
|
||||
* 중복 참여 검증
|
||||
* - 이벤트ID + 전화번호로 중복 참여 확인
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param phoneNumber 전화번호
|
||||
* @return 참여자 정보 (존재하지 않으면 empty)
|
||||
*/
|
||||
Optional<Participant> findByEventIdAndPhoneNumber(String eventId, String phoneNumber);
|
||||
|
||||
/**
|
||||
* 이벤트별 참여자 목록 조회 (페이징)
|
||||
* - 참여일시 내림차순 정렬
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param pageable 페이징 정보
|
||||
* @return 참여자 목록 (페이징)
|
||||
*/
|
||||
Page<Participant> 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<Participant> 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<Participant> searchParticipants(
|
||||
@Param("eventId") String eventId,
|
||||
@Param("searchKeyword") String searchKeyword,
|
||||
Pageable pageable
|
||||
);
|
||||
|
||||
/**
|
||||
* 이벤트별 미당첨 참여자 전체 조회
|
||||
* - 추첨 알고리즘에서 사용
|
||||
* - 참여일시 오름차순 정렬 (공정성)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param isWinner 당첨 여부 (false)
|
||||
* @return 미당첨 참여자 전체 목록
|
||||
*/
|
||||
List<Participant> findByEventIdAndIsWinnerOrderByParticipatedAtAsc(String eventId, Boolean isWinner);
|
||||
|
||||
/**
|
||||
* 이벤트별 당첨자 목록 조회
|
||||
* - 당첨 일시 내림차순 정렬
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param isWinner 당첨 여부 (true)
|
||||
* @return 당첨자 목록
|
||||
*/
|
||||
List<Participant> 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<Participant> findByApplicationNumber(String applicationNumber);
|
||||
}
|
||||
+59
@@ -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<String, Object> 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<SendResult<String, Object>> 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
+22
@@ -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 커스터마이징 가능
|
||||
}
|
||||
+46
@@ -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;
|
||||
}
|
||||
+166
@@ -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<String, Object> 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<Object> 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;
|
||||
}
|
||||
}
|
||||
+104
@@ -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<String, Object> redisTemplate(RedisConnectionFactory connectionFactory) {
|
||||
RedisTemplate<String, Object> 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);
|
||||
}
|
||||
}
|
||||
+181
@@ -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;
|
||||
|
||||
/**
|
||||
* 이벤트 참여
|
||||
*
|
||||
* <p>고객이 이벤트에 참여합니다.</p>
|
||||
*
|
||||
* <p><strong>비즈니스 로직:</strong></p>
|
||||
* <ul>
|
||||
* <li>중복 참여 검증 (전화번호 기반)</li>
|
||||
* <li>이벤트 진행 상태 검증</li>
|
||||
* <li>응모 번호 생성 (EVT-{timestamp}-{random})</li>
|
||||
* <li>Kafka 이벤트 발행 (ParticipantRegistered)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Response:</strong></p>
|
||||
* <ul>
|
||||
* <li>201 Created: 참여 성공</li>
|
||||
* <li>400 Bad Request: 유효하지 않은 요청, 중복 참여</li>
|
||||
* <li>404 Not Found: 이벤트를 찾을 수 없음</li>
|
||||
* <li>409 Conflict: 이벤트 진행 불가 상태</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param eventId 이벤트 ID (Path Variable)
|
||||
* @param request 참여 요청 정보 (이름, 전화번호, 이메일, 개인정보 동의 등)
|
||||
* @return 참여 응답 (참여자 ID, 응모 번호, 참여 일시 등)
|
||||
*/
|
||||
@PostMapping("/participate")
|
||||
public ResponseEntity<ParticipationResponse> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 목록 조회
|
||||
*
|
||||
* <p>이벤트의 참여자 목록을 조회합니다.</p>
|
||||
*
|
||||
* <p><strong>기능:</strong></p>
|
||||
* <ul>
|
||||
* <li>페이징 지원 (기본: page=0, size=20)</li>
|
||||
* <li>참여일시 기준 정렬 (최신순)</li>
|
||||
* <li>매장 방문 여부 필터링 (선택)</li>
|
||||
* <li>당첨 여부 필터링 (선택)</li>
|
||||
* <li>전화번호 마스킹 처리 (개인정보 보호)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Response:</strong></p>
|
||||
* <ul>
|
||||
* <li>200 OK: 조회 성공</li>
|
||||
* <li>404 Not Found: 이벤트를 찾을 수 없음</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param eventId 이벤트 ID (Path Variable)
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값: 0)
|
||||
* @param size 페이지 크기 (기본값: 20, 최대: 100)
|
||||
* @param storeVisited 매장 방문 여부 필터 (nullable)
|
||||
* @param isWinner 당첨 여부 필터 (nullable)
|
||||
* @return 참여자 목록 (페이징 정보 포함)
|
||||
*/
|
||||
@GetMapping("/participants")
|
||||
public ResponseEntity<ParticipantListResponse> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 검색
|
||||
*
|
||||
* <p>이벤트의 참여자를 이름 또는 전화번호로 검색합니다.</p>
|
||||
*
|
||||
* <p><strong>기능:</strong></p>
|
||||
* <ul>
|
||||
* <li>이름 또는 전화번호로 검색 (부분 일치)</li>
|
||||
* <li>페이징 지원 (기본: page=0, size=20)</li>
|
||||
* <li>전화번호 마스킹 처리 (개인정보 보호)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Response:</strong></p>
|
||||
* <ul>
|
||||
* <li>200 OK: 검색 성공</li>
|
||||
* <li>404 Not Found: 이벤트를 찾을 수 없음</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param eventId 이벤트 ID (Path Variable)
|
||||
* @param keyword 검색 키워드 (이름 또는 전화번호)
|
||||
* @param page 페이지 번호 (0부터 시작, 기본값: 0)
|
||||
* @param size 페이지 크기 (기본값: 20, 최대: 100)
|
||||
* @return 검색된 참여자 목록 (페이징 정보 포함)
|
||||
*/
|
||||
@GetMapping("/participants/search")
|
||||
public ResponseEntity<ParticipantListResponse> 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);
|
||||
}
|
||||
}
|
||||
+114
@@ -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;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨
|
||||
*
|
||||
* <p>이벤트 당첨자를 추첨합니다.</p>
|
||||
*
|
||||
* <p><strong>비즈니스 로직:</strong></p>
|
||||
* <ul>
|
||||
* <li>중복 추첨 검증 (이벤트별 1회만 가능)</li>
|
||||
* <li>참여자 수 검증 (당첨자 수보다 많아야 함)</li>
|
||||
* <li>Fisher-Yates Shuffle 알고리즘 사용</li>
|
||||
* <li>매장 방문 보너스 가중치 적용 (선택)</li>
|
||||
* <li>추첨 로그 저장 (감사 추적)</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Response:</strong></p>
|
||||
* <ul>
|
||||
* <li>200 OK: 추첨 성공</li>
|
||||
* <li>400 Bad Request: 유효하지 않은 요청 (당첨자 수가 참여자 수보다 많음)</li>
|
||||
* <li>404 Not Found: 이벤트를 찾을 수 없음</li>
|
||||
* <li>409 Conflict: 이미 추첨 완료</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param eventId 이벤트 ID (Path Variable)
|
||||
* @param request 추첨 요청 정보 (당첨자 수, 매장 방문 보너스 적용 여부)
|
||||
* @return 추첨 결과 (당첨자 목록, 추첨 일시, 추첨 로그 ID 등)
|
||||
*/
|
||||
@PostMapping("/draw-winners")
|
||||
public ResponseEntity<WinnerDrawResponse> 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* 당첨자 목록 조회
|
||||
*
|
||||
* <p>이벤트의 당첨자 목록을 조회합니다.</p>
|
||||
*
|
||||
* <p><strong>기능:</strong></p>
|
||||
* <ul>
|
||||
* <li>당첨 순위별 정렬 (당첨 일시 내림차순)</li>
|
||||
* <li>전화번호 마스킹 처리 (개인정보 보호)</li>
|
||||
* <li>응모 번호 포함</li>
|
||||
* </ul>
|
||||
*
|
||||
* <p><strong>Response:</strong></p>
|
||||
* <ul>
|
||||
* <li>200 OK: 조회 성공</li>
|
||||
* <li>404 Not Found: 이벤트를 찾을 수 없음 또는 당첨자가 없음</li>
|
||||
* </ul>
|
||||
*
|
||||
* @param eventId 이벤트 ID (Path Variable)
|
||||
* @return 당첨자 목록 (당첨 순위, 이름, 마스킹된 전화번호, 당첨 일시 등)
|
||||
*/
|
||||
@GetMapping("/winners")
|
||||
public ResponseEntity<List<WinnerDto>> getWinners(
|
||||
@PathVariable("eventId") String eventId) {
|
||||
|
||||
log.info("GET /events/{}/winners", eventId);
|
||||
|
||||
List<WinnerDto> winners = winnerDrawService.getWinners(eventId);
|
||||
|
||||
log.info("Winners fetched successfully - eventId: {}, count: {}",
|
||||
eventId, winners.size());
|
||||
|
||||
return ResponseEntity.ok(winners);
|
||||
}
|
||||
}
|
||||
@@ -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: "***-****-***" # 전화번호 마스킹 패턴
|
||||
Reference in New Issue
Block a user