mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2025-12-06 08:06:25 +00:00
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:
parent
50cf1dbcf1
commit
e0c1066316
@ -15,7 +15,9 @@
|
||||
"Bash(git add:*)",
|
||||
"Bash(git commit:*)",
|
||||
"Bash(git push)",
|
||||
"Bash(git pull:*)"
|
||||
"Bash(git pull:*)",
|
||||
"Bash(dir:*)",
|
||||
"Bash(./gradlew participation-service:compileJava:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
71
.run/ParticipationServiceApplication.run.xml
Normal file
71
.run/ParticipationServiceApplication.run.xml
Normal file
@ -0,0 +1,71 @@
|
||||
<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>
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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();
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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 커스터마이징 가능
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
@ -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;
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
@ -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);
|
||||
}
|
||||
}
|
||||
88
participation-service/src/main/resources/application.yml
Normal file
88
participation-service/src/main/resources/application.yml
Normal file
@ -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: "***-****-***" # 전화번호 마스킹 패턴
|
||||
Loading…
x
Reference in New Issue
Block a user