diff --git a/.claude/settings.local.json b/.claude/settings.local.json
index 8d1f14d..0c539cf 100644
--- a/.claude/settings.local.json
+++ b/.claude/settings.local.json
@@ -15,7 +15,35 @@
"Bash(git add:*)",
"Bash(git commit:*)",
"Bash(git push)",
- "Bash(git pull:*)"
+ "Bash(git pull:*)",
+ "Bash(./gradlew participation-service:compileJava:*)",
+ "Bash(find:*)",
+ "Bash(netstat:*)",
+ "Bash(findstr:*)",
+ "Bash(docker-compose up:*)",
+ "Bash(docker --version:*)",
+ "Bash(timeout 60 bash:*)",
+ "Bash(docker ps:*)",
+ "Bash(docker exec:*)",
+ "Bash(docker-compose down:*)",
+ "Bash(git rm:*)",
+ "Bash(git restore:*)",
+ "Bash(./gradlew participation-service:test:*)",
+ "Bash(timeout 30 bash:*)",
+ "Bash(helm list:*)",
+ "Bash(helm upgrade:*)",
+ "Bash(helm repo add:*)",
+ "Bash(helm repo update:*)",
+ "Bash(kubectl get:*)",
+ "Bash(python3:*)",
+ "Bash(timeout 120 bash -c 'while true; do sleep 5; kubectl get pods -n kt-event-marketing | grep kafka | grep -v Running && continue; echo \"\"\"\"All Kafka pods are Running!\"\"\"\"; break; done')",
+ "Bash(kubectl delete:*)",
+ "Bash(kubectl logs:*)",
+ "Bash(kubectl describe:*)",
+ "Bash(kubectl exec:*)",
+ "mcp__context7__resolve-library-id",
+ "mcp__context7__get-library-docs",
+ "Bash(python -m json.tool:*)"
],
"deny": [],
"ask": []
diff --git a/.gitignore b/.gitignore
index 4126c1d..b1f9379 100644
--- a/.gitignore
+++ b/.gitignore
@@ -21,6 +21,7 @@ dist/
build/
*.log
.gradle/
+logs/
# Environment
.env
@@ -31,3 +32,6 @@ build/
tmp/
temp/
*.tmp
+
+# Docker (로컬 개발용)
+backing-service/docker-compose.yml
diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml
new file mode 100644
index 0000000..a323100
--- /dev/null
+++ b/.run/ParticipationServiceApplication.run.xml
@@ -0,0 +1,69 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/claude/make-run-profile.md b/claude/make-run-profile.md
new file mode 100644
index 0000000..420fb4e
--- /dev/null
+++ b/claude/make-run-profile.md
@@ -0,0 +1,178 @@
+ % Total % Received % Xferd Average Speed Time Time Time Current
+ Dload Upload Total Spent Left Speed
+
0 0 0 0 0 0 0 0 --:--:-- --:--:-- --:--:-- 0# 서비스실행파일작성가이드
+
+[요청사항]
+- <수행원칙>을 준용하여 수행
+- <수행순서>에 따라 수행
+- [결과파일] 안내에 따라 파일 작성
+
+[가이드]
+<수행원칙>
+- 설정 Manifest(src/main/resources/application*.yml)의 각 항목의 값은 하드코딩하지 않고 환경변수 처리
+- Kubernetes에 배포된 데이터베이스는 LoadBalacer유형의 Service를 만들어 연결
+- MQ 이용 시 'MQ설치결과서'의 연결 정보를 실행 프로파일의 환경변수로 등록
+<수행순서>
+- 준비:
+ - 데이터베이스설치결과서(develop/database/exec/db-exec-dev.md) 분석
+ - 캐시설치결과서(develop/database/exec/cache-exec-dev.md) 분석
+ - MQ설치결과서(develop/mq/mq-exec-dev.md) 분석 - 연결 정보 확인
+ - kubectl get svc -n tripgen-dev | grep LoadBalancer 실행하여 External IP 목록 확인
+- 실행:
+ - 각 서비스별를 서브에이젼트로 병렬 수행
+ - 설정 Manifest 수정
+ - 하드코딩 되어 있는 값이 있으면 환경변수로 변환
+ - 특히, 데이터베이스, MQ 등의 연결 정보는 반드시 환경변수로 변환해야 함
+ - 민감한 정보의 디퐅트값은 생략하거나 간략한 값으로 지정
+ - '<로그설정>'을 참조하여 Log 파일 설정
+ - '<실행프로파일 작성 가이드>'에 따라 서비스 실행프로파일 작성
+ - LoadBalancer External IP를 DB_HOST, REDIS_HOST로 설정
+ - MQ 연결 정보를 application.yml의 환경변수명에 맞춰 설정
+ - 서비스 실행 및 오류 수정
+ - 'IntelliJ서비스실행기'를 'tools' 디렉토리에 다운로드
+ - python 또는 python3 명령으로 백그라우드로 실행하고 결과 로그를 분석
+ nohup python3 tools/run-intellij-service-profile.py {service-name} > logs/{service-name}.log 2>&1 & echo "Started {service-name} with PID: $!"
+ - 서비스 실행은 다른 방법 사용하지 말고 **반드시 python 프로그램 이용**
+ - 오류 수정 후 필요 시 실행파일의 환경변수를 올바르게 변경
+ - 서비스 정상 시작 확인 후 서비스 중지
+ - 결과: {service-name}/.run
+<서비스 중지 방법>
+- Window
+ - netstat -ano | findstr :{PORT}
+ - powershell "Stop-Process -Id {Process number} -Force"
+- Linux/Mac
+ - netstat -ano | grep {PORT}
+ - kill -9 {Process number}
+<로그설정>
+- **application.yml 로그 파일 설정**:
+ ```yaml
+ logging:
+ file:
+ name: ${LOG_FILE:logs/trip-service.log}
+ logback:
+ rollingpolicy:
+ max-file-size: 10MB
+ max-history: 7
+ total-size-cap: 100MB
+ ```
+
+<실행프로파일 작성 가이드>
+- {service-name}/.run/{service-name}.run.xml 파일로 작성
+- Spring Boot가 아니고 **Gradle 실행 프로파일**이어야 함: '[실행프로파일 예시]' 참조
+- Kubernetes에 배포된 데이터베이스의 LoadBalancer Service 확인:
+ - kubectl get svc -n {namespace} | grep LoadBalancer 명령으로 LoadBalancer IP 확인
+ - 각 서비스별 데이터베이스의 LoadBalancer External IP를 DB_HOST로 사용
+ - 캐시(Redis)의 LoadBalancer External IP를 REDIS_HOST로 사용
+- MQ 연결 설정:
+ - MQ설치결과서(develop/mq/mq-exec-dev.md)에서 연결 정보 확인
+ - MQ 유형에 따른 연결 정보 설정 예시:
+ - RabbitMQ: RABBITMQ_HOST, RABBITMQ_PORT, RABBITMQ_USERNAME, RABBITMQ_PASSWORD
+ - Kafka: KAFKA_BOOTSTRAP_SERVERS, KAFKA_SECURITY_PROTOCOL
+ - Azure Service Bus: SERVICE_BUS_CONNECTION_STRING
+ - AWS SQS: AWS_REGION, AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY
+ - Redis (Pub/Sub): REDIS_HOST, REDIS_PORT, REDIS_PASSWORD
+ - ActiveMQ: ACTIVEMQ_BROKER_URL, ACTIVEMQ_USER, ACTIVEMQ_PASSWORD
+ - 기타 MQ: 해당 MQ의 연결에 필요한 호스트, 포트, 인증정보, 연결문자열 등을 환경변수로 설정
+ - application.yml에 정의된 환경변수명 확인 후 매핑
+- 백킹서비스 연결 정보 매핑:
+ - 데이터베이스설치결과서에서 각 서비스별 DB 인증 정보 확인
+ - 캐시설치결과서에서 각 서비스별 Redis 인증 정보 확인
+ - LoadBalancer의 External IP를 호스트로 사용 (내부 DNS 아님)
+- 개발모드의 DDL_AUTO값은 update로 함
+- JWT Secret Key는 모든 서비스가 동일해야 함
+- application.yaml의 환경변수와 일치하도록 환경변수 설정
+- application.yaml의 민감 정보는 기본값으로 지정하지 않고 실제 백킹서비스 정보로 지정
+- 백킹서비스 연결 확인 결과를 바탕으로 정확한 값을 지정
+- 기존에 파일이 있으면 내용을 분석하여 항목 추가/수정/삭제
+
+[실행프로파일 예시]
+```
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
+```
+
+[참고자료]
+- 데이터베이스설치결과서: develop/database/exec/db-exec-dev.md
+ - 각 서비스별 DB 연결 정보 (사용자명, 비밀번호, DB명)
+ - LoadBalancer Service External IP 목록
+- 캐시설치결과서: develop/database/exec/cache-exec-dev.md
+ - 각 서비스별 Redis 연결 정보
+ - LoadBalancer Service External IP 목록
+- MQ설치결과서: develop/mq/mq-exec-dev.md
+ - MQ 유형 및 연결 정보
+ - 연결에 필요한 호스트, 포트, 인증 정보
+ - LoadBalancer Service External IP (해당하는 경우)
+
diff --git a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
index bd422c5..0065a7a 100644
--- a/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
+++ b/common/src/main/java/com/kt/event/common/exception/ErrorCode.java
@@ -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", "분석 데이터를 찾을 수 없습니다"),
diff --git a/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
index d382813..d5fc76b 100644
--- a/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
+++ b/common/src/main/java/com/kt/event/common/exception/GlobalExceptionHandler.java
@@ -2,6 +2,8 @@ package com.kt.event.common.exception;
import com.kt.event.common.dto.ErrorResponse;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.dao.DataIntegrityViolationException;
+import org.springframework.data.mapping.PropertyReferenceException;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.AccessDeniedException;
@@ -161,6 +163,66 @@ public class GlobalExceptionHandler {
.body(errorResponse);
}
+ /**
+ * 데이터 무결성 제약 위반 예외 처리
+ *
+ * @param ex 데이터 무결성 예외
+ * @return 에러 응답
+ */
+ @ExceptionHandler(DataIntegrityViolationException.class)
+ public ResponseEntity handleDataIntegrityViolationException(DataIntegrityViolationException ex) {
+ log.warn("Data integrity violation: {}", ex.getMessage());
+
+ String message = "데이터 중복 또는 무결성 제약 위반이 발생했습니다";
+ String details = ex.getMessage();
+
+ // 중복 키 에러인 경우 메시지 개선
+ if (ex.getMessage() != null) {
+ if (ex.getMessage().contains("uk_event_phone") || ex.getMessage().contains("phone_number")) {
+ message = "이미 참여하신 이벤트입니다";
+ details = "동일한 전화번호로 이미 참여 기록이 있습니다";
+ } else if (ex.getMessage().contains("participant_id")) {
+ message = "참여 처리 중 오류가 발생했습니다";
+ details = "잠시 후 다시 시도해주세요";
+ }
+ }
+
+ ErrorResponse errorResponse = ErrorResponse.of(
+ ErrorCode.DUPLICATE_PARTICIPATION.getCode(),
+ message,
+ details
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.CONFLICT)
+ .body(errorResponse);
+ }
+
+ /**
+ * 잘못된 정렬 필드 예외 처리
+ *
+ * @param ex 속성 참조 예외
+ * @return 에러 응답
+ */
+ @ExceptionHandler(PropertyReferenceException.class)
+ public ResponseEntity handlePropertyReferenceException(PropertyReferenceException ex) {
+ log.warn("Invalid sort property: {}", ex.getMessage());
+
+ String message = "잘못된 정렬 필드입니다";
+ String details = String.format("'%s' 필드는 존재하지 않습니다. 사용 가능한 필드: id, participantId, eventId, name, phoneNumber, email, storeVisited, bonusEntries, agreeMarketing, agreePrivacy, isWinner, winnerRank, wonAt, createdAt, updatedAt",
+ ex.getPropertyName());
+
+ ErrorResponse errorResponse = ErrorResponse.of(
+ ErrorCode.COMMON_003.getCode(),
+ message,
+ details
+ );
+
+ return ResponseEntity
+ .status(HttpStatus.BAD_REQUEST)
+ .body(errorResponse);
+ }
+
/**
* 일반 예외 처리
*
diff --git a/develop/dev/test-backend-participation.md b/develop/dev/test-backend-participation.md
new file mode 100644
index 0000000..5c7a032
--- /dev/null
+++ b/develop/dev/test-backend-participation.md
@@ -0,0 +1,206 @@
+# Participation Service 백엔드 테스트 결과
+
+## 테스트 정보
+- **테스트 일시**: 2025-10-27
+- **서비스**: participation-service
+- **포트**: 8084
+- **테스트 수행자**: AI Assistant
+
+## 1. 실행 프로파일 작성
+
+### 1.1 작성된 파일
+1. **`.run/ParticipationServiceApplication.run.xml`**
+ - IntelliJ Gradle 실행 프로파일
+ - 16개 환경 변수 설정
+
+2. **`participation-service/.run/participation-service.run.xml`**
+ - 서비스별 실행 프로파일
+ - 동일한 환경 변수 구성
+
+### 1.2 환경 변수 구성
+```yaml
+# 서버 설정
+SERVER_PORT: 8084
+
+# 데이터베이스 설정
+DB_HOST: 4.230.72.147
+DB_PORT: 5432
+DB_NAME: participationdb
+DB_USERNAME: eventuser
+DB_PASSWORD: Hi5Jessica!
+
+# JPA 설정
+DDL_AUTO: validate # ✅ update → validate로 수정
+SHOW_SQL: true
+
+# Redis 설정 (추가됨)
+REDIS_HOST: 20.214.210.71
+REDIS_PORT: 6379
+REDIS_PASSWORD: Hi5Jessica!
+
+# Kafka 설정
+KAFKA_BOOTSTRAP_SERVERS: 20.249.182.13:9095,4.217.131.59:9095
+
+# JWT 설정
+JWT_SECRET: kt-event-marketing-secret-key-for-development-only-change-in-production
+JWT_EXPIRATION: 86400000
+
+# 로깅 설정
+LOG_LEVEL: INFO
+LOG_FILE: logs/participation-service.log
+```
+
+## 2. 발생한 오류 및 수정 내역
+
+### 2.1 오류 1: PostgreSQL 인덱스 중복
+**증상**:
+```
+Caused by: org.postgresql.util.PSQLException: ERROR: relation "idx_event_id" already exists
+```
+
+**원인**:
+- Hibernate DDL 모드가 `update`로 설정되어 이미 존재하는 인덱스를 생성하려고 시도
+
+**수정**:
+- `application.yml`: `ddl-auto: ${DDL_AUTO:validate}`로 변경
+- 실행 프로파일: `DDL_AUTO=validate`로 설정
+- **파일**:
+ - `participation-service/src/main/resources/application.yml` (21번 라인)
+ - `.run/ParticipationServiceApplication.run.xml` (17번 라인)
+ - `participation-service/.run/participation-service.run.xml` (17번 라인)
+
+### 2.2 오류 2: Redis 연결 실패
+**증상**:
+```
+Caused by: io.lettuce.core.RedisConnectionException: Unable to connect to localhost/:6379
+```
+
+**원인**:
+- Redis 설정이 `application.yml`에 완전히 누락되어 기본값(localhost:6379)으로 연결 시도
+
+**수정**:
+- `application.yml`에 Redis 설정 섹션 추가:
+```yaml
+spring:
+ data:
+ redis:
+ host: ${REDIS_HOST:20.214.210.71}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:Hi5Jessica!}
+ timeout: 3000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 8
+ min-idle: 2
+ max-wait: -1ms
+```
+- 실행 프로파일에 Redis 환경 변수 3개 추가
+- **파일**:
+ - `participation-service/src/main/resources/application.yml` (29-41번 라인)
+ - `.run/ParticipationServiceApplication.run.xml` (20-22번 라인)
+ - `participation-service/.run/participation-service.run.xml` (20-22번 라인)
+
+### 2.3 오류 3: PropertyReferenceException (해결됨)
+**증상**:
+```
+org.springframework.data.mapping.PropertyReferenceException: No property 'string' found for type 'Participant'
+```
+
+**상태**:
+- 위의 설정 수정 후 더 이상 발생하지 않음
+- 현재 API 호출 시 정상 동작 확인
+
+## 3. 테스트 결과
+
+### 3.1 서비스 상태 확인
+```bash
+$ curl -s "http://localhost:8084/actuator/health"
+{
+ "status": "UP"
+}
+```
+✅ **결과**: 정상 (UP)
+
+### 3.2 API 엔드포인트 테스트
+
+#### 참여자 목록 조회
+```bash
+$ curl "http://localhost:8084/events/3/participants?storeVisited=true"
+{
+ "success": true,
+ "data": {
+ "content": [],
+ "page": 0,
+ "size": 20,
+ "totalElements": 0,
+ "totalPages": 0,
+ "first": true,
+ "last": true
+ },
+ "timestamp": "2025-10-27T10:30:28.622134"
+}
+```
+✅ **결과**: HTTP 200, 정상 응답 (데이터 없음은 정상)
+
+### 3.3 인프라 연결 상태
+
+| 구성요소 | 상태 | 접속 정보 |
+|---------|------|-----------|
+| PostgreSQL | ✅ 정상 | 4.230.72.147:5432/participationdb |
+| Redis | ✅ 정상 | 20.214.210.71:6379 |
+| Kafka | ✅ 정상 | 20.249.182.13:9095,4.217.131.59:9095 |
+
+## 4. 수정된 파일 목록
+
+1. **`participation-service/src/main/resources/application.yml`**
+ - JPA DDL 모드: `update` → `validate`
+ - Redis 설정 전체 추가
+
+2. **`.run/ParticipationServiceApplication.run.xml`**
+ - DDL_AUTO 환경 변수: `update` → `validate`
+ - Redis 환경 변수 3개 추가 (REDIS_HOST, REDIS_PORT, REDIS_PASSWORD)
+
+3. **`participation-service/.run/participation-service.run.xml`**
+ - DDL_AUTO 환경 변수: `update` → `validate`
+ - Redis 환경 변수 3개 추가
+
+## 5. 결론
+
+### 5.1 테스트 성공 여부
+✅ **성공**: 모든 오류가 수정되었고 서비스가 정상적으로 작동함
+
+### 5.2 주요 성과
+1. ✅ IntelliJ 실행 프로파일 작성 완료
+2. ✅ PostgreSQL 인덱스 중복 오류 해결
+3. ✅ Redis 연결 설정 완료
+4. ✅ PropertyReferenceException 오류 해결
+5. ✅ Health 체크 통과 (모든 인프라 연결 정상)
+6. ✅ API 엔드포인트 정상 동작 확인
+
+### 5.3 권장사항
+1. **프로덕션 환경**:
+ - `DDL_AUTO`를 `none`으로 설정하고 Flyway/Liquibase 같은 마이그레이션 도구 사용 권장
+ - JWT_SECRET을 안전한 값으로 변경 필수
+
+2. **로깅**:
+ - 프로덕션에서는 `SHOW_SQL=false`로 설정 권장
+ - LOG_LEVEL을 `WARN` 또는 `ERROR`로 조정
+
+3. **테스트 데이터**:
+ - 현재 참여자 데이터가 없으므로 테스트 데이터 추가 고려
+
+## 6. 다음 단계
+
+1. **API 통합 테스트**:
+ - 참여자 등록 API 테스트
+ - 참여자 조회 API 테스트
+ - 당첨자 추첨 API 테스트
+
+2. **성능 테스트**:
+ - 대량 참여자 등록 시나리오
+ - 동시 접속 테스트
+
+3. **E2E 테스트**:
+ - Event Service와의 통합 테스트
+ - Kafka 이벤트 발행/구독 테스트
diff --git a/develop/mq/mq-exec-dev.md b/develop/mq/mq-exec-dev.md
index 52baedb..7517845 100644
--- a/develop/mq/mq-exec-dev.md
+++ b/develop/mq/mq-exec-dev.md
@@ -3,9 +3,9 @@
## 설치 정보
### Kafka 브로커 정보
-- **Host**: 4.230.50.63
-- **Port**: 9092
-- **Broker 주소**: 4.230.50.63:9092
+- **Host**: 4.217.131.59
+- **Port**: 9095
+- **Broker 주소**: 4.217.131.59:9095
### Consumer Group ID 설정
| 서비스 | Consumer Group ID | 설명 |
@@ -32,7 +32,7 @@ spring:
### 환경 변수 설정
```bash
-export KAFKA_BOOTSTRAP_SERVERS=4.230.50.63:9092
+export KAFKA_BOOTSTRAP_SERVERS=20.249.182.13:9095,4.217.131.59:9095
export KAFKA_CONSUMER_GROUP_ID=ai # 또는 analytic
```
diff --git a/participation-service/.run/ParticipationServiceApplication.run.xml b/participation-service/.run/ParticipationServiceApplication.run.xml
new file mode 100644
index 0000000..cfab385
--- /dev/null
+++ b/participation-service/.run/ParticipationServiceApplication.run.xml
@@ -0,0 +1,64 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
diff --git a/participation-service/.run/participation-service.run.xml b/participation-service/.run/participation-service.run.xml
new file mode 100644
index 0000000..ba99e03
--- /dev/null
+++ b/participation-service/.run/participation-service.run.xml
@@ -0,0 +1,56 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ true
+ true
+
+
+
+
+
+
+
+
+
+
+
+
+ false
+ false
+
+
+
\ No newline at end of file
diff --git a/participation-service/build.gradle b/participation-service/build.gradle
index c5507a9..12730de 100644
--- a/participation-service/build.gradle
+++ b/participation-service/build.gradle
@@ -1,7 +1,51 @@
+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'
+ testRuntimeOnly 'com.h2database:h2'
+}
+
+tasks.named('test') {
+ useJUnitPlatform()
}
diff --git a/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java
new file mode 100644
index 0000000..1edcb91
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java
@@ -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);
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java
new file mode 100644
index 0000000..5e167cc
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersRequest.java
@@ -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;
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java
new file mode 100644
index 0000000..d9ff7a0
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/DrawWinnersResponse.java
@@ -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 winners;
+
+ @Getter
+ @NoArgsConstructor
+ @AllArgsConstructor
+ @Builder
+ public static class WinnerSummary {
+ private String participantId;
+ private String name;
+ private String phoneNumber;
+ private Integer rank;
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java
new file mode 100644
index 0000000..6f85b6c
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java
@@ -0,0 +1,37 @@
+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 String channel = "SNS";
+
+ @Builder.Default
+ private Boolean agreeMarketing = false;
+
+ @NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다")
+ private Boolean agreePrivacy;
+
+ @Builder.Default
+ private Boolean storeVisited = false;
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java
new file mode 100644
index 0000000..9ffeec4
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java
@@ -0,0 +1,42 @@
+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 String channel;
+ 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())
+ .channel(participant.getChannel())
+ .participatedAt(participant.getCreatedAt())
+ .storeVisited(participant.getStoreVisited())
+ .bonusEntries(participant.getBonusEntries())
+ .isWinner(participant.getIsWinner())
+ .build();
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java
new file mode 100644
index 0000000..27b5acc
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java
@@ -0,0 +1,133 @@
+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 static com.kt.event.participation.exception.ParticipationException.EventNotFoundException;
+import static com.kt.event.participation.exception.ParticipationException.ParticipantNotFoundException;
+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;
+
+import java.util.Optional;
+
+/**
+ * 이벤트 참여 서비스
+ *
+ * @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())
+ .channel(request.getChannel())
+ .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 getParticipants(
+ String eventId, Boolean storeVisited, Pageable pageable) {
+
+ Page participantPage;
+ if (storeVisited != null) {
+ participantPage = participantRepository
+ .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(eventId, storeVisited, pageable);
+ } else {
+ participantPage = participantRepository
+ .findByEventIdOrderByCreatedAtDesc(eventId, pageable);
+ }
+
+ Page 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) {
+ // 참여자 조회
+ Optional participantOpt = participantRepository
+ .findByEventIdAndParticipantId(eventId, participantId);
+
+ // 참여자가 없으면 이벤트 존재 여부 확인
+ if (participantOpt.isEmpty()) {
+ long participantCount = participantRepository.countByEventId(eventId);
+ if (participantCount == 0) {
+ // 이벤트에 참여자가 한 명도 없음 = 이벤트가 존재하지 않음
+ throw new EventNotFoundException();
+ }
+ // 이벤트는 존재하지만 해당 참여자가 없음
+ throw new ParticipantNotFoundException();
+ }
+
+ return ParticipationResponse.from(participantOpt.get());
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java
new file mode 100644
index 0000000..68cb4e0
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java
@@ -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 participants = participantRepository.findByEventIdAndIsWinnerFalse(eventId);
+ long participantCount = participants.size();
+
+ // 참여자 수 검증
+ if (participantCount < request.getWinnerCount()) {
+ throw new InsufficientParticipantsException(participantCount, request.getWinnerCount());
+ }
+
+ // 가중치 적용 추첨 풀 생성
+ List drawPool = createDrawPool(participants, request.getApplyStoreVisitBonus());
+
+ // 추첨 실행
+ Collections.shuffle(drawPool);
+ List 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 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 getWinners(String eventId, Pageable pageable) {
+ // 추첨 완료 확인
+ if (!drawLogRepository.existsByEventId(eventId)) {
+ throw new NoWinnersYetException();
+ }
+
+ Page winnerPage = participantRepository
+ .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(eventId, pageable);
+
+ Page responsePage = winnerPage.map(ParticipationResponse::from);
+ return PageResponse.of(responsePage);
+ }
+
+ /**
+ * 추첨 풀 생성 (매장 방문 보너스 적용)
+ *
+ * @param participants 참여자 목록
+ * @param applyBonus 보너스 적용 여부
+ * @return 추첨 풀
+ */
+ private List createDrawPool(List participants, Boolean applyBonus) {
+ if (!applyBonus) {
+ return new ArrayList<>(participants);
+ }
+
+ List pool = new ArrayList<>();
+ for (Participant participant : participants) {
+ // 보너스 응모권 수만큼 추첨 풀에 추가
+ int entries = participant.getBonusEntries();
+ for (int i = 0; i < entries; i++) {
+ pool.add(participant);
+ }
+ }
+ return pool;
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java
new file mode 100644
index 0000000..748f68c
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java
@@ -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;
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java
new file mode 100644
index 0000000..432aa6e
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java
@@ -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 {
+
+ /**
+ * 이벤트 ID로 추첨 로그 조회
+ * 이미 추첨이 진행되었는지 확인
+ *
+ * @param eventId 이벤트 ID
+ * @return 추첨 로그 Optional
+ */
+ Optional findByEventId(String eventId);
+
+ /**
+ * 이벤트 ID로 추첨 여부 확인
+ *
+ * @param eventId 이벤트 ID
+ * @return 추첨 여부
+ */
+ boolean existsByEventId(String eventId);
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java
new file mode 100644
index 0000000..0aac1f8
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java
@@ -0,0 +1,180 @@
+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;
+
+ /**
+ * 참여 채널
+ * 기본값: SNS
+ * TODO: 기존 데이터 마이그레이션 후 nullable = false로 변경
+ */
+ @Column(name = "channel", length = 20, nullable = true)
+ private String channel;
+
+ /**
+ * 매장 방문 여부
+ * 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) {
+ // eventId가 "evt_YYYYMMDD_XXX" 형식인 경우
+ if (eventId != null && eventId.length() >= 16 && eventId.startsWith("evt_")) {
+ String dateTime = eventId.substring(4, 12); // "20250124"
+ return String.format("prt_%s_%03d", dateTime, sequenceNumber);
+ }
+
+ // 그 외의 경우 (짧은 eventId 등): 현재 날짜 사용
+ String dateTime = java.time.LocalDate.now().format(
+ java.time.format.DateTimeFormatter.ofPattern("yyyyMMdd"));
+ return String.format("prt_%s_%03d", dateTime, sequenceNumber);
+ }
+
+ /**
+ * 보너스 응모권 계산
+ *
+ * @param storeVisited 매장 방문 여부
+ * @return 보너스 응모권 수
+ */
+ public static Integer calculateBonusEntries(Boolean storeVisited) {
+ return storeVisited ? 5 : 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;
+ }
+ if (this.channel == null || this.channel.isBlank()) {
+ this.channel = "SNS";
+ }
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java
new file mode 100644
index 0000000..d7563dd
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java
@@ -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 {
+
+ /**
+ * 참여자 ID로 조회
+ *
+ * @param participantId 참여자 ID
+ * @return 참여자 Optional
+ */
+ Optional findByParticipantId(String participantId);
+
+ /**
+ * 이벤트 ID와 전화번호로 중복 참여 체크
+ *
+ * @param eventId 이벤트 ID
+ * @param phoneNumber 전화번호
+ * @return 참여 여부
+ */
+ boolean existsByEventIdAndPhoneNumber(String eventId, String phoneNumber);
+
+ /**
+ * 이벤트 ID로 참여자 목록 조회 (페이징)
+ *
+ * @param eventId 이벤트 ID
+ * @param pageable 페이징 정보
+ * @return 참여자 페이지
+ */
+ Page findByEventIdOrderByCreatedAtDesc(String eventId, Pageable pageable);
+
+ /**
+ * 이벤트 ID와 매장 방문 여부로 참여자 목록 조회 (페이징)
+ *
+ * @param eventId 이벤트 ID
+ * @param storeVisited 매장 방문 여부
+ * @param pageable 페이징 정보
+ * @return 참여자 페이지
+ */
+ Page findByEventIdAndStoreVisitedOrderByCreatedAtDesc(
+ String eventId, Boolean storeVisited, Pageable pageable);
+
+ /**
+ * 이벤트 ID로 전체 참여자 수 조회
+ *
+ * @param eventId 이벤트 ID
+ * @return 참여자 수
+ */
+ long countByEventId(String eventId);
+
+ /**
+ * 이벤트 ID로 당첨자 목록 조회 (페이징)
+ *
+ * @param eventId 이벤트 ID
+ * @param pageable 페이징 정보
+ * @return 당첨자 페이지
+ */
+ Page 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 findMaxIdByEventId(@Param("eventId") String eventId);
+
+ /**
+ * 이벤트 ID로 비당첨자 목록 조회 (추첨용)
+ *
+ * @param eventId 이벤트 ID
+ * @return 비당첨자 목록
+ */
+ List findByEventIdAndIsWinnerFalse(String eventId);
+
+ /**
+ * 이벤트 ID와 참여자 ID로 조회
+ *
+ * @param eventId 이벤트 ID
+ * @param participantId 참여자 ID
+ * @return 참여자 Optional
+ */
+ Optional findByEventIdAndParticipantId(String eventId, String participantId);
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java
new file mode 100644
index 0000000..0561e05
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/exception/ParticipationException.java
@@ -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, "아직 당첨자 추첨이 진행되지 않았습니다");
+ }
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java
new file mode 100644
index 0000000..b43fdfc
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/config/SecurityConfig.java
@@ -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();
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java
new file mode 100644
index 0000000..d2e8f61
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java
@@ -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 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);
+ // 이벤트 발행 실패는 서비스 로직에 영향을 주지 않음
+ }
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java
new file mode 100644
index 0000000..25ea454
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java
@@ -0,0 +1,41 @@
+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 String channel;
+ 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())
+ .channel(participant.getChannel())
+ .storeVisited(participant.getStoreVisited())
+ .bonusEntries(participant.getBonusEntries())
+ .participatedAt(participant.getCreatedAt())
+ .build();
+ }
+}
diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java
new file mode 100644
index 0000000..0643fb9
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java
@@ -0,0 +1,94 @@
+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 io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springdoc.core.annotations.ParameterObject;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+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("/api/v1")
+@RequiredArgsConstructor
+public class ParticipationController {
+
+ private final ParticipationService participationService;
+
+ /**
+ * 이벤트 참여
+ * POST /events/{eventId}/participate
+ */
+ @PostMapping("/events/{eventId}/participate")
+ public ResponseEntity> 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
+ */
+ @Operation(
+ summary = "참여자 목록 조회",
+ description = "이벤트의 참여자 목록을 페이징하여 조회합니다. " +
+ "정렬 가능한 필드: createdAt(기본값), participantId, name, phoneNumber, bonusEntries, isWinner, wonAt"
+ )
+ @GetMapping("/events/{eventId}/participants")
+ public ResponseEntity>> getParticipants(
+ @Parameter(description = "이벤트 ID", example = "evt_20250124_001")
+ @PathVariable String eventId,
+
+ @Parameter(description = "매장 방문 여부 필터 (true: 방문자만, false: 미방문자만, null: 전체)")
+ @RequestParam(required = false) Boolean storeVisited,
+
+ @ParameterObject
+ @PageableDefault(size = 20, sort = "createdAt", direction = Sort.Direction.DESC)
+ Pageable pageable) {
+
+ log.info("참여자 목록 조회 요청 - eventId: {}, storeVisited: {}", eventId, storeVisited);
+ PageResponse response =
+ participationService.getParticipants(eventId, storeVisited, pageable);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+
+ /**
+ * 참여자 상세 조회
+ * GET /events/{eventId}/participants/{participantId}
+ */
+ @GetMapping("/events/{eventId}/participants/{participantId}")
+ public ResponseEntity> 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));
+ }
+}
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..f7fbc83
--- /dev/null
+++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java
@@ -0,0 +1,74 @@
+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 io.swagger.v3.oas.annotations.Operation;
+import io.swagger.v3.oas.annotations.Parameter;
+import io.swagger.v3.oas.annotations.tags.Tag;
+import org.springdoc.core.annotations.ParameterObject;
+import jakarta.validation.Valid;
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.data.domain.Pageable;
+import org.springframework.data.domain.Sort;
+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("/api/v1")
+@RequiredArgsConstructor
+public class WinnerController {
+
+ private final WinnerDrawService winnerDrawService;
+
+ /**
+ * 당첨자 추첨
+ * POST /events/{eventId}/draw-winners
+ */
+ @PostMapping("/events/{eventId}/draw-winners")
+ public ResponseEntity> 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
+ */
+ @Operation(
+ summary = "당첨자 목록 조회",
+ description = "이벤트의 당첨자 목록을 페이징하여 조회합니다. " +
+ "정렬 가능한 필드: winnerRank(기본값), wonAt, participantId, name, phoneNumber, bonusEntries"
+ )
+ @GetMapping("/events/{eventId}/winners")
+ public ResponseEntity>> getWinners(
+ @Parameter(description = "이벤트 ID", example = "evt_20250124_001")
+ @PathVariable String eventId,
+
+ @ParameterObject
+ @PageableDefault(size = 20, sort = "winnerRank", direction = Sort.Direction.ASC)
+ Pageable pageable) {
+
+ log.info("당첨자 목록 조회 요청 - eventId: {}", eventId);
+ PageResponse response = winnerDrawService.getWinners(eventId, pageable);
+
+ return ResponseEntity.ok(ApiResponse.success(response));
+ }
+}
diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml
new file mode 100644
index 0000000..fa3a8c3
--- /dev/null
+++ b/participation-service/src/main/resources/application.yml
@@ -0,0 +1,75 @@
+spring:
+ application:
+ name: participation-service
+
+ # 데이터베이스 설정
+ datasource:
+ url: jdbc:postgresql://${DB_HOST:4.230.72.147}:${DB_PORT:5432}/${DB_NAME:participationdb}
+ username: ${DB_USERNAME:eventuser}
+ password: ${DB_PASSWORD:Hi5Jessica!}
+ driver-class-name: org.postgresql.Driver
+ hikari:
+ maximum-pool-size: 10
+ minimum-idle: 5
+ connection-timeout: 30000
+ idle-timeout: 600000
+ max-lifetime: 1800000
+
+ # JPA 설정
+ jpa:
+ hibernate:
+ ddl-auto: ${DDL_AUTO:validate}
+ show-sql: ${SHOW_SQL:true}
+ properties:
+ hibernate:
+ format_sql: true
+ dialect: org.hibernate.dialect.PostgreSQLDialect
+ default_batch_fetch_size: 100
+
+ # Redis 설정
+ data:
+ redis:
+ host: ${REDIS_HOST:20.214.210.71}
+ port: ${REDIS_PORT:6379}
+ password: ${REDIS_PASSWORD:Hi5Jessica!}
+ timeout: 3000ms
+ lettuce:
+ pool:
+ max-active: 8
+ max-idle: 8
+ min-idle: 2
+ max-wait: -1ms
+
+ # Kafka 설정
+ kafka:
+ bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:4.217.131.59:9095}
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ acks: all
+ retries: 3
+
+# JWT 설정
+jwt:
+ secret: ${JWT_SECRET:kt-event-marketing-secret-key-for-development-only-change-in-production}
+ expiration: ${JWT_EXPIRATION:86400000}
+
+# 서버 설정
+server:
+ port: ${SERVER_PORT:8084}
+
+# 로깅 설정
+logging:
+ level:
+ com.kt.event.participation: ${LOG_LEVEL:INFO}
+ org.hibernate.SQL: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
+ org.springframework.kafka: DEBUG
+ org.apache.kafka: DEBUG
+ file:
+ name: ${LOG_FILE:logs/participation-service.log}
+ logback:
+ rollingpolicy:
+ max-file-size: 10MB
+ max-history: 7
+ total-size-cap: 100MB
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java
new file mode 100644
index 0000000..32881dc
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/DrawLogRepositoryIntegrationTest.java
@@ -0,0 +1,167 @@
+package com.kt.event.participation.test.integration;
+
+import com.kt.event.participation.domain.draw.DrawLog;
+import com.kt.event.participation.domain.draw.DrawLogRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+
+import java.time.LocalDateTime;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * DrawLogRepository 통합 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@DataJpaTest
+@DisplayName("DrawLogRepository 통합 테스트")
+class DrawLogRepositoryIntegrationTest {
+
+ @Autowired
+ private DrawLogRepository drawLogRepository;
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final Integer TOTAL_PARTICIPANTS = 100;
+ private static final Integer WINNER_COUNT = 10;
+ private static final String ALGORITHM = "WEIGHTED_RANDOM";
+ private static final String DRAWN_BY = "SYSTEM";
+
+ @BeforeEach
+ void setUp() {
+ drawLogRepository.deleteAll();
+ }
+
+ @Test
+ @DisplayName("추첨 로그를 저장하면 정상적으로 조회할 수 있다")
+ void givenDrawLog_whenSave_thenCanRetrieve() {
+ // Given
+ DrawLog drawLog = createDrawLog(VALID_EVENT_ID, true);
+
+ // When
+ DrawLog saved = drawLogRepository.save(drawLog);
+
+ // Then
+ assertThat(saved.getId()).isNotNull();
+ assertThat(saved.getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(saved.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS);
+ assertThat(saved.getWinnerCount()).isEqualTo(WINNER_COUNT);
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 추첨 로그를 조회할 수 있다")
+ void givenSavedDrawLog_whenFindByEventId_thenReturnDrawLog() {
+ // Given
+ DrawLog drawLog = createDrawLog(VALID_EVENT_ID, true);
+ drawLogRepository.save(drawLog);
+
+ // When
+ Optional found = drawLogRepository.findByEventId(VALID_EVENT_ID);
+
+ // Then
+ assertThat(found).isPresent();
+ assertThat(found.get().getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(found.get().getApplyStoreVisitBonus()).isTrue();
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 이벤트 ID로 조회하면 Empty가 반환된다")
+ void givenNoDrawLog_whenFindByEventId_thenReturnEmpty() {
+ // Given
+ String nonExistentEventId = "evt_99999999_999";
+
+ // When
+ Optional found = drawLogRepository.findByEventId(nonExistentEventId);
+
+ // Then
+ assertThat(found).isEmpty();
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 추첨 여부를 확인할 수 있다")
+ void givenSavedDrawLog_whenExistsByEventId_thenReturnTrue() {
+ // Given
+ DrawLog drawLog = createDrawLog(VALID_EVENT_ID, false);
+ drawLogRepository.save(drawLog);
+
+ // When
+ boolean exists = drawLogRepository.existsByEventId(VALID_EVENT_ID);
+
+ // Then
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("추첨이 없는 이벤트 ID로 확인하면 false가 반환된다")
+ void givenNoDrawLog_whenExistsByEventId_thenReturnFalse() {
+ // Given
+ String nonExistentEventId = "evt_99999999_999";
+
+ // When
+ boolean exists = drawLogRepository.existsByEventId(nonExistentEventId);
+
+ // Then
+ assertThat(exists).isFalse();
+ }
+
+ @Test
+ @DisplayName("매장 방문 보너스 미적용 추첨 로그를 저장할 수 있다")
+ void givenDrawLogWithoutBonus_whenSave_thenCanRetrieve() {
+ // Given
+ DrawLog drawLog = createDrawLog(VALID_EVENT_ID, false);
+
+ // When
+ DrawLog saved = drawLogRepository.save(drawLog);
+
+ // Then
+ assertThat(saved.getApplyStoreVisitBonus()).isFalse();
+ }
+
+ @Test
+ @DisplayName("추첨 로그의 모든 필드가 정상적으로 저장된다")
+ void givenCompleteDrawLog_whenSave_thenAllFieldsPersisted() {
+ // Given
+ LocalDateTime now = LocalDateTime.now();
+ DrawLog drawLog = DrawLog.builder()
+ .eventId(VALID_EVENT_ID)
+ .totalParticipants(TOTAL_PARTICIPANTS)
+ .winnerCount(WINNER_COUNT)
+ .applyStoreVisitBonus(true)
+ .algorithm(ALGORITHM)
+ .drawnAt(now)
+ .drawnBy(DRAWN_BY)
+ .build();
+
+ // When
+ DrawLog saved = drawLogRepository.save(drawLog);
+
+ // Then
+ assertThat(saved.getId()).isNotNull();
+ assertThat(saved.getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(saved.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS);
+ assertThat(saved.getWinnerCount()).isEqualTo(WINNER_COUNT);
+ assertThat(saved.getApplyStoreVisitBonus()).isTrue();
+ assertThat(saved.getAlgorithm()).isEqualTo(ALGORITHM);
+ assertThat(saved.getDrawnAt()).isEqualToIgnoringNanos(now);
+ assertThat(saved.getDrawnBy()).isEqualTo(DRAWN_BY);
+ }
+
+ // 헬퍼 메서드
+ private DrawLog createDrawLog(String eventId, boolean applyBonus) {
+ return DrawLog.builder()
+ .eventId(eventId)
+ .totalParticipants(TOTAL_PARTICIPANTS)
+ .winnerCount(WINNER_COUNT)
+ .applyStoreVisitBonus(applyBonus)
+ .algorithm(ALGORITHM)
+ .drawnAt(LocalDateTime.now())
+ .drawnBy(DRAWN_BY)
+ .build();
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java
new file mode 100644
index 0000000..08983d4
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/KafkaEventPublishIntegrationTest.java
@@ -0,0 +1,173 @@
+package com.kt.event.participation.test.integration;
+
+import com.kt.event.participation.application.dto.ParticipationRequest;
+import com.kt.event.participation.application.service.ParticipationService;
+import com.kt.event.participation.domain.participant.ParticipantRepository;
+import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent;
+import org.apache.kafka.clients.consumer.Consumer;
+import org.apache.kafka.clients.consumer.ConsumerConfig;
+import org.apache.kafka.clients.consumer.ConsumerRecord;
+import org.apache.kafka.common.serialization.StringDeserializer;
+import org.junit.jupiter.api.AfterEach;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.Disabled;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.context.SpringBootTest;
+import org.springframework.kafka.core.DefaultKafkaConsumerFactory;
+import org.springframework.kafka.support.serializer.JsonDeserializer;
+import org.springframework.kafka.test.EmbeddedKafkaBroker;
+import org.springframework.kafka.test.context.EmbeddedKafka;
+import org.springframework.kafka.test.utils.KafkaTestUtils;
+import org.springframework.test.context.ActiveProfiles;
+
+import java.time.Duration;
+import java.util.Collections;
+import java.util.Map;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * Kafka 이벤트 발행 통합 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@Disabled("Kafka producer가 embedded broker의 bootstrap servers를 사용하도록 설정 필요")
+@SpringBootTest
+@EmbeddedKafka(partitions = 1, topics = {"participant-registered-events"}, ports = {0})
+@DisplayName("Kafka 이벤트 발행 통합 테스트")
+class KafkaEventPublishIntegrationTest {
+
+ private static final String TOPIC = "participant-registered-events";
+ private static final String TEST_EVENT_ID = "EVT-TEST-001";
+
+ @Autowired
+ private ParticipationService participationService;
+
+ @Autowired
+ private ParticipantRepository participantRepository;
+
+ @Autowired
+ private EmbeddedKafkaBroker embeddedKafka;
+
+ private Consumer consumer;
+
+ @BeforeEach
+ void setUp() {
+ // Kafka Consumer 설정
+ Map consumerProps = KafkaTestUtils.consumerProps(
+ embeddedKafka.getBrokersAsString(), "test-group", "false");
+ consumerProps.put(ConsumerConfig.KEY_DESERIALIZER_CLASS_CONFIG, StringDeserializer.class);
+ consumerProps.put(ConsumerConfig.VALUE_DESERIALIZER_CLASS_CONFIG, JsonDeserializer.class);
+ consumerProps.put(JsonDeserializer.TRUSTED_PACKAGES, "*");
+ consumerProps.put(JsonDeserializer.VALUE_DEFAULT_TYPE, ParticipantRegisteredEvent.class);
+ consumerProps.put(ConsumerConfig.AUTO_OFFSET_RESET_CONFIG, "latest");
+
+ DefaultKafkaConsumerFactory consumerFactory =
+ new DefaultKafkaConsumerFactory<>(consumerProps);
+ consumer = consumerFactory.createConsumer();
+ consumer.subscribe(Collections.singletonList(TOPIC));
+ }
+
+ @AfterEach
+ void tearDown() {
+ if (consumer != null) {
+ consumer.close();
+ }
+ // 테스트 데이터 정리
+ participantRepository.deleteAll();
+ }
+
+ @Test
+ @DisplayName("이벤트 참여 시 Kafka 이벤트가 발행되어야 한다")
+ void shouldPublishKafkaEventWhenParticipate() throws Exception {
+ // Given: 참여 요청 데이터
+ ParticipationRequest request = ParticipationRequest.builder()
+ .name("테스트사용자")
+ .phoneNumber("01012345678")
+ .email("test@example.com")
+ .storeVisited(true)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .build();
+
+ // When: 이벤트 참여
+ participationService.participate(TEST_EVENT_ID, request);
+
+ // Then: Kafka 메시지 수신 확인
+ ConsumerRecord record =
+ KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10));
+
+ assertThat(record).isNotNull();
+ assertThat(record.key()).isEqualTo(TEST_EVENT_ID);
+
+ ParticipantRegisteredEvent event = record.value();
+ assertThat(event).isNotNull();
+ assertThat(event.getEventId()).isEqualTo(TEST_EVENT_ID);
+ assertThat(event.getName()).isEqualTo("테스트사용자");
+ assertThat(event.getPhoneNumber()).isEqualTo("01012345678");
+ assertThat(event.getStoreVisited()).isTrue();
+ assertThat(event.getBonusEntries()).isEqualTo(5);
+ assertThat(event.getParticipatedAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("매장 미방문 참여자의 이벤트가 발행되어야 한다")
+ void shouldPublishEventForNonStoreVisitor() throws Exception {
+ // Given: 매장 미방문 참여 요청
+ ParticipationRequest request = ParticipationRequest.builder()
+ .name("온라인사용자")
+ .phoneNumber("01098765432")
+ .email("online@example.com")
+ .storeVisited(false)
+ .agreeMarketing(false)
+ .agreePrivacy(true)
+ .build();
+
+ // When: 이벤트 참여
+ participationService.participate(TEST_EVENT_ID, request);
+
+ // Then: Kafka 메시지 수신 확인
+ ConsumerRecord record =
+ KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10));
+
+ assertThat(record).isNotNull();
+
+ ParticipantRegisteredEvent event = record.value();
+ assertThat(event.getStoreVisited()).isFalse();
+ assertThat(event.getBonusEntries()).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("여러 참여자의 이벤트가 순차적으로 발행되어야 한다")
+ void shouldPublishMultipleEventsSequentially() throws Exception {
+ // Given: 3명의 참여자
+ for (int i = 1; i <= 3; i++) {
+ ParticipationRequest request = ParticipationRequest.builder()
+ .name("참여자" + i)
+ .phoneNumber("0101234567" + i)
+ .email("user" + i + "@example.com")
+ .storeVisited(i % 2 == 0)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .build();
+
+ // When: 이벤트 참여
+ participationService.participate(TEST_EVENT_ID, request);
+ }
+
+ // Then: 3개의 Kafka 메시지 수신 확인
+ for (int i = 1; i <= 3; i++) {
+ ConsumerRecord record =
+ KafkaTestUtils.getSingleRecord(consumer, TOPIC, Duration.ofSeconds(10));
+
+ assertThat(record).isNotNull();
+
+ ParticipantRegisteredEvent event = record.value();
+ assertThat(event.getName()).startsWith("참여자");
+ assertThat(event.getEventId()).isEqualTo(TEST_EVENT_ID);
+ }
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java
new file mode 100644
index 0000000..25c3ea6
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/ParticipantRepositoryIntegrationTest.java
@@ -0,0 +1,324 @@
+package com.kt.event.participation.test.integration;
+
+import com.kt.event.participation.domain.participant.Participant;
+import com.kt.event.participation.domain.participant.ParticipantRepository;
+import org.junit.jupiter.api.BeforeEach;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * ParticipantRepository 통합 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@DataJpaTest
+@DisplayName("ParticipantRepository 통합 테스트")
+class ParticipantRepositoryIntegrationTest {
+
+ @Autowired
+ private ParticipantRepository participantRepository;
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final String VALID_NAME = "홍길동";
+ private static final String VALID_PHONE = "010-1234-5678";
+ private static final String VALID_EMAIL = "hong@test.com";
+
+ @BeforeEach
+ void setUp() {
+ participantRepository.deleteAll();
+ }
+
+ @Test
+ @DisplayName("참여자를 저장하면 정상적으로 조회할 수 있다")
+ void givenParticipant_whenSave_thenCanRetrieve() {
+ // Given
+ Participant participant = createValidParticipant();
+
+ // When
+ Participant saved = participantRepository.save(participant);
+
+ // Then
+ assertThat(saved.getId()).isNotNull();
+ assertThat(saved.getParticipantId()).isEqualTo(participant.getParticipantId());
+ assertThat(saved.getName()).isEqualTo(VALID_NAME);
+ }
+
+ @Test
+ @DisplayName("참여자 ID로 조회하면 해당 참여자가 반환된다")
+ void givenSavedParticipant_whenFindByParticipantId_thenReturnParticipant() {
+ // Given
+ Participant participant = createValidParticipant();
+ participantRepository.save(participant);
+
+ // When
+ Optional found = participantRepository.findByParticipantId(participant.getParticipantId());
+
+ // Then
+ assertThat(found).isPresent();
+ assertThat(found.get().getName()).isEqualTo(VALID_NAME);
+ }
+
+ @Test
+ @DisplayName("이벤트 ID와 전화번호로 중복 참여를 확인할 수 있다")
+ void givenSavedParticipant_whenExistsByEventIdAndPhoneNumber_thenReturnTrue() {
+ // Given
+ Participant participant = createValidParticipant();
+ participantRepository.save(participant);
+
+ // When
+ boolean exists = participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE);
+
+ // Then
+ assertThat(exists).isTrue();
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 참여자 목록을 페이징 조회할 수 있다")
+ void givenMultipleParticipants_whenFindByEventId_thenReturnPagedList() {
+ // Given
+ for (int i = 1; i <= 5; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(i % 2 == 0)
+ .bonusEntries(i % 2 == 0 ? 2 : 1)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+ }
+ Pageable pageable = PageRequest.of(0, 3);
+
+ // When
+ Page page = participantRepository.findByEventIdOrderByCreatedAtDesc(VALID_EVENT_ID, pageable);
+
+ // Then
+ assertThat(page.getContent()).hasSize(3);
+ assertThat(page.getTotalElements()).isEqualTo(5);
+ assertThat(page.getTotalPages()).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("매장 방문 여부로 필터링하여 참여자 목록을 조회할 수 있다")
+ void givenParticipantsWithStoreVisit_whenFindByStoreVisited_thenReturnFiltered() {
+ // Given
+ for (int i = 1; i <= 5; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(i % 2 == 0)
+ .bonusEntries(i % 2 == 0 ? 2 : 1)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+ }
+ Pageable pageable = PageRequest.of(0, 10);
+
+ // When
+ Page page = participantRepository
+ .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(VALID_EVENT_ID, true, pageable);
+
+ // Then
+ assertThat(page.getContent()).hasSize(2);
+ assertThat(page.getContent()).allMatch(Participant::getStoreVisited);
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 전체 참여자 수를 조회할 수 있다")
+ void givenParticipants_whenCountByEventId_thenReturnCount() {
+ // Given
+ for (int i = 1; i <= 3; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+ }
+
+ // When
+ long count = participantRepository.countByEventId(VALID_EVENT_ID);
+
+ // Then
+ assertThat(count).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("당첨자만 순위 순으로 조회할 수 있다")
+ void givenWinners_whenFindWinners_thenReturnSortedByRank() {
+ // Given
+ for (int i = 1; i <= 3; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("당첨자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("winner" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(true)
+ .build();
+ participant.markAsWinner(4 - i); // 역순으로 순위 부여
+ participantRepository.save(participant);
+ }
+ Pageable pageable = PageRequest.of(0, 10);
+
+ // When
+ Page page = participantRepository
+ .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(VALID_EVENT_ID, pageable);
+
+ // Then
+ assertThat(page.getContent()).hasSize(3);
+ assertThat(page.getContent().get(0).getWinnerRank()).isEqualTo(1);
+ assertThat(page.getContent().get(1).getWinnerRank()).isEqualTo(2);
+ assertThat(page.getContent().get(2).getWinnerRank()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 당첨자 수를 조회할 수 있다")
+ void givenWinners_whenCountWinners_thenReturnCount() {
+ // Given
+ for (int i = 1; i <= 5; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(i <= 2)
+ .build();
+ if (i <= 2) {
+ participant.markAsWinner(i);
+ }
+ participantRepository.save(participant);
+ }
+
+ // When
+ long count = participantRepository.countByEventIdAndIsWinnerTrue(VALID_EVENT_ID);
+
+ // Then
+ assertThat(count).isEqualTo(2);
+ }
+
+ @Test
+ @DisplayName("이벤트 ID로 최대 ID를 조회할 수 있다")
+ void givenParticipants_whenFindMaxId_thenReturnMaxId() {
+ // Given
+ for (int i = 1; i <= 3; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+ }
+
+ // When
+ Optional maxId = participantRepository.findMaxIdByEventId(VALID_EVENT_ID);
+
+ // Then
+ assertThat(maxId).isPresent();
+ assertThat(maxId.get()).isGreaterThan(0);
+ }
+
+ @Test
+ @DisplayName("비당첨자 목록만 조회할 수 있다")
+ void givenMixedParticipants_whenFindNonWinners_thenReturnOnlyNonWinners() {
+ // Given
+ for (int i = 1; i <= 5; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(i <= 2)
+ .build();
+ participantRepository.save(participant);
+ }
+
+ // When
+ List nonWinners = participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID);
+
+ // Then
+ assertThat(nonWinners).hasSize(3);
+ assertThat(nonWinners).allMatch(p -> !p.getIsWinner());
+ }
+
+ @Test
+ @DisplayName("이벤트 ID와 참여자 ID로 조회할 수 있다")
+ void givenParticipant_whenFindByEventIdAndParticipantId_thenReturnParticipant() {
+ // Given
+ Participant participant = createValidParticipant();
+ participantRepository.save(participant);
+
+ // When
+ Optional found = participantRepository
+ .findByEventIdAndParticipantId(VALID_EVENT_ID, participant.getParticipantId());
+
+ // Then
+ assertThat(found).isPresent();
+ assertThat(found.get().getName()).isEqualTo(VALID_NAME);
+ }
+
+ // 헬퍼 메서드
+ private Participant createValidParticipant() {
+ return Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java b/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java
new file mode 100644
index 0000000..9cfa6bd
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/integration/QueryVerificationTest.java
@@ -0,0 +1,114 @@
+package com.kt.event.participation.test.integration;
+
+import com.kt.event.participation.domain.participant.Participant;
+import com.kt.event.participation.domain.participant.ParticipantRepository;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.springframework.beans.factory.annotation.Autowired;
+import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest;
+import org.springframework.test.context.TestPropertySource;
+
+/**
+ * Spring Data JPA 메서드의 실제 쿼리 확인용 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@DataJpaTest
+@TestPropertySource(properties = {
+ "spring.jpa.show-sql=true",
+ "spring.jpa.properties.hibernate.format_sql=true",
+ "logging.level.org.hibernate.SQL=DEBUG",
+ "logging.level.org.hibernate.type.descriptor.sql.BasicBinder=TRACE"
+})
+@DisplayName("JPA 쿼리 검증 테스트")
+class QueryVerificationTest {
+
+ @Autowired
+ private ParticipantRepository participantRepository;
+
+ @Test
+ @DisplayName("countByEventIdAndIsWinnerTrue 메서드의 실제 쿼리 확인")
+ void verifyCountByEventIdAndIsWinnerTrueQuery() {
+ // Given
+ String eventId = "evt_test_001";
+
+ // 테스트 데이터 생성
+ for (int i = 1; i <= 5; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_test_" + i)
+ .eventId(eventId)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(i <= 2)
+ .build();
+ participantRepository.save(participant);
+ }
+
+ // When - 이 쿼리가 실행되면서 콘솔에 SQL이 출력됨
+ System.out.println("\n========== countByEventIdAndIsWinnerTrue 실행 ==========");
+ long count = participantRepository.countByEventIdAndIsWinnerTrue(eventId);
+ System.out.println("========== 결과: " + count + " ==========\n");
+ }
+
+ @Test
+ @DisplayName("findByEventIdAndPhoneNumber 메서드의 실제 쿼리 확인")
+ void verifyExistsByEventIdAndPhoneNumberQuery() {
+ // Given
+ String eventId = "evt_test_002";
+ String phoneNumber = "010-1234-5678";
+
+ Participant participant = Participant.builder()
+ .participantId("prt_test_001")
+ .eventId(eventId)
+ .name("홍길동")
+ .phoneNumber(phoneNumber)
+ .email("hong@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+
+ // When
+ System.out.println("\n========== existsByEventIdAndPhoneNumber 실행 ==========");
+ boolean exists = participantRepository.existsByEventIdAndPhoneNumber(eventId, phoneNumber);
+ System.out.println("========== 결과: " + exists + " ==========\n");
+ }
+
+ @Test
+ @DisplayName("findByEventIdOrderByCreatedAtDesc 메서드의 실제 쿼리 확인")
+ void verifyFindByEventIdOrderByCreatedAtDescQuery() {
+ // Given
+ String eventId = "evt_test_003";
+
+ for (int i = 1; i <= 3; i++) {
+ Participant participant = Participant.builder()
+ .participantId("prt_test_" + i)
+ .eventId(eventId)
+ .name("참여자" + i)
+ .phoneNumber("010-1234-" + String.format("%04d", i))
+ .email("test" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ participantRepository.save(participant);
+ }
+
+ // When
+ System.out.println("\n========== findByEventIdOrderByCreatedAtDesc 실행 ==========");
+ participantRepository.findByEventIdOrderByCreatedAtDesc(eventId,
+ org.springframework.data.domain.PageRequest.of(0, 10));
+ System.out.println("========== 쿼리 실행 완료 ==========\n");
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java
new file mode 100644
index 0000000..18e72ee
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/DrawLogUnitTest.java
@@ -0,0 +1,97 @@
+package com.kt.event.participation.test.unit;
+
+import com.kt.event.participation.domain.draw.DrawLog;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import java.time.LocalDateTime;
+
+import static org.assertj.core.api.Assertions.assertThat;
+
+/**
+ * DrawLog Entity 단위 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@DisplayName("DrawLog 엔티티 단위 테스트")
+class DrawLogUnitTest {
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final Integer TOTAL_PARTICIPANTS = 100;
+ private static final Integer WINNER_COUNT = 10;
+ private static final String ALGORITHM = "WEIGHTED_RANDOM";
+ private static final String DRAWN_BY = "admin";
+
+ @Test
+ @DisplayName("빌더로 추첨 로그를 생성하면 필드가 정상 설정된다")
+ void givenValidData_whenBuild_thenDrawLogCreated() {
+ // Given
+ LocalDateTime drawnAt = LocalDateTime.now();
+
+ // When
+ DrawLog drawLog = DrawLog.builder()
+ .eventId(VALID_EVENT_ID)
+ .totalParticipants(TOTAL_PARTICIPANTS)
+ .winnerCount(WINNER_COUNT)
+ .applyStoreVisitBonus(true)
+ .algorithm(ALGORITHM)
+ .drawnAt(drawnAt)
+ .drawnBy(DRAWN_BY)
+ .build();
+
+ // Then
+ assertThat(drawLog.getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(drawLog.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS);
+ assertThat(drawLog.getWinnerCount()).isEqualTo(WINNER_COUNT);
+ assertThat(drawLog.getApplyStoreVisitBonus()).isTrue();
+ assertThat(drawLog.getAlgorithm()).isEqualTo(ALGORITHM);
+ assertThat(drawLog.getDrawnAt()).isEqualTo(drawnAt);
+ assertThat(drawLog.getDrawnBy()).isEqualTo(DRAWN_BY);
+ }
+
+ @Test
+ @DisplayName("매장 방문 보너스 미적용으로 추첨 로그를 생성할 수 있다")
+ void givenNoBonus_whenBuild_thenDrawLogCreated() {
+ // Given
+ LocalDateTime drawnAt = LocalDateTime.now();
+
+ // When
+ DrawLog drawLog = DrawLog.builder()
+ .eventId(VALID_EVENT_ID)
+ .totalParticipants(TOTAL_PARTICIPANTS)
+ .winnerCount(WINNER_COUNT)
+ .applyStoreVisitBonus(false)
+ .algorithm(ALGORITHM)
+ .drawnAt(drawnAt)
+ .drawnBy(DRAWN_BY)
+ .build();
+
+ // Then
+ assertThat(drawLog.getApplyStoreVisitBonus()).isFalse();
+ }
+
+ @Test
+ @DisplayName("당첨자가 없는 경우도 추첨 로그를 생성할 수 있다")
+ void givenNoWinners_whenBuild_thenDrawLogCreated() {
+ // Given
+ LocalDateTime drawnAt = LocalDateTime.now();
+ Integer zeroWinners = 0;
+
+ // When
+ DrawLog drawLog = DrawLog.builder()
+ .eventId(VALID_EVENT_ID)
+ .totalParticipants(TOTAL_PARTICIPANTS)
+ .winnerCount(zeroWinners)
+ .applyStoreVisitBonus(true)
+ .algorithm(ALGORITHM)
+ .drawnAt(drawnAt)
+ .drawnBy(DRAWN_BY)
+ .build();
+
+ // Then
+ assertThat(drawLog.getWinnerCount()).isZero();
+ assertThat(drawLog.getTotalParticipants()).isEqualTo(TOTAL_PARTICIPANTS);
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java
new file mode 100644
index 0000000..e96747a
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipantUnitTest.java
@@ -0,0 +1,222 @@
+package com.kt.event.participation.test.unit;
+
+import com.kt.event.participation.domain.participant.Participant;
+import org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+
+import static org.assertj.core.api.Assertions.*;
+
+/**
+ * Participant Entity 단위 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@DisplayName("Participant 엔티티 단위 테스트")
+class ParticipantUnitTest {
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final String VALID_NAME = "홍길동";
+ private static final String VALID_PHONE = "010-1234-5678";
+ private static final String VALID_EMAIL = "hong@test.com";
+ private static final Long VALID_SEQUENCE = 1L;
+
+ @Test
+ @DisplayName("매장 방문 시 participantId가 정상적으로 생성된다")
+ void givenStoreVisited_whenGenerateParticipantId_thenSuccess() {
+ // Given
+ String eventId = VALID_EVENT_ID;
+ Long sequenceNumber = VALID_SEQUENCE;
+
+ // When
+ String participantId = Participant.generateParticipantId(eventId, sequenceNumber);
+
+ // Then
+ assertThat(participantId).isEqualTo("prt_20250124_001");
+ assertThat(participantId).startsWith("prt_");
+ assertThat(participantId).hasSize(16);
+ }
+
+ @Test
+ @DisplayName("시퀀스 번호가 증가하면 participantId도 증가한다")
+ void givenLargeSequence_whenGenerateParticipantId_thenIdIncreases() {
+ // Given
+ String eventId = VALID_EVENT_ID;
+ Long sequenceNumber = 999L;
+
+ // When
+ String participantId = Participant.generateParticipantId(eventId, sequenceNumber);
+
+ // Then
+ assertThat(participantId).isEqualTo("prt_20250124_999");
+ }
+
+ @Test
+ @DisplayName("매장 방문 시 보너스 응모권이 5개가 된다")
+ void givenStoreVisited_whenCalculateBonusEntries_thenFive() {
+ // Given
+ Boolean storeVisited = true;
+
+ // When
+ Integer bonusEntries = Participant.calculateBonusEntries(storeVisited);
+
+ // Then
+ assertThat(bonusEntries).isEqualTo(5);
+ }
+
+ @Test
+ @DisplayName("매장 미방문 시 보너스 응모권이 1개가 된다")
+ void givenNotVisited_whenCalculateBonusEntries_thenOne() {
+ // Given
+ Boolean storeVisited = false;
+
+ // When
+ Integer bonusEntries = Participant.calculateBonusEntries(storeVisited);
+
+ // Then
+ assertThat(bonusEntries).isEqualTo(1);
+ }
+
+ @Test
+ @DisplayName("당첨자로 표시하면 isWinner가 true가 되고 당첨 정보가 설정된다")
+ void givenParticipant_whenMarkAsWinner_thenWinnerFieldsSet() {
+ // Given
+ Participant participant = createValidParticipant();
+ Integer winnerRank = 1;
+
+ // When
+ participant.markAsWinner(winnerRank);
+
+ // Then
+ assertThat(participant.getIsWinner()).isTrue();
+ assertThat(participant.getWinnerRank()).isEqualTo(1);
+ assertThat(participant.getWonAt()).isNotNull();
+ }
+
+ @Test
+ @DisplayName("빌더로 참여자를 생성하면 필드가 정상 설정된다")
+ void givenValidData_whenBuild_thenParticipantCreated() {
+ // Given & When
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .bonusEntries(5)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+
+ // Then
+ assertThat(participant.getParticipantId()).isEqualTo("prt_20250124_001");
+ assertThat(participant.getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(participant.getName()).isEqualTo(VALID_NAME);
+ assertThat(participant.getPhoneNumber()).isEqualTo(VALID_PHONE);
+ assertThat(participant.getEmail()).isEqualTo(VALID_EMAIL);
+ assertThat(participant.getStoreVisited()).isTrue();
+ assertThat(participant.getBonusEntries()).isEqualTo(5);
+ assertThat(participant.getAgreeMarketing()).isTrue();
+ assertThat(participant.getAgreePrivacy()).isTrue();
+ assertThat(participant.getIsWinner()).isFalse();
+ }
+
+ @Test
+ @DisplayName("prePersist에서 개인정보 동의가 null이면 예외가 발생한다")
+ void givenNullPrivacyAgree_whenPrePersist_thenThrowException() {
+ // Given
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .storeVisited(true)
+ .agreePrivacy(null)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(participant::prePersist)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("개인정보 수집 및 이용 동의는 필수입니다");
+ }
+
+ @Test
+ @DisplayName("prePersist에서 개인정보 동의가 false이면 예외가 발생한다")
+ void givenFalsePrivacyAgree_whenPrePersist_thenThrowException() {
+ // Given
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .storeVisited(true)
+ .agreePrivacy(false)
+ .build();
+
+ // When & Then
+ assertThatThrownBy(participant::prePersist)
+ .isInstanceOf(IllegalStateException.class)
+ .hasMessage("개인정보 수집 및 이용 동의는 필수입니다");
+ }
+
+ @Test
+ @DisplayName("prePersist에서 bonusEntries가 null이면 자동 계산된다")
+ void givenNullBonusEntries_whenPrePersist_thenCalculated() {
+ // Given
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .storeVisited(true)
+ .agreePrivacy(true)
+ .bonusEntries(null)
+ .build();
+
+ // When
+ participant.prePersist();
+
+ // Then
+ assertThat(participant.getBonusEntries()).isEqualTo(5);
+ }
+
+ @Test
+ @DisplayName("prePersist에서 isWinner가 null이면 false로 설정된다")
+ void givenNullIsWinner_whenPrePersist_thenSetFalse() {
+ // Given
+ Participant participant = Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .storeVisited(true)
+ .agreePrivacy(true)
+ .isWinner(null)
+ .build();
+
+ // When
+ participant.prePersist();
+
+ // Then
+ assertThat(participant.getIsWinner()).isFalse();
+ }
+
+ // 헬퍼 메서드
+ private Participant createValidParticipant() {
+ return Participant.builder()
+ .participantId("prt_20250124_001")
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .bonusEntries(5)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java
new file mode 100644
index 0000000..754a303
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/ParticipationServiceUnitTest.java
@@ -0,0 +1,271 @@
+package com.kt.event.participation.test.unit;
+
+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 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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.List;
+import java.util.Optional;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.BDDMockito.*;
+
+/**
+ * ParticipationService 단위 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("ParticipationService 단위 테스트")
+class ParticipationServiceUnitTest {
+
+ @Mock
+ private ParticipantRepository participantRepository;
+
+ @Mock
+ private KafkaProducerService kafkaProducerService;
+
+ @InjectMocks
+ private ParticipationService participationService;
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final String VALID_PARTICIPANT_ID = "prt_20250124_001";
+ private static final String VALID_NAME = "홍길동";
+ private static final String VALID_PHONE = "010-1234-5678";
+ private static final String VALID_EMAIL = "hong@test.com";
+
+ @Test
+ @DisplayName("정상적인 참여 요청이면 참여자가 저장되고 Kafka 이벤트가 발행된다")
+ void givenValidRequest_whenParticipate_thenSaveAndPublishEvent() {
+ // Given
+ ParticipationRequest request = createValidRequest();
+ Participant savedParticipant = createValidParticipant();
+
+ given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE))
+ .willReturn(false);
+ given(participantRepository.findMaxIdByEventId(VALID_EVENT_ID))
+ .willReturn(Optional.of(0L));
+ given(participantRepository.save(any(Participant.class)))
+ .willReturn(savedParticipant);
+ willDoNothing().given(kafkaProducerService)
+ .publishParticipantRegistered(any());
+
+ // When
+ ParticipationResponse response = participationService.participate(VALID_EVENT_ID, request);
+
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.getParticipantId()).isEqualTo(VALID_PARTICIPANT_ID);
+ assertThat(response.getName()).isEqualTo(VALID_NAME);
+ assertThat(response.getPhoneNumber()).isEqualTo(VALID_PHONE);
+
+ then(participantRepository).should(times(1)).save(any(Participant.class));
+ then(kafkaProducerService).should(times(1)).publishParticipantRegistered(any());
+ }
+
+ @Test
+ @DisplayName("중복 참여 시 DuplicateParticipationException이 발생한다")
+ void givenDuplicatePhone_whenParticipate_thenThrowException() {
+ // Given
+ ParticipationRequest request = createValidRequest();
+
+ given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE))
+ .willReturn(true);
+
+ // When & Then
+ assertThatThrownBy(() -> participationService.participate(VALID_EVENT_ID, request))
+ .isInstanceOf(DuplicateParticipationException.class)
+ .hasMessageContaining("이미 참여하신 이벤트입니다");
+
+ then(participantRepository).should(never()).save(any());
+ then(kafkaProducerService).should(never()).publishParticipantRegistered(any());
+ }
+
+ @Test
+ @DisplayName("매장 방문 참여자는 보너스 응모권이 2개가 된다")
+ void givenStoreVisited_whenParticipate_thenBonusEntriesIsTwo() {
+ // Given
+ ParticipationRequest request = ParticipationRequest.builder()
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .build();
+
+ Participant savedParticipant = Participant.builder()
+ .participantId(VALID_PARTICIPANT_ID)
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+
+ given(participantRepository.existsByEventIdAndPhoneNumber(VALID_EVENT_ID, VALID_PHONE))
+ .willReturn(false);
+ given(participantRepository.findMaxIdByEventId(VALID_EVENT_ID))
+ .willReturn(Optional.of(0L));
+ given(participantRepository.save(any(Participant.class)))
+ .willReturn(savedParticipant);
+
+ // When
+ ParticipationResponse response = participationService.participate(VALID_EVENT_ID, request);
+
+ // Then
+ assertThat(response.getBonusEntries()).isEqualTo(2);
+ assertThat(response.getStoreVisited()).isTrue();
+ }
+
+ @Test
+ @DisplayName("참여자 목록 조회 시 페이징이 적용된다")
+ void givenPageable_whenGetParticipants_thenReturnPagedList() {
+ // Given
+ Pageable pageable = PageRequest.of(0, 10);
+ List participants = List.of(
+ createValidParticipant(),
+ createAnotherParticipant()
+ );
+ Page participantPage = new PageImpl<>(participants, pageable, 2);
+
+ given(participantRepository.findByEventIdOrderByCreatedAtDesc(VALID_EVENT_ID, pageable))
+ .willReturn(participantPage);
+
+ // When
+ PageResponse response = participationService
+ .getParticipants(VALID_EVENT_ID, null, pageable);
+
+ // Then
+ assertThat(response.getContent()).hasSize(2);
+ assertThat(response.getTotalElements()).isEqualTo(2);
+ assertThat(response.getTotalPages()).isEqualTo(1);
+ assertThat(response.isFirst()).isTrue();
+ assertThat(response.isLast()).isTrue();
+ }
+
+ @Test
+ @DisplayName("매장 방문 필터 적용 시 필터링된 참여자 목록이 조회된다")
+ void givenStoreVisitedFilter_whenGetParticipants_thenReturnFilteredList() {
+ // Given
+ Boolean storeVisited = true;
+ Pageable pageable = PageRequest.of(0, 10);
+ List participants = List.of(createValidParticipant());
+ Page participantPage = new PageImpl<>(participants, pageable, 1);
+
+ given(participantRepository.findByEventIdAndStoreVisitedOrderByCreatedAtDesc(
+ VALID_EVENT_ID, storeVisited, pageable))
+ .willReturn(participantPage);
+
+ // When
+ PageResponse response = participationService
+ .getParticipants(VALID_EVENT_ID, storeVisited, pageable);
+
+ // Then
+ assertThat(response.getContent()).hasSize(1);
+ assertThat(response.getContent().get(0).getStoreVisited()).isTrue();
+
+ then(participantRepository).should(times(1))
+ .findByEventIdAndStoreVisitedOrderByCreatedAtDesc(VALID_EVENT_ID, storeVisited, pageable);
+ }
+
+ @Test
+ @DisplayName("참여자 상세 조회 시 정상적으로 반환된다")
+ void givenValidParticipantId_whenGetParticipant_thenReturnParticipant() {
+ // Given
+ Participant participant = createValidParticipant();
+
+ given(participantRepository.findByEventIdAndParticipantId(VALID_EVENT_ID, VALID_PARTICIPANT_ID))
+ .willReturn(Optional.of(participant));
+
+ // When
+ ParticipationResponse response = participationService
+ .getParticipant(VALID_EVENT_ID, VALID_PARTICIPANT_ID);
+
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.getParticipantId()).isEqualTo(VALID_PARTICIPANT_ID);
+ assertThat(response.getName()).isEqualTo(VALID_NAME);
+ }
+
+ @Test
+ @DisplayName("존재하지 않는 참여자 조회 시 ParticipantNotFoundException이 발생한다")
+ void givenInvalidParticipantId_whenGetParticipant_thenThrowException() {
+ // Given
+ String invalidParticipantId = "prt_20250124_999";
+
+ given(participantRepository.findByEventIdAndParticipantId(VALID_EVENT_ID, invalidParticipantId))
+ .willReturn(Optional.empty());
+ given(participantRepository.countByEventId(VALID_EVENT_ID))
+ .willReturn(1L); // 이벤트에 다른 참여자가 있음을 나타냄
+
+ // When & Then
+ assertThatThrownBy(() -> participationService.getParticipant(VALID_EVENT_ID, invalidParticipantId))
+ .isInstanceOf(ParticipantNotFoundException.class)
+ .hasMessageContaining("참여자를 찾을 수 없습니다");
+ }
+
+ // 헬퍼 메서드
+ private ParticipationRequest createValidRequest() {
+ return ParticipationRequest.builder()
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .build();
+ }
+
+ private Participant createValidParticipant() {
+ return Participant.builder()
+ .participantId(VALID_PARTICIPANT_ID)
+ .eventId(VALID_EVENT_ID)
+ .name(VALID_NAME)
+ .phoneNumber(VALID_PHONE)
+ .email(VALID_EMAIL)
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ }
+
+ private Participant createAnotherParticipant() {
+ return Participant.builder()
+ .participantId("prt_20250124_002")
+ .eventId(VALID_EVENT_ID)
+ .name("김철수")
+ .phoneNumber("010-9876-5432")
+ .email("kim@test.com")
+ .storeVisited(false)
+ .bonusEntries(1)
+ .agreeMarketing(false)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build();
+ }
+}
diff --git a/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java b/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java
new file mode 100644
index 0000000..eca7e3d
--- /dev/null
+++ b/participation-service/src/test/java/com/kt/event/participation/test/unit/WinnerDrawServiceUnitTest.java
@@ -0,0 +1,245 @@
+package com.kt.event.participation.test.unit;
+
+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 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 org.junit.jupiter.api.DisplayName;
+import org.junit.jupiter.api.Test;
+import org.junit.jupiter.api.extension.ExtendWith;
+import org.mockito.InjectMocks;
+import org.mockito.Mock;
+import org.mockito.junit.jupiter.MockitoExtension;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.PageImpl;
+import org.springframework.data.domain.PageRequest;
+import org.springframework.data.domain.Pageable;
+
+import java.util.ArrayList;
+import java.util.List;
+
+import static org.assertj.core.api.Assertions.*;
+import static org.mockito.ArgumentMatchers.*;
+import static org.mockito.BDDMockito.*;
+
+/**
+ * WinnerDrawService 단위 테스트
+ *
+ * @author Digital Garage Team
+ * @since 2025-01-24
+ */
+@ExtendWith(MockitoExtension.class)
+@DisplayName("WinnerDrawService 단위 테스트")
+class WinnerDrawServiceUnitTest {
+
+ @Mock
+ private ParticipantRepository participantRepository;
+
+ @Mock
+ private DrawLogRepository drawLogRepository;
+
+ @InjectMocks
+ private WinnerDrawService winnerDrawService;
+
+ // 테스트 데이터 상수
+ private static final String VALID_EVENT_ID = "evt_20250124_001";
+ private static final Integer WINNER_COUNT = 2;
+
+ @Test
+ @DisplayName("정상적인 추첨 요청이면 당첨자가 선정되고 로그가 저장된다")
+ void givenValidRequest_whenDrawWinners_thenWinnersSelectedAndLogSaved() {
+ // Given
+ DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, false);
+ List participants = createParticipantList(5);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false);
+ given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID))
+ .willReturn(participants);
+ given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0));
+ given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0));
+
+ // When
+ DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request);
+
+ // Then
+ assertThat(response).isNotNull();
+ assertThat(response.getEventId()).isEqualTo(VALID_EVENT_ID);
+ assertThat(response.getTotalParticipants()).isEqualTo(5);
+ assertThat(response.getWinnerCount()).isEqualTo(WINNER_COUNT);
+ assertThat(response.getWinners()).hasSize(WINNER_COUNT);
+ assertThat(response.getDrawnAt()).isNotNull();
+
+ then(participantRepository).should(times(1)).saveAll(anyList());
+ then(drawLogRepository).should(times(1)).save(any(DrawLog.class));
+ }
+
+ @Test
+ @DisplayName("이미 추첨이 완료된 이벤트면 AlreadyDrawnException이 발생한다")
+ void givenAlreadyDrawn_whenDrawWinners_thenThrowException() {
+ // Given
+ DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, false);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(true);
+
+ // When & Then
+ assertThatThrownBy(() -> winnerDrawService.drawWinners(VALID_EVENT_ID, request))
+ .isInstanceOf(AlreadyDrawnException.class);
+
+ then(participantRepository).should(never()).findByEventIdAndIsWinnerFalse(anyString());
+ }
+
+ @Test
+ @DisplayName("참여자 수가 당첨자 수보다 적으면 InsufficientParticipantsException이 발생한다")
+ void givenInsufficientParticipants_whenDrawWinners_thenThrowException() {
+ // Given
+ DrawWinnersRequest request = createDrawRequest(10, false);
+ List participants = createParticipantList(5);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false);
+ given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID))
+ .willReturn(participants);
+
+ // When & Then
+ assertThatThrownBy(() -> winnerDrawService.drawWinners(VALID_EVENT_ID, request))
+ .isInstanceOf(InsufficientParticipantsException.class);
+
+ then(participantRepository).should(never()).saveAll(anyList());
+ then(drawLogRepository).should(never()).save(any(DrawLog.class));
+ }
+
+ @Test
+ @DisplayName("매장 방문 보너스 적용 시 가중치가 반영된 추첨이 이루어진다")
+ void givenApplyBonus_whenDrawWinners_thenWeightedDraw() {
+ // Given
+ DrawWinnersRequest request = createDrawRequest(WINNER_COUNT, true);
+ List participants = createParticipantList(5);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false);
+ given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID))
+ .willReturn(participants);
+ given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0));
+ given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0));
+
+ // When
+ DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request);
+
+ // Then
+ assertThat(response.getWinnerCount()).isEqualTo(WINNER_COUNT);
+ then(drawLogRepository).should(times(1)).save(argThat(log ->
+ log.getApplyStoreVisitBonus().equals(true)
+ ));
+ }
+
+ @Test
+ @DisplayName("당첨자 목록 조회 시 순위 순으로 정렬되어 반환된다")
+ void givenWinnersExist_whenGetWinners_thenReturnSortedByRank() {
+ // Given
+ Pageable pageable = PageRequest.of(0, 10);
+ List winners = createWinnerList(3);
+ Page winnerPage = new PageImpl<>(winners, pageable, 3);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(true);
+ given(participantRepository.findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(VALID_EVENT_ID, pageable))
+ .willReturn(winnerPage);
+
+ // When
+ PageResponse response = winnerDrawService.getWinners(VALID_EVENT_ID, pageable);
+
+ // Then
+ assertThat(response.getContent()).hasSize(3);
+ assertThat(response.getTotalElements()).isEqualTo(3);
+ }
+
+ @Test
+ @DisplayName("추첨이 완료되지 않은 이벤트의 당첨자 조회 시 NoWinnersYetException이 발생한다")
+ void givenNoDrawYet_whenGetWinners_thenThrowException() {
+ // Given
+ Pageable pageable = PageRequest.of(0, 10);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false);
+
+ // When & Then
+ assertThatThrownBy(() -> winnerDrawService.getWinners(VALID_EVENT_ID, pageable))
+ .isInstanceOf(NoWinnersYetException.class);
+
+ then(participantRepository).should(never())
+ .findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(anyString(), any(Pageable.class));
+ }
+
+ @Test
+ @DisplayName("당첨자 추첨 시 모든 참여자에게 순위가 할당된다")
+ void givenParticipants_whenDrawWinners_thenAllWinnersHaveRank() {
+ // Given
+ DrawWinnersRequest request = createDrawRequest(3, false);
+ List participants = createParticipantList(5);
+
+ given(drawLogRepository.existsByEventId(VALID_EVENT_ID)).willReturn(false);
+ given(participantRepository.findByEventIdAndIsWinnerFalse(VALID_EVENT_ID))
+ .willReturn(participants);
+ given(participantRepository.saveAll(anyList())).willAnswer(invocation -> invocation.getArgument(0));
+ given(drawLogRepository.save(any(DrawLog.class))).willAnswer(invocation -> invocation.getArgument(0));
+
+ // When
+ DrawWinnersResponse response = winnerDrawService.drawWinners(VALID_EVENT_ID, request);
+
+ // Then
+ assertThat(response.getWinners()).allSatisfy(winner -> {
+ assertThat(winner.getRank()).isNotNull();
+ assertThat(winner.getRank()).isBetween(1, 3);
+ });
+ }
+
+ // 헬퍼 메서드
+ private DrawWinnersRequest createDrawRequest(Integer winnerCount, Boolean applyBonus) {
+ return DrawWinnersRequest.builder()
+ .winnerCount(winnerCount)
+ .applyStoreVisitBonus(applyBonus)
+ .build();
+ }
+
+ private List createParticipantList(int count) {
+ List participants = new ArrayList<>();
+ for (int i = 1; i <= count; i++) {
+ participants.add(Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("참여자" + i)
+ .phoneNumber("010-" + String.format("%04d", 1000 + i) + "-" + String.format("%04d", i))
+ .email("participant" + i + "@test.com")
+ .storeVisited(i % 2 == 0)
+ .bonusEntries(i % 2 == 0 ? 2 : 1)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(false)
+ .build());
+ }
+ return participants;
+ }
+
+ private List createWinnerList(int count) {
+ List winners = new ArrayList<>();
+ for (int i = 1; i <= count; i++) {
+ Participant winner = Participant.builder()
+ .participantId("prt_20250124_" + String.format("%03d", i))
+ .eventId(VALID_EVENT_ID)
+ .name("당첨자" + i)
+ .phoneNumber("010-" + String.format("%04d", 1000 + i) + "-" + String.format("%04d", i))
+ .email("winner" + i + "@test.com")
+ .storeVisited(true)
+ .bonusEntries(2)
+ .agreeMarketing(true)
+ .agreePrivacy(true)
+ .isWinner(true)
+ .build();
+ winner.markAsWinner(i);
+ winners.add(winner);
+ }
+ return winners;
+ }
+}
diff --git a/participation-service/src/test/resources/application.yml b/participation-service/src/test/resources/application.yml
new file mode 100644
index 0000000..895ba9e
--- /dev/null
+++ b/participation-service/src/test/resources/application.yml
@@ -0,0 +1,46 @@
+spring:
+ # JPA 설정
+ jpa:
+ hibernate:
+ ddl-auto: create-drop
+ show-sql: true
+ properties:
+ hibernate:
+ format_sql: true
+ dialect: org.hibernate.dialect.H2Dialect
+
+ # H2 인메모리 데이터베이스 설정
+ datasource:
+ url: jdbc:h2:mem:testdb
+ driver-class-name: org.h2.Driver
+ username: sa
+ password:
+
+ # Kafka 설정 (통합 테스트용)
+ kafka:
+ bootstrap-servers: 20.249.182.13:9095
+ producer:
+ key-serializer: org.apache.kafka.common.serialization.StringSerializer
+ value-serializer: org.springframework.kafka.support.serializer.JsonSerializer
+ acks: all
+ retries: 3
+
+ # H2 콘솔 활성화 (디버깅용)
+ h2:
+ console:
+ enabled: true
+ path: /h2-console
+
+# JWT 설정 (테스트용)
+jwt:
+ secret: test-secret-key-for-testing-only-minimum-256-bits
+ expiration: 86400000
+
+# 로깅 레벨
+logging:
+ level:
+ org.hibernate.SQL: DEBUG
+ org.hibernate.type.descriptor.sql.BasicBinder: TRACE
+ com.kt.event.participation: DEBUG
+ org.springframework.kafka: DEBUG
+ org.apache.kafka: DEBUG
diff --git a/tools/run-intellij-service-profile.py b/tools/run-intellij-service-profile.py
new file mode 100644
index 0000000..2278686
--- /dev/null
+++ b/tools/run-intellij-service-profile.py
@@ -0,0 +1,303 @@
+#!/usr/bin/env python3
+# -*- coding: utf-8 -*-
+"""
+Tripgen Service Runner Script
+Reads execution profiles from {service-name}/.run/{service-name}.run.xml and runs services accordingly.
+
+Usage:
+ python run-config.py
+
+Examples:
+ python run-config.py user-service
+ python run-config.py location-service
+ python run-config.py trip-service
+ python run-config.py ai-service
+"""
+
+import os
+import sys
+import subprocess
+import xml.etree.ElementTree as ET
+from pathlib import Path
+import argparse
+
+
+def get_project_root():
+ """Find project root directory"""
+ current_dir = Path(__file__).parent.absolute()
+ while current_dir.parent != current_dir:
+ if (current_dir / 'gradlew').exists() or (current_dir / 'gradlew.bat').exists():
+ return current_dir
+ current_dir = current_dir.parent
+
+ # If gradlew not found, assume parent directory of develop as project root
+ return Path(__file__).parent.parent.absolute()
+
+
+def parse_run_configurations(project_root, service_name=None):
+ """Parse run configuration files from .run directories"""
+ configurations = {}
+
+ if service_name:
+ # Parse specific service configuration
+ run_config_path = project_root / service_name / '.run' / f'{service_name}.run.xml'
+ if run_config_path.exists():
+ config = parse_single_run_config(run_config_path, service_name)
+ if config:
+ configurations[service_name] = config
+ else:
+ print(f"[ERROR] Cannot find run configuration: {run_config_path}")
+ else:
+ # Find all service directories
+ service_dirs = ['user-service', 'location-service', 'trip-service', 'ai-service']
+ for service in service_dirs:
+ run_config_path = project_root / service / '.run' / f'{service}.run.xml'
+ if run_config_path.exists():
+ config = parse_single_run_config(run_config_path, service)
+ if config:
+ configurations[service] = config
+
+ return configurations
+
+
+def parse_single_run_config(config_path, service_name):
+ """Parse a single run configuration file"""
+ try:
+ tree = ET.parse(config_path)
+ root = tree.getroot()
+
+ # Find configuration element
+ config = root.find('.//configuration[@type="GradleRunConfiguration"]')
+ if config is None:
+ print(f"[WARNING] No Gradle configuration found in {config_path}")
+ return None
+
+ # Extract environment variables
+ env_vars = {}
+ env_option = config.find('.//option[@name="env"]')
+ if env_option is not None:
+ env_map = env_option.find('map')
+ if env_map is not None:
+ for entry in env_map.findall('entry'):
+ key = entry.get('key')
+ value = entry.get('value')
+ if key and value:
+ env_vars[key] = value
+
+ # Extract task names
+ task_names = []
+ task_names_option = config.find('.//option[@name="taskNames"]')
+ if task_names_option is not None:
+ task_list = task_names_option.find('list')
+ if task_list is not None:
+ for option in task_list.findall('option'):
+ value = option.get('value')
+ if value:
+ task_names.append(value)
+
+ if env_vars or task_names:
+ return {
+ 'env_vars': env_vars,
+ 'task_names': task_names,
+ 'config_path': str(config_path)
+ }
+
+ return None
+
+ except ET.ParseError as e:
+ print(f"[ERROR] XML parsing error in {config_path}: {e}")
+ return None
+ except Exception as e:
+ print(f"[ERROR] Error reading {config_path}: {e}")
+ return None
+
+
+def get_gradle_command(project_root):
+ """Return appropriate Gradle command for OS"""
+ if os.name == 'nt': # Windows
+ gradle_bat = project_root / 'gradlew.bat'
+ if gradle_bat.exists():
+ return str(gradle_bat)
+ return 'gradle.bat'
+ else: # Unix-like (Linux, macOS)
+ gradle_sh = project_root / 'gradlew'
+ if gradle_sh.exists():
+ return str(gradle_sh)
+ return 'gradle'
+
+
+def run_service(service_name, config, project_root):
+ """Run service"""
+ print(f"[START] Starting {service_name} service...")
+
+ # Set environment variables
+ env = os.environ.copy()
+ for key, value in config['env_vars'].items():
+ env[key] = value
+ print(f" [ENV] {key}={value}")
+
+ # Prepare Gradle command
+ gradle_cmd = get_gradle_command(project_root)
+
+ # Execute tasks
+ for task_name in config['task_names']:
+ print(f"\n[RUN] Executing: {task_name}")
+
+ cmd = [gradle_cmd, task_name]
+
+ try:
+ # Execute from project root directory
+ process = subprocess.Popen(
+ cmd,
+ cwd=project_root,
+ env=env,
+ stdout=subprocess.PIPE,
+ stderr=subprocess.STDOUT,
+ universal_newlines=True,
+ bufsize=1,
+ encoding='utf-8',
+ errors='replace'
+ )
+
+ print(f"[CMD] Command: {' '.join(cmd)}")
+ print(f"[DIR] Working directory: {project_root}")
+ print("=" * 50)
+
+ # Real-time output
+ for line in process.stdout:
+ print(line.rstrip())
+
+ # Wait for process completion
+ process.wait()
+
+ if process.returncode == 0:
+ print(f"\n[SUCCESS] {task_name} execution completed")
+ else:
+ print(f"\n[FAILED] {task_name} execution failed (exit code: {process.returncode})")
+ return False
+
+ except KeyboardInterrupt:
+ print(f"\n[STOP] Interrupted by user")
+ process.terminate()
+ return False
+ except Exception as e:
+ print(f"\n[ERROR] Execution error: {e}")
+ return False
+
+ return True
+
+
+def list_available_services(configurations):
+ """List available services"""
+ print("[LIST] Available services:")
+ print("=" * 40)
+
+ for service_name, config in configurations.items():
+ if config['task_names']:
+ print(f" [SERVICE] {service_name}")
+ if 'config_path' in config:
+ print(f" +-- Config: {config['config_path']}")
+ for task in config['task_names']:
+ print(f" +-- Task: {task}")
+ print(f" +-- {len(config['env_vars'])} environment variables")
+ print()
+
+
+def main():
+ """Main function"""
+ parser = argparse.ArgumentParser(
+ description='Tripgen Service Runner Script',
+ formatter_class=argparse.RawDescriptionHelpFormatter,
+ epilog="""
+Examples:
+ python run-config.py user-service
+ python run-config.py location-service
+ python run-config.py trip-service
+ python run-config.py ai-service
+ python run-config.py --list
+ """
+ )
+
+ parser.add_argument(
+ 'service_name',
+ nargs='?',
+ help='Service name to run'
+ )
+
+ parser.add_argument(
+ '--list', '-l',
+ action='store_true',
+ help='List available services'
+ )
+
+ args = parser.parse_args()
+
+ # Find project root
+ project_root = get_project_root()
+ print(f"[INFO] Project root: {project_root}")
+
+ # Parse run configurations
+ print("[INFO] Reading run configuration files...")
+ configurations = parse_run_configurations(project_root)
+
+ if not configurations:
+ print("[ERROR] No execution configurations found")
+ return 1
+
+ print(f"[INFO] Found {len(configurations)} execution configurations")
+
+ # List services request
+ if args.list:
+ list_available_services(configurations)
+ return 0
+
+ # If service name not provided
+ if not args.service_name:
+ print("\n[ERROR] Please provide service name")
+ list_available_services(configurations)
+ print("Usage: python run-config.py ")
+ return 1
+
+ # Find service
+ service_name = args.service_name
+
+ # Try to parse specific service configuration if not found
+ if service_name not in configurations:
+ print(f"[INFO] Trying to find configuration for '{service_name}'...")
+ configurations = parse_run_configurations(project_root, service_name)
+
+ if service_name not in configurations:
+ print(f"[ERROR] Cannot find '{service_name}' service")
+ list_available_services(configurations)
+ return 1
+
+ config = configurations[service_name]
+
+ if not config['task_names']:
+ print(f"[ERROR] No executable tasks found for '{service_name}' service")
+ return 1
+
+ # Execute service
+ print(f"\n[TARGET] Starting '{service_name}' service execution")
+ print("=" * 50)
+
+ success = run_service(service_name, config, project_root)
+
+ if success:
+ print(f"\n[COMPLETE] '{service_name}' service started successfully!")
+ return 0
+ else:
+ print(f"\n[FAILED] Failed to start '{service_name}' service")
+ return 1
+
+
+if __name__ == '__main__':
+ try:
+ exit_code = main()
+ sys.exit(exit_code)
+ except KeyboardInterrupt:
+ print("\n[STOP] Interrupted by user")
+ sys.exit(1)
+ except Exception as e:
+ print(f"\n[ERROR] Unexpected error occurred: {e}")
+ sys.exit(1)
\ No newline at end of file