이벤트의 참여자 목록을 조회합니다.
+ *
+ * getParticipants(
+ @PathVariable("eventId") String eventId,
+ @RequestParam(value = "page", defaultValue = "0") int page,
+ @RequestParam(value = "size", defaultValue = "20") int size,
+ @RequestParam(value = "storeVisited", required = false) Boolean storeVisited,
+ @RequestParam(value = "isWinner", required = false) Boolean isWinner) {
+
+ log.info("GET /events/{}/participants - page: {}, size: {}, storeVisited: {}, isWinner: {}",
+ eventId, page, size, storeVisited, isWinner);
+
+ // 페이지 크기 제한 (최대 100)
+ int validatedSize = Math.min(size, 100);
+
+ Pageable pageable = PageRequest.of(page, validatedSize);
+
+ // 참여 경로는 API 스펙에 없으므로 null로 전달
+ ParticipantListResponse response = participationService.getParticipantList(
+ eventId, null, isWinner, pageable);
+
+ log.info("Participant list fetched successfully - eventId: {}, totalElements: {}",
+ eventId, response.getTotalElements());
+
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 참여자 검색
+ *
+ * 이벤트의 참여자를 이름 또는 전화번호로 검색합니다.
+ *
+ * 기능:
+ *
+ * - 이름 또는 전화번호로 검색 (부분 일치)
+ * - 페이징 지원 (기본: page=0, size=20)
+ * - 전화번호 마스킹 처리 (개인정보 보호)
+ *
+ *
+ * Response:
+ *
+ * - 200 OK: 검색 성공
+ * - 404 Not Found: 이벤트를 찾을 수 없음
+ *
+ *
+ * @param eventId 이벤트 ID (Path Variable)
+ * @param keyword 검색 키워드 (이름 또는 전화번호)
+ * @param page 페이지 번호 (0부터 시작, 기본값: 0)
+ * @param size 페이지 크기 (기본값: 20, 최대: 100)
+ * @return 검색된 참여자 목록 (페이징 정보 포함)
+ */
+ @GetMapping("/participants/search")
+ public ResponseEntity searchParticipants(
+ @PathVariable("eventId") String eventId,
+ @RequestParam("keyword") String keyword,
+ @RequestParam(value = "page", defaultValue = "0") int page,
+ @RequestParam(value = "size", defaultValue = "20") int size) {
+
+ log.info("GET /events/{}/participants/search - keyword: {}, page: {}, size: {}",
+ eventId, keyword, page, size);
+
+ // 페이지 크기 제한 (최대 100)
+ int validatedSize = Math.min(size, 100);
+
+ Pageable pageable = PageRequest.of(page, validatedSize);
+
+ ParticipantListResponse response = participationService.searchParticipants(
+ eventId, keyword, pageable);
+
+ log.info("Participants searched successfully - eventId: {}, keyword: {}, totalElements: {}",
+ eventId, keyword, response.getTotalElements());
+
+ return ResponseEntity.ok(response);
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
new file mode 100644
index 0000000..6de791c
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
@@ -0,0 +1,114 @@
+package com.kt.event.participation.presentation.controller;
+
+import com.kt.event.participation.application.dto.WinnerDrawRequest;
+import com.kt.event.participation.application.dto.WinnerDrawResponse;
+import com.kt.event.participation.application.dto.WinnerDto;
+import com.kt.event.participation.application.service.WinnerDrawService;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.http.ResponseEntity;
+import org.springframework.web.bind.annotation.*;
+
+import java.util.List;
+
+/**
+ * 당첨자 추첨 및 관리 컨트롤러
+ * - 당첨자 추첨 실행
+ * - 당첨자 목록 조회
+ *
+ * RESTful API 설계 원칙:
+ * - Base Path: /events/{eventId}
+ * - HTTP Method 사용: POST (추첨), GET (조회)
+ * - HTTP Status Code: 200 (성공), 400 (잘못된 요청), 404 (찾을 수 없음), 409 (중복 추첨)
+ * - Request Validation: @Valid 사용하여 요청 검증
+ * - Error Handling: GlobalExceptionHandler에서 처리
+ *
+ * @author Digital Garage Team
+ * @since 2025-10-23
+ */
+@Slf4j
+@RestController
+@RequestMapping("/events/{eventId}")
+@RequiredArgsConstructor
+public class WinnerController {
+
+ private final WinnerDrawService winnerDrawService;
+
+ /**
+ * 당첨자 추첨
+ *
+ * 이벤트 당첨자를 추첨합니다.
+ *
+ * 비즈니스 로직:
+ *
+ * - 중복 추첨 검증 (이벤트별 1회만 가능)
+ * - 참여자 수 검증 (당첨자 수보다 많아야 함)
+ * - Fisher-Yates Shuffle 알고리즘 사용
+ * - 매장 방문 보너스 가중치 적용 (선택)
+ * - 추첨 로그 저장 (감사 추적)
+ *
+ *
+ * Response:
+ *
+ * - 200 OK: 추첨 성공
+ * - 400 Bad Request: 유효하지 않은 요청 (당첨자 수가 참여자 수보다 많음)
+ * - 404 Not Found: 이벤트를 찾을 수 없음
+ * - 409 Conflict: 이미 추첨 완료
+ *
+ *
+ * @param eventId 이벤트 ID (Path Variable)
+ * @param request 추첨 요청 정보 (당첨자 수, 매장 방문 보너스 적용 여부)
+ * @return 추첨 결과 (당첨자 목록, 추첨 일시, 추첨 로그 ID 등)
+ */
+ @PostMapping("/draw-winners")
+ public ResponseEntity drawWinners(
+ @PathVariable("eventId") String eventId,
+ @Valid @RequestBody WinnerDrawRequest request) {
+
+ log.info("POST /events/{}/draw-winners - winnerCount: {}, visitBonusApplied: {}",
+ eventId, request.getWinnerCount(), request.getVisitBonusApplied());
+
+ WinnerDrawResponse response = winnerDrawService.drawWinners(eventId, request);
+
+ log.info("Winners drawn successfully - eventId: {}, drawLogId: {}, winnerCount: {}",
+ eventId, response.getDrawLogId(), response.getWinnerCount());
+
+ return ResponseEntity.ok(response);
+ }
+
+ /**
+ * 당첨자 목록 조회
+ *
+ * 이벤트의 당첨자 목록을 조회합니다.
+ *
+ * 기능:
+ *
+ * - 당첨 순위별 정렬 (당첨 일시 내림차순)
+ * - 전화번호 마스킹 처리 (개인정보 보호)
+ * - 응모 번호 포함
+ *
+ *
+ * Response:
+ *
+ * - 200 OK: 조회 성공
+ * - 404 Not Found: 이벤트를 찾을 수 없음 또는 당첨자가 없음
+ *
+ *
+ * @param eventId 이벤트 ID (Path Variable)
+ * @return 당첨자 목록 (당첨 순위, 이름, 마스킹된 전화번호, 당첨 일시 등)
+ */
+ @GetMapping("/winners")
+ public ResponseEntity> getWinners(
+ @PathVariable("eventId") String eventId) {
+
+ log.info("GET /events/{}/winners", eventId);
+
+ List winners = winnerDrawService.getWinners(eventId);
+
+ log.info("Winners fetched successfully - eventId: {}, count: {}",
+ eventId, winners.size());
+
+ return ResponseEntity.ok(winners);
+ }
+}
diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml
new file mode 100644
index 0000000..7fc673d
--- /dev/null
+++ b/participation-service/src/main/resources/application.yml
@@ -0,0 +1,88 @@
+spring:
+ application:
+ name: participation-service
+
+ datasource:
+ url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:participation_db}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8
+ username: ${DB_USER:root}
+ password: ${DB_PASSWORD:password}
+ driver-class-name: com.mysql.cj.jdbc.Driver
+ hikari:
+ maximum-pool-size: ${DB_POOL_SIZE:10}
+ minimum-idle: ${DB_MIN_IDLE:5}
+ connection-timeout: ${DB_CONN_TIMEOUT:30000}
+ idle-timeout: ${DB_IDLE_TIMEOUT:600000}
+ max-lifetime: ${DB_MAX_LIFETIME:1800000}
+
+ jpa:
+ hibernate:
+ ddl-auto: ${JPA_DDL_AUTO:validate}
+ show-sql: ${JPA_SHOW_SQL:false}
+ properties:
+ hibernate:
+ format_sql: true
+ use_sql_comments: true
+ dialect: org.hibernate.dialect.MySQL8Dialect
+
+ # Redis Configuration
+ data:
+ redis:
+ host: ${REDIS_HOST:localhost}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:}
+ timeout: ${REDIS_TIMEOUT:3000}
+ lettuce:
+ pool:
+ max-active: ${REDIS_POOL_MAX_ACTIVE:8}
+ max-idle: ${REDIS_POOL_MAX_IDLE:8}
+ min-idle: ${REDIS_POOL_MIN_IDLE:2}
+ max-wait: ${REDIS_POOL_MAX_WAIT:3000}
+
+ # Kafka Configuration
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ acks: ${KAFKA_PRODUCER_ACKS:all}
+ retries: ${KAFKA_PRODUCER_RETRIES:3}
+ properties:
+ max.in.flight.requests.per.connection: 1
+ enable.idempotence: true
+ # Topic Names
+ topics:
+ participant-registered: participant-events
+
+server:
+ port: ${SERVER_PORT:8084}
+ servlet:
+ context-path: /
+ error:
+ include-message: always
+ include-binding-errors: always
+
+# Logging
+logging:
+ level:
+ root: ${LOG_LEVEL_ROOT:INFO}
+ com.kt.event.participation: ${LOG_LEVEL_APP:DEBUG}
+ org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO}
+ org.springframework.kafka: ${LOG_LEVEL_KAFKA:INFO}
+ pattern:
+ console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
+ file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
+ file:
+ name: ${LOG_FILE_PATH:./logs}/participation-service.log
+ max-size: ${LOG_FILE_MAX_SIZE:10MB}
+ max-history: ${LOG_FILE_MAX_HISTORY:30}
+
+# Application-specific Configuration
+app:
+ cache:
+ duplicate-check-ttl: ${CACHE_DUPLICATE_TTL:604800} # 7 days in seconds
+ participant-list-ttl: ${CACHE_PARTICIPANT_TTL:600} # 10 minutes in seconds
+ lottery:
+ algorithm: FISHER_YATES_SHUFFLE
+ visit-bonus-weight: ${LOTTERY_VISIT_BONUS:2.0} # 매장 방문 고객 가중치
+ security:
+ phone-mask-pattern: "***-****-***" # 전화번호 마스킹 패턴