Revert "Participation Service 백엔드 개발 완료"

This reverts commit e0c1066316dddddbe052bf6e639dc0d7d49b0b0d.
This commit is contained in:
doyeon 2025-10-24 09:21:39 +09:00 committed by Unknown
parent e0c1066316
commit ade2719dc7
45 changed files with 1 additions and 2908 deletions

View File

@ -15,9 +15,7 @@
"Bash(git add:*)", "Bash(git add:*)",
"Bash(git commit:*)", "Bash(git commit:*)",
"Bash(git push)", "Bash(git push)",
"Bash(git pull:*)", "Bash(git pull:*)"
"Bash(dir:*)",
"Bash(./gradlew participation-service:compileJava:*)"
], ],
"deny": [], "deny": [],
"ask": [] "ask": []

Binary file not shown.

View File

@ -1,71 +0,0 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="ParticipationServiceApplication" type="SpringBootApplicationConfigurationType" factoryName="Spring Boot" nameIsGenerated="true">
<option name="ACTIVE_PROFILES" />
<option name="FRAME_DEACTIVATION_UPDATE_POLICY" value="UpdateClassesAndResources" />
<module name="kt-event-marketing.participation-service.main" />
<option name="SPRING_BOOT_MAIN_CLASS" value="com.kt.event.participation.ParticipationServiceApplication" />
<option name="VM_PARAMETERS" value="" />
<extension name="coverage">
<pattern>
<option name="PATTERN" value="com.kt.event.participation.*" />
<option name="ENABLED" value="true" />
</pattern>
</extension>
<option name="ALTERNATIVE_JRE_PATH" />
<option name="SHORTEN_COMMAND_LINE" value="NONE" />
<option name="PROGRAM_PARAMETERS" value="" />
<option name="WORKING_DIRECTORY" value="$PROJECT_DIR$/participation-service" />
<envs>
<!-- Database Configuration -->
<env name="DB_HOST" value="localhost" />
<env name="DB_PORT" value="3306" />
<env name="DB_NAME" value="participation_db" />
<env name="DB_USER" value="root" />
<env name="DB_PASSWORD" value="password" />
<env name="DB_POOL_SIZE" value="10" />
<env name="DB_MIN_IDLE" value="5" />
<env name="DB_CONN_TIMEOUT" value="30000" />
<env name="DB_IDLE_TIMEOUT" value="600000" />
<env name="DB_MAX_LIFETIME" value="1800000" />
<!-- JPA Configuration -->
<env name="JPA_DDL_AUTO" value="update" />
<env name="JPA_SHOW_SQL" value="true" />
<!-- Redis Configuration -->
<env name="REDIS_HOST" value="localhost" />
<env name="REDIS_PORT" value="6379" />
<env name="REDIS_PASSWORD" value="" />
<env name="REDIS_TIMEOUT" value="3000" />
<env name="REDIS_POOL_MAX_ACTIVE" value="8" />
<env name="REDIS_POOL_MAX_IDLE" value="8" />
<env name="REDIS_POOL_MIN_IDLE" value="2" />
<env name="REDIS_POOL_MAX_WAIT" value="3000" />
<!-- Kafka Configuration -->
<env name="KAFKA_BOOTSTRAP_SERVERS" value="localhost:9092" />
<env name="KAFKA_PRODUCER_ACKS" value="all" />
<env name="KAFKA_PRODUCER_RETRIES" value="3" />
<!-- Server Configuration -->
<env name="SERVER_PORT" value="8084" />
<!-- Logging Configuration -->
<env name="LOG_LEVEL_ROOT" value="INFO" />
<env name="LOG_LEVEL_APP" value="DEBUG" />
<env name="LOG_LEVEL_REDIS" value="INFO" />
<env name="LOG_LEVEL_KAFKA" value="INFO" />
<env name="LOG_FILE_PATH" value="./logs" />
<env name="LOG_FILE_MAX_SIZE" value="10MB" />
<env name="LOG_FILE_MAX_HISTORY" value="30" />
<!-- App-specific Configuration -->
<env name="CACHE_DUPLICATE_TTL" value="604800" />
<env name="CACHE_PARTICIPANT_TTL" value="600" />
<env name="LOTTERY_VISIT_BONUS" value="2.0" />
</envs>
<method v="2">
<option name="Make" enabled="true" />
</method>
</configuration>
</component>

View File

@ -1,25 +0,0 @@
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);
}
}

View File

@ -1,38 +0,0 @@
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;
}
}

View File

@ -1,76 +0,0 @@
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;
}

View File

@ -1,54 +0,0 @@
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;
}

View File

@ -1,67 +0,0 @@
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;
}

View File

@ -1,64 +0,0 @@
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;
}

View File

@ -1,35 +0,0 @@
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;
}

View File

@ -1,59 +0,0 @@
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;
}

View File

@ -1,70 +0,0 @@
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;
}

View File

@ -1,117 +0,0 @@
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;
}
}

View File

@ -1,403 +0,0 @@
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)
}
}
}

View File

@ -1,312 +0,0 @@
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;
}
}

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,110 +0,0 @@
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);
}
}

View File

@ -1,18 +0,0 @@
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);
}
}

View File

@ -1,24 +0,0 @@
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;
}
}

View File

@ -1,37 +0,0 @@
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;
}

View File

@ -1,134 +0,0 @@
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();
}
}

View File

@ -1,46 +0,0 @@
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);
}

View File

@ -1,161 +0,0 @@
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);
}
}

View File

@ -1,132 +0,0 @@
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);
}

View File

@ -1,59 +0,0 @@
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)
}
}
}

View File

@ -1,22 +0,0 @@
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 커스터마이징 가능
}

View File

@ -1,46 +0,0 @@
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;
}

View File

@ -1,166 +0,0 @@
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;
}
}

View File

@ -1,104 +0,0 @@
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);
}
}

View File

@ -1,181 +0,0 @@
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);
}
}

View File

@ -1,114 +0,0 @@
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);
}
}

View File

@ -1,88 +0,0 @@
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: "***-****-***" # 전화번호 마스킹 패턴