Participation Service 백엔드 개발 완료

- 이벤트 참여 API 구현
- 참여자 목록/상세 조회 API 구현
- 당첨자 추첨 및 조회 API 구현
- PostgreSQL 데이터베이스 연동
- Kafka 이벤트 발행 연동
- 로깅 설정 및 실행 프로파일 추가
- .gradle 폴더 Git 추적 제거

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
doyeon 2025-10-24 13:29:10 +09:00
parent c6de9bd1d0
commit 04d417e34c
42 changed files with 1187 additions and 8 deletions

View File

@ -1,2 +0,0 @@
#Thu Oct 23 17:51:21 KST 2025
gradle.version=8.10

Binary file not shown.

View File

@ -64,11 +64,14 @@ public enum ErrorCode {
DIST_004("DIST_004", "배포 상태를 찾을 수 없습니다"),
// 참여 에러 (PART_XXX)
PART_001("PART_001", "이미 참여한 이벤트입니다"),
PART_002("PART_002", "이벤트 참여 기간이 아닙니다"),
PART_003("PART_003", "참여자를 찾을 수 없습니다"),
PART_004("PART_004", "당첨자 추첨에 실패했습니다"),
PART_005("PART_005", "이벤트가 종료되었습니다"),
DUPLICATE_PARTICIPATION("PART_001", "이미 참여한 이벤트입니다"),
EVENT_NOT_ACTIVE("PART_002", "이벤트 참여 기간이 아닙니다"),
PARTICIPANT_NOT_FOUND("PART_003", "참여자를 찾을 수 없습니다"),
DRAW_FAILED("PART_004", "당첨자 추첨에 실패했습니다"),
EVENT_ENDED("PART_005", "이벤트가 종료되었습니다"),
ALREADY_DRAWN("PART_006", "이미 당첨자 추첨이 완료되었습니다"),
INSUFFICIENT_PARTICIPANTS("PART_007", "참여자 수가 당첨자 수보다 적습니다"),
NO_WINNERS_YET("PART_008", "아직 당첨자 추첨이 진행되지 않았습니다"),
// 분석 에러 (ANALYTICS_XXX)
ANALYTICS_001("ANALYTICS_001", "분석 데이터를 찾을 수 없습니다"),

View File

@ -1,7 +1,50 @@
plugins {
id 'java'
id 'org.springframework.boot'
id 'io.spring.dependency-management'
}
group = 'com.kt.event'
version = '1.0.0'
sourceCompatibility = '21'
configurations {
compileOnly {
extendsFrom annotationProcessor
}
}
repositories {
mavenCentral()
}
dependencies {
// Kafka for event publishing
// Common
implementation project(':common')
// Spring Boot Starters
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.kafka:spring-kafka'
// PostgreSQL
runtimeOnly 'org.postgresql:postgresql'
// Lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'
// Jackson for JSON
implementation 'com.fasterxml.jackson.core:jackson-databind'
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
// Test
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.kafka:spring-kafka-test'
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
}
tasks.named('test') {
useJUnitPlatform()
}

View File

@ -0,0 +1,23 @@
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 Main Application
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@SpringBootApplication(scanBasePackages = {
"com.kt.event.participation",
"com.kt.event.common"
})
@EnableJpaAuditing
public class ParticipationServiceApplication {
public static void main(String[] args) {
SpringApplication.run(ParticipationServiceApplication.class, args);
}
}

View File

@ -0,0 +1,21 @@
package com.kt.event.participation.application.dto;
import jakarta.validation.constraints.*;
import lombok.*;
/**
* 당첨자 추첨 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DrawWinnersRequest {
@NotNull(message = "당첨자 수는 필수입니다")
@Min(value = 1, message = "당첨자 수는 최소 1명 이상이어야 합니다")
private Integer winnerCount;
@Builder.Default
private Boolean applyStoreVisitBonus = true;
}

View File

@ -0,0 +1,33 @@
package com.kt.event.participation.application.dto;
import lombok.*;
import java.time.LocalDateTime;
import java.util.List;
/**
* 당첨자 추첨 응답 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class DrawWinnersResponse {
private String eventId;
private Integer totalParticipants;
private Integer winnerCount;
private LocalDateTime drawnAt;
private List<WinnerSummary> winners;
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public static class WinnerSummary {
private String participantId;
private String name;
private String phoneNumber;
private Integer rank;
}
}

View File

@ -0,0 +1,34 @@
package com.kt.event.participation.application.dto;
import jakarta.validation.constraints.*;
import lombok.*;
/**
* 이벤트 참여 요청 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ParticipationRequest {
@NotBlank(message = "이름은 필수입니다")
@Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
private String name;
@NotBlank(message = "전화번호는 필수입니다")
@Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
private String phoneNumber;
@Email(message = "이메일 형식이 올바르지 않습니다")
private String email;
@Builder.Default
private Boolean agreeMarketing = false;
@NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다")
private Boolean agreePrivacy;
@Builder.Default
private Boolean storeVisited = false;
}

View File

@ -0,0 +1,40 @@
package com.kt.event.participation.application.dto;
import com.kt.event.participation.domain.participant.Participant;
import lombok.*;
import java.time.LocalDateTime;
/**
* 이벤트 참여 응답 DTO
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ParticipationResponse {
private String participantId;
private String eventId;
private String name;
private String phoneNumber;
private String email;
private LocalDateTime participatedAt;
private Boolean storeVisited;
private Integer bonusEntries;
private Boolean isWinner;
public static ParticipationResponse from(Participant participant) {
return ParticipationResponse.builder()
.participantId(participant.getParticipantId())
.eventId(participant.getEventId())
.name(participant.getName())
.phoneNumber(participant.getPhoneNumber())
.email(participant.getEmail())
.participatedAt(participant.getCreatedAt())
.storeVisited(participant.getStoreVisited())
.bonusEntries(participant.getBonusEntries())
.isWinner(participant.getIsWinner())
.build();
}
}

View File

@ -0,0 +1,117 @@
package com.kt.event.participation.application.service;
import com.kt.event.common.dto.PageResponse;
import com.kt.event.participation.application.dto.ParticipationRequest;
import com.kt.event.participation.application.dto.ParticipationResponse;
import com.kt.event.participation.domain.participant.Participant;
import com.kt.event.participation.domain.participant.ParticipantRepository;
import com.kt.event.participation.exception.ParticipationException.*;
import com.kt.event.participation.infrastructure.kafka.KafkaProducerService;
import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 이벤트 참여 서비스
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class ParticipationService {
private final ParticipantRepository participantRepository;
private final KafkaProducerService kafkaProducerService;
/**
* 이벤트 참여
*
* @param eventId 이벤트 ID
* @param request 참여 요청
* @return 참여 응답
*/
@Transactional
public ParticipationResponse participate(String eventId, ParticipationRequest request) {
log.info("이벤트 참여 시작 - eventId: {}, phoneNumber: {}", eventId, request.getPhoneNumber());
// 중복 참여 체크
if (participantRepository.existsByEventIdAndPhoneNumber(eventId, request.getPhoneNumber())) {
throw new DuplicateParticipationException();
}
// 참여자 ID 생성
Long maxId = participantRepository.findMaxIdByEventId(eventId).orElse(0L);
String participantId = Participant.generateParticipantId(eventId, maxId + 1);
// 참여자 저장
Participant participant = Participant.builder()
.participantId(participantId)
.eventId(eventId)
.name(request.getName())
.phoneNumber(request.getPhoneNumber())
.email(request.getEmail())
.storeVisited(request.getStoreVisited())
.bonusEntries(Participant.calculateBonusEntries(request.getStoreVisited()))
.agreeMarketing(request.getAgreeMarketing())
.agreePrivacy(request.getAgreePrivacy())
.isWinner(false)
.build();
participant = participantRepository.save(participant);
log.info("참여자 저장 완료 - participantId: {}", participantId);
// Kafka 이벤트 발행
kafkaProducerService.publishParticipantRegistered(
ParticipantRegisteredEvent.from(participant)
);
return ParticipationResponse.from(participant);
}
/**
* 참여자 목록 조회
*
* @param eventId 이벤트 ID
* @param storeVisited 매장 방문 여부 필터 (nullable)
* @param pageable 페이징 정보
* @return 참여자 목록
*/
@Transactional(readOnly = true)
public PageResponse<ParticipationResponse> getParticipants(
String eventId, Boolean storeVisited, Pageable pageable) {
Page<Participant> participantPage;
if (storeVisited != null) {
participantPage = participantRepository
.findByEventIdAndStoreVisitedOrderByCreatedAtDesc(eventId, storeVisited, pageable);
} else {
participantPage = participantRepository
.findByEventIdOrderByCreatedAtDesc(eventId, pageable);
}
Page<ParticipationResponse> responsePage = participantPage.map(ParticipationResponse::from);
return PageResponse.of(responsePage);
}
/**
* 참여자 상세 조회
*
* @param eventId 이벤트 ID
* @param participantId 참여자 ID
* @return 참여자 정보
*/
@Transactional(readOnly = true)
public ParticipationResponse getParticipant(String eventId, String participantId) {
Participant participant = participantRepository
.findByEventIdAndParticipantId(eventId, participantId)
.orElseThrow(ParticipantNotFoundException::new);
return ParticipationResponse.from(participant);
}
}

View File

@ -0,0 +1,158 @@
package com.kt.event.participation.application.service;
import com.kt.event.common.dto.PageResponse;
import com.kt.event.participation.application.dto.DrawWinnersRequest;
import com.kt.event.participation.application.dto.DrawWinnersResponse;
import com.kt.event.participation.application.dto.DrawWinnersResponse.WinnerSummary;
import com.kt.event.participation.application.dto.ParticipationResponse;
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 com.kt.event.participation.exception.ParticipationException.*;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
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.util.ArrayList;
import java.util.Collections;
import java.util.List;
import java.util.stream.Collectors;
/**
* 당첨자 추첨 서비스
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class WinnerDrawService {
private final ParticipantRepository participantRepository;
private final DrawLogRepository drawLogRepository;
/**
* 당첨자 추첨
*
* @param eventId 이벤트 ID
* @param request 추첨 요청
* @return 추첨 결과
*/
@Transactional
public DrawWinnersResponse drawWinners(String eventId, DrawWinnersRequest request) {
log.info("당첨자 추첨 시작 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount());
// 이미 추첨이 완료되었는지 확인
if (drawLogRepository.existsByEventId(eventId)) {
throw new AlreadyDrawnException();
}
// 참여자 목록 조회
List<Participant> participants = participantRepository.findByEventIdAndIsWinnerFalse(eventId);
long participantCount = participants.size();
// 참여자 검증
if (participantCount < request.getWinnerCount()) {
throw new InsufficientParticipantsException(participantCount, request.getWinnerCount());
}
// 가중치 적용 추첨 생성
List<Participant> drawPool = createDrawPool(participants, request.getApplyStoreVisitBonus());
// 추첨 실행
Collections.shuffle(drawPool);
List<Participant> winners = drawPool.stream()
.distinct()
.limit(request.getWinnerCount())
.collect(Collectors.toList());
// 당첨자 업데이트
LocalDateTime now = LocalDateTime.now();
for (int i = 0; i < winners.size(); i++) {
winners.get(i).markAsWinner(i + 1);
}
participantRepository.saveAll(winners);
// 추첨 로그 저장
DrawLog drawLog = DrawLog.builder()
.eventId(eventId)
.totalParticipants((int) participantCount)
.winnerCount(request.getWinnerCount())
.applyStoreVisitBonus(request.getApplyStoreVisitBonus())
.algorithm("WEIGHTED_RANDOM")
.drawnAt(now)
.drawnBy("SYSTEM")
.build();
drawLogRepository.save(drawLog);
log.info("당첨자 추첨 완료 - eventId: {}, winners: {}", eventId, winners.size());
// 응답 생성
List<WinnerSummary> winnerSummaries = winners.stream()
.map(w -> WinnerSummary.builder()
.participantId(w.getParticipantId())
.name(w.getName())
.phoneNumber(w.getPhoneNumber())
.rank(w.getWinnerRank())
.build())
.collect(Collectors.toList());
return DrawWinnersResponse.builder()
.eventId(eventId)
.totalParticipants((int) participantCount)
.winnerCount(winners.size())
.drawnAt(now)
.winners(winnerSummaries)
.build();
}
/**
* 당첨자 목록 조회
*
* @param eventId 이벤트 ID
* @param pageable 페이징 정보
* @return 당첨자 목록
*/
@Transactional(readOnly = true)
public PageResponse<ParticipationResponse> getWinners(String eventId, Pageable pageable) {
// 추첨 완료 확인
if (!drawLogRepository.existsByEventId(eventId)) {
throw new NoWinnersYetException();
}
Page<Participant> winnerPage = participantRepository
.findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(eventId, pageable);
Page<ParticipationResponse> responsePage = winnerPage.map(ParticipationResponse::from);
return PageResponse.of(responsePage);
}
/**
* 추첨 생성 (매장 방문 보너스 적용)
*
* @param participants 참여자 목록
* @param applyBonus 보너스 적용 여부
* @return 추첨
*/
private List<Participant> createDrawPool(List<Participant> participants, Boolean applyBonus) {
if (!applyBonus) {
return new ArrayList<>(participants);
}
List<Participant> pool = new ArrayList<>();
for (Participant participant : participants) {
// 보너스 응모권 수만큼 추첨 풀에 추가
int entries = participant.getBonusEntries();
for (int i = 0; i < entries; i++) {
pool.add(participant);
}
}
return pool;
}
}

View File

@ -0,0 +1,71 @@
package com.kt.event.participation.domain.draw;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
/**
* 당첨자 추첨 로그 엔티티
* 추첨 이력 관리 재추첨 방지
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Entity
@Table(name = "draw_logs",
indexes = {
@Index(name = "idx_event_id", columnList = "event_id")
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class DrawLog extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 이벤트 ID
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 전체 참여자
*/
@Column(name = "total_participants", nullable = false)
private Integer totalParticipants;
/**
* 당첨자
*/
@Column(name = "winner_count", nullable = false)
private Integer winnerCount;
/**
* 매장 방문 보너스 적용 여부
*/
@Column(name = "apply_store_visit_bonus", nullable = false)
private Boolean applyStoreVisitBonus;
/**
* 추첨 알고리즘
*/
@Column(name = "algorithm", nullable = false, length = 50)
private String algorithm;
/**
* 추첨 일시
*/
@Column(name = "drawn_at", nullable = false)
private java.time.LocalDateTime drawnAt;
/**
* 추첨 실행자 ID (관리자 또는 시스템)
*/
@Column(name = "drawn_by", length = 50)
private String drawnBy;
}

View File

@ -0,0 +1,33 @@
package com.kt.event.participation.domain.draw;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.stereotype.Repository;
import java.util.Optional;
/**
* 추첨 로그 리포지토리
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Repository
public interface DrawLogRepository extends JpaRepository<DrawLog, Long> {
/**
* 이벤트 ID로 추첨 로그 조회
* 이미 추첨이 진행되었는지 확인
*
* @param eventId 이벤트 ID
* @return 추첨 로그 Optional
*/
Optional<DrawLog> findByEventId(String eventId);
/**
* 이벤트 ID로 추첨 여부 확인
*
* @param eventId 이벤트 ID
* @return 추첨 여부
*/
boolean existsByEventId(String eventId);
}

View File

@ -0,0 +1,162 @@
package com.kt.event.participation.domain.participant;
import com.kt.event.common.entity.BaseTimeEntity;
import jakarta.persistence.*;
import lombok.*;
/**
* 이벤트 참여자 엔티티
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Entity
@Table(name = "participants",
indexes = {
@Index(name = "idx_event_id", columnList = "event_id"),
@Index(name = "idx_event_phone", columnList = "event_id, phone_number")
},
uniqueConstraints = {
@UniqueConstraint(name = "uk_event_phone", columnNames = {"event_id", "phone_number"})
}
)
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
@Builder
public class Participant extends BaseTimeEntity {
@Id
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;
/**
* 참여자 ID (외부 노출용)
* : prt_20250123_001
*/
@Column(name = "participant_id", nullable = false, unique = true, length = 50)
private String participantId;
/**
* 이벤트 ID
* Event Service의 이벤트 식별자
*/
@Column(name = "event_id", nullable = false, length = 50)
private String eventId;
/**
* 참여자 이름
*/
@Column(name = "name", nullable = false, length = 50)
private String name;
/**
* 참여자 전화번호
* 중복 참여 체크 키로 사용
*/
@Column(name = "phone_number", nullable = false, length = 20)
private String phoneNumber;
/**
* 참여자 이메일
*/
@Column(name = "email", length = 100)
private String email;
/**
* 매장 방문 여부
* true일 경우 보너스 응모권 부여
*/
@Column(name = "store_visited", nullable = false)
private Boolean storeVisited;
/**
* 보너스 응모권
* 기본 1, 매장 방문 +1
*/
@Column(name = "bonus_entries", nullable = false)
private Integer bonusEntries;
/**
* 마케팅 정보 수신 동의
*/
@Column(name = "agree_marketing", nullable = false)
private Boolean agreeMarketing;
/**
* 개인정보 수집 이용 동의 (필수)
*/
@Column(name = "agree_privacy", nullable = false)
private Boolean agreePrivacy;
/**
* 당첨 여부
*/
@Column(name = "is_winner", nullable = false)
private Boolean isWinner;
/**
* 당첨 순위 (당첨자일 경우)
*/
@Column(name = "winner_rank")
private Integer winnerRank;
/**
* 당첨 일시
*/
@Column(name = "won_at")
private java.time.LocalDateTime wonAt;
/**
* 참여자 ID 생성
*
* @param eventId 이벤트 ID
* @param sequenceNumber 순번
* @return 생성된 참여자 ID
*/
public static String generateParticipantId(String eventId, Long sequenceNumber) {
// evt_20250123_001 prt_20250123_001
String dateTime = eventId.substring(4, 12); // 20250123
return String.format("prt_%s_%03d", dateTime, sequenceNumber);
}
/**
* 보너스 응모권 계산
*
* @param storeVisited 매장 방문 여부
* @return 보너스 응모권
*/
public static Integer calculateBonusEntries(Boolean storeVisited) {
return storeVisited ? 2 : 1;
}
/**
* 당첨자로 설정
*
* @param rank 당첨 순위
*/
public void markAsWinner(Integer rank) {
this.isWinner = true;
this.winnerRank = rank;
this.wonAt = java.time.LocalDateTime.now();
}
/**
* 참여자 생성 유효성 검증
*/
@PrePersist
public void prePersist() {
if (this.agreePrivacy == null || !this.agreePrivacy) {
throw new IllegalStateException("개인정보 수집 및 이용 동의는 필수입니다");
}
if (this.bonusEntries == null) {
this.bonusEntries = calculateBonusEntries(this.storeVisited);
}
if (this.isWinner == null) {
this.isWinner = false;
}
if (this.agreeMarketing == null) {
this.agreeMarketing = false;
}
}
}

View File

@ -0,0 +1,109 @@
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;
/**
* 참여자 리포지토리
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Repository
public interface ParticipantRepository extends JpaRepository<Participant, Long> {
/**
* 참여자 ID로 조회
*
* @param participantId 참여자 ID
* @return 참여자 Optional
*/
Optional<Participant> findByParticipantId(String participantId);
/**
* 이벤트 ID와 전화번호로 중복 참여 체크
*
* @param eventId 이벤트 ID
* @param phoneNumber 전화번호
* @return 참여 여부
*/
boolean existsByEventIdAndPhoneNumber(String eventId, String phoneNumber);
/**
* 이벤트 ID로 참여자 목록 조회 (페이징)
*
* @param eventId 이벤트 ID
* @param pageable 페이징 정보
* @return 참여자 페이지
*/
Page<Participant> findByEventIdOrderByCreatedAtDesc(String eventId, Pageable pageable);
/**
* 이벤트 ID와 매장 방문 여부로 참여자 목록 조회 (페이징)
*
* @param eventId 이벤트 ID
* @param storeVisited 매장 방문 여부
* @param pageable 페이징 정보
* @return 참여자 페이지
*/
Page<Participant> findByEventIdAndStoreVisitedOrderByCreatedAtDesc(
String eventId, Boolean storeVisited, Pageable pageable);
/**
* 이벤트 ID로 전체 참여자 조회
*
* @param eventId 이벤트 ID
* @return 참여자
*/
long countByEventId(String eventId);
/**
* 이벤트 ID로 당첨자 목록 조회 (페이징)
*
* @param eventId 이벤트 ID
* @param pageable 페이징 정보
* @return 당첨자 페이지
*/
Page<Participant> findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(String eventId, Pageable pageable);
/**
* 이벤트 ID로 당첨자 조회
*
* @param eventId 이벤트 ID
* @return 당첨자
*/
long countByEventIdAndIsWinnerTrue(String eventId);
/**
* 이벤트 ID로 참여자 ID 최대값 조회 (순번 생성용)
*
* @param eventId 이벤트 ID
* @return 최대 ID
*/
@Query("SELECT MAX(p.id) FROM Participant p WHERE p.eventId = :eventId")
Optional<Long> findMaxIdByEventId(@Param("eventId") String eventId);
/**
* 이벤트 ID로 비당첨자 목록 조회 (추첨용)
*
* @param eventId 이벤트 ID
* @return 비당첨자 목록
*/
List<Participant> findByEventIdAndIsWinnerFalse(String eventId);
/**
* 이벤트 ID와 참여자 ID로 조회
*
* @param eventId 이벤트 ID
* @param participantId 참여자 ID
* @return 참여자 Optional
*/
Optional<Participant> findByEventIdAndParticipantId(String eventId, String participantId);
}

View File

@ -0,0 +1,85 @@
package com.kt.event.participation.exception;
import com.kt.event.common.exception.BusinessException;
import com.kt.event.common.exception.ErrorCode;
/**
* 참여 관련 비즈니스 예외
*
* @author Digital Garage Team
* @since 2025-01-24
*/
public class ParticipationException extends BusinessException {
public ParticipationException(ErrorCode errorCode) {
super(errorCode);
}
public ParticipationException(ErrorCode errorCode, String message) {
super(errorCode, message);
}
/**
* 중복 참여 예외
*/
public static class DuplicateParticipationException extends ParticipationException {
public DuplicateParticipationException() {
super(ErrorCode.DUPLICATE_PARTICIPATION, "이미 참여하신 이벤트입니다");
}
}
/**
* 이벤트를 찾을 없음 예외
*/
public static class EventNotFoundException extends ParticipationException {
public EventNotFoundException() {
super(ErrorCode.EVENT_001, "이벤트를 찾을 수 없습니다");
}
}
/**
* 이벤트가 활성 상태가 아님 예외
*/
public static class EventNotActiveException extends ParticipationException {
public EventNotActiveException() {
super(ErrorCode.EVENT_NOT_ACTIVE, "현재 참여할 수 없는 이벤트입니다");
}
}
/**
* 참여자를 찾을 없음 예외
*/
public static class ParticipantNotFoundException extends ParticipationException {
public ParticipantNotFoundException() {
super(ErrorCode.PARTICIPANT_NOT_FOUND, "참여자를 찾을 수 없습니다");
}
}
/**
* 이미 추첨이 완료됨 예외
*/
public static class AlreadyDrawnException extends ParticipationException {
public AlreadyDrawnException() {
super(ErrorCode.ALREADY_DRAWN, "이미 당첨자 추첨이 완료되었습니다");
}
}
/**
* 참여자 부족 예외
*/
public static class InsufficientParticipantsException extends ParticipationException {
public InsufficientParticipantsException(long participantCount, int winnerCount) {
super(ErrorCode.INSUFFICIENT_PARTICIPANTS,
String.format("참여자 수(%d)가 당첨자 수(%d)보다 적습니다", participantCount, winnerCount));
}
}
/**
* 당첨자가 없음 예외
*/
public static class NoWinnersYetException extends ParticipationException {
public NoWinnersYetException() {
super(ErrorCode.NO_WINNERS_YET, "아직 당첨자 추첨이 진행되지 않았습니다");
}
}
}

View File

@ -0,0 +1,32 @@
package com.kt.event.participation.infrastructure.config;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.web.SecurityFilterChain;
/**
* Security Configuration for Participation Service
* 이벤트 참여 API는 공개 API로 인증 불필요
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Configuration
@EnableWebSecurity
public class SecurityConfig {
@Bean
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
http
.csrf(csrf -> csrf.disable())
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
.authorizeHttpRequests(auth -> auth
.anyRequest().permitAll()
);
return http.build();
}
}

View File

@ -0,0 +1,39 @@
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.kafka.core.KafkaTemplate;
import org.springframework.stereotype.Service;
/**
* Kafka Producer 서비스
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class KafkaProducerService {
private static final String PARTICIPANT_REGISTERED_TOPIC = "participant-registered-events";
private final KafkaTemplate<String, Object> kafkaTemplate;
/**
* 참여자 등록 이벤트 발행
*
* @param event 참여자 등록 이벤트
*/
public void publishParticipantRegistered(ParticipantRegisteredEvent event) {
try {
kafkaTemplate.send(PARTICIPANT_REGISTERED_TOPIC, event.getEventId(), event);
log.info("Kafka 이벤트 발행 성공 - topic: {}, participantId: {}",
PARTICIPANT_REGISTERED_TOPIC, event.getParticipantId());
} catch (Exception e) {
log.error("Kafka 이벤트 발행 실패 - participantId: {}", event.getParticipantId(), e);
// 이벤트 발행 실패는 서비스 로직에 영향을 주지 않음
}
}
}

View File

@ -0,0 +1,39 @@
package com.kt.event.participation.infrastructure.kafka.event;
import com.kt.event.participation.domain.participant.Participant;
import lombok.*;
import java.time.LocalDateTime;
/**
* 참여자 등록 Kafka 이벤트
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Getter
@NoArgsConstructor
@AllArgsConstructor
@Builder
public class ParticipantRegisteredEvent {
private String participantId;
private String eventId;
private String name;
private String phoneNumber;
private Boolean storeVisited;
private Integer bonusEntries;
private LocalDateTime participatedAt;
public static ParticipantRegisteredEvent from(Participant participant) {
return ParticipantRegisteredEvent.builder()
.participantId(participant.getParticipantId())
.eventId(participant.getEventId())
.name(participant.getName())
.phoneNumber(participant.getPhoneNumber())
.storeVisited(participant.getStoreVisited())
.bonusEntries(participant.getBonusEntries())
.participatedAt(participant.getCreatedAt())
.build();
}
}

View File

@ -0,0 +1,79 @@
package com.kt.event.participation.presentation.controller;
import com.kt.event.common.dto.ApiResponse;
import com.kt.event.common.dto.PageResponse;
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.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 이벤트 참여 컨트롤러
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Slf4j
@RestController
@RequestMapping
@RequiredArgsConstructor
public class ParticipationController {
private final ParticipationService participationService;
/**
* 이벤트 참여
* POST /events/{eventId}/participate
*/
@PostMapping("/events/{eventId}/participate")
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
@PathVariable String eventId,
@Valid @RequestBody ParticipationRequest request) {
log.info("이벤트 참여 요청 - eventId: {}", eventId);
ParticipationResponse response = participationService.participate(eventId, request);
return ResponseEntity
.status(HttpStatus.CREATED)
.body(ApiResponse.success(response));
}
/**
* 참여자 목록 조회
* GET /events/{eventId}/participants
*/
@GetMapping("/events/{eventId}/participants")
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
@PathVariable String eventId,
@RequestParam(required = false) Boolean storeVisited,
@PageableDefault(size = 20) Pageable pageable) {
log.info("참여자 목록 조회 요청 - eventId: {}, storeVisited: {}", eventId, storeVisited);
PageResponse<ParticipationResponse> response =
participationService.getParticipants(eventId, storeVisited, pageable);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 참여자 상세 조회
* GET /events/{eventId}/participants/{participantId}
*/
@GetMapping("/events/{eventId}/participants/{participantId}")
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
@PathVariable String eventId,
@PathVariable String participantId) {
log.info("참여자 상세 조회 요청 - eventId: {}, participantId: {}", eventId, participantId);
ParticipationResponse response = participationService.getParticipant(eventId, participantId);
return ResponseEntity.ok(ApiResponse.success(response));
}
}

View File

@ -0,0 +1,60 @@
package com.kt.event.participation.presentation.controller;
import com.kt.event.common.dto.ApiResponse;
import com.kt.event.common.dto.PageResponse;
import com.kt.event.participation.application.dto.DrawWinnersRequest;
import com.kt.event.participation.application.dto.DrawWinnersResponse;
import com.kt.event.participation.application.dto.ParticipationResponse;
import com.kt.event.participation.application.service.WinnerDrawService;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.domain.Pageable;
import org.springframework.data.web.PageableDefault;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.*;
/**
* 당첨자 추첨 컨트롤러
*
* @author Digital Garage Team
* @since 2025-01-24
*/
@Slf4j
@RestController
@RequestMapping
@RequiredArgsConstructor
public class WinnerController {
private final WinnerDrawService winnerDrawService;
/**
* 당첨자 추첨
* POST /events/{eventId}/draw-winners
*/
@PostMapping("/events/{eventId}/draw-winners")
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
@PathVariable String eventId,
@Valid @RequestBody DrawWinnersRequest request) {
log.info("당첨자 추첨 요청 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount());
DrawWinnersResponse response = winnerDrawService.drawWinners(eventId, request);
return ResponseEntity.ok(ApiResponse.success(response));
}
/**
* 당첨자 목록 조회
* GET /events/{eventId}/winners
*/
@GetMapping("/events/{eventId}/winners")
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
@PathVariable String eventId,
@PageableDefault(size = 20) Pageable pageable) {
log.info("당첨자 목록 조회 요청 - eventId: {}", eventId);
PageResponse<ParticipationResponse> response = winnerDrawService.getWinners(eventId, pageable);
return ResponseEntity.ok(ApiResponse.success(response));
}
}