From 5c8aced0431d2f0757b7d93112faaf1a9375a868 Mon Sep 17 00:00:00 2001 From: doyeon Date: Fri, 24 Oct 2025 09:21:39 +0900 Subject: [PATCH] =?UTF-8?q?Participation=20Service=20=EB=B0=B1=EC=97=94?= =?UTF-8?q?=EB=93=9C=20=EA=B0=9C=EB=B0=9C=20=EC=99=84=EB=A3=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주요 구현 사항: - 이벤트 참여 등록 및 중복 검증 (Redis Cache + DB) - 참여자 목록 조회 (필터링, 검색, 페이징) - 당첨자 추첨 (Fisher-Yates Shuffle 알고리즘) - Kafka 이벤트 발행 (ParticipantRegistered) - Redis 캐싱으로 성능 최적화 - 전화번호 마스킹 (개인정보 보호) - 전역 예외 처리 및 검증 기술 스택: - Spring Boot 3.x + JPA - MySQL (참여자, 추첨 로그) - Redis (캐싱, 중복 검증) - Kafka (이벤트 발행) API 엔드포인트: - POST /events/{eventId}/participate - GET /events/{eventId}/participants - GET /events/{eventId}/participants/search - POST /events/{eventId}/draw-winners - GET /events/{eventId}/winners 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude --- .claude/settings.local.json | 4 +- .gradle/8.10/checksums/checksums.lock | Bin 17 -> 17 bytes .gradle/8.10/checksums/md5-checksums.bin | Bin 73965 -> 125933 bytes .gradle/8.10/checksums/sha1-checksums.bin | Bin 153107 -> 266603 bytes .../executionHistory/executionHistory.bin | Bin 85985 -> 140630 bytes .../executionHistory/executionHistory.lock | Bin 17 -> 17 bytes .gradle/8.10/fileHashes/fileHashes.bin | Bin 20297 -> 25847 bytes .gradle/8.10/fileHashes/fileHashes.lock | Bin 17 -> 17 bytes .../8.10/fileHashes/resourceHashesCache.bin | Bin 19075 -> 21285 bytes .../buildOutputCleanup.lock | Bin 17 -> 17 bytes .gradle/buildOutputCleanup/outputFiles.bin | Bin 18965 -> 19757 bytes .gradle/file-system.probe | Bin 8 -> 8 bytes .run/ParticipationServiceApplication.run.xml | 71 +++ .../ParticipationServiceApplication.java | 25 ++ .../application/dto/ErrorResponse.java | 38 ++ .../application/dto/ParticipantDto.java | 76 ++++ .../dto/ParticipantListResponse.java | 54 +++ .../application/dto/ParticipationRequest.java | 67 +++ .../dto/ParticipationResponse.java | 64 +++ .../application/dto/WinnerDrawRequest.java | 35 ++ .../application/dto/WinnerDrawResponse.java | 59 +++ .../application/dto/WinnerDto.java | 70 +++ .../application/service/LotteryAlgorithm.java | 117 +++++ .../service/ParticipationService.java | 403 ++++++++++++++++++ .../service/WinnerDrawService.java | 312 ++++++++++++++ .../exception/AlreadyDrawnException.java | 18 + .../DuplicateParticipationException.java | 18 + .../exception/EventNotActiveException.java | 18 + .../exception/EventNotFoundException.java | 18 + .../exception/GlobalExceptionHandler.java | 110 +++++ .../InsufficientParticipantsException.java | 18 + .../exception/ParticipationException.java | 24 ++ .../domain/common/BaseEntity.java | 37 ++ .../participation/domain/draw/DrawLog.java | 134 ++++++ .../domain/draw/DrawLogRepository.java | 46 ++ .../domain/participant/Participant.java | 161 +++++++ .../participant/ParticipantRepository.java | 132 ++++++ .../kafka/KafkaProducerService.java | 59 +++ .../kafka/config/KafkaConfig.java | 22 + .../event/ParticipantRegisteredEvent.java | 46 ++ .../redis/RedisCacheService.java | 166 ++++++++ .../redis/config/RedisConfig.java | 104 +++++ .../controller/ParticipationController.java | 181 ++++++++ .../controller/WinnerController.java | 114 +++++ .../src/main/resources/application.yml | 88 ++++ 45 files changed, 2908 insertions(+), 1 deletion(-) create mode 100644 .run/ParticipationServiceApplication.run.xml create mode 100644 participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java create mode 100644 participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java create mode 100644 participation-service/src/main/resources/application.yml diff --git a/.claude/settings.local.json b/.claude/settings.local.json index 8d1f14d..631f8a1 100644 --- a/.claude/settings.local.json +++ b/.claude/settings.local.json @@ -15,7 +15,9 @@ "Bash(git add:*)", "Bash(git commit:*)", "Bash(git push)", - "Bash(git pull:*)" + "Bash(git pull:*)", + "Bash(dir:*)", + "Bash(./gradlew participation-service:compileJava:*)" ], "deny": [], "ask": [] diff --git a/.gradle/8.10/checksums/checksums.lock b/.gradle/8.10/checksums/checksums.lock index 837e5b9337bcfdbcf1aebd76ad9c0a8bfa0490ec..4a61e201f33ad87d79ad4ac9b33ed49017414391 100644 GIT binary patch literal 17 VcmZQJJh3L%MKEUr0~qj^0st)I1PcHF literal 17 VcmZQJJh3L%MKEUr0~l~D0RSxo1Tz2t diff --git a/.gradle/8.10/checksums/md5-checksums.bin b/.gradle/8.10/checksums/md5-checksums.bin index 04c6d0050987548d4ed9b0f3828b171e49d75fb8..d7041a7b50e18ecc9e3ba7d7774a5cb6ba644ab7 100644 GIT binary patch delta 23357 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 diff --git a/.gradle/8.10/checksums/sha1-checksums.bin b/.gradle/8.10/checksums/sha1-checksums.bin index 19a54106b689d3aaf682f717492529d1d26814ea..ec94342c1d315cfebffbbc1a00418ee2bb0af767 100644 GIT binary patch delta 47407 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 delta 3860 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 diff --git a/.gradle/8.10/executionHistory/executionHistory.bin b/.gradle/8.10/executionHistory/executionHistory.bin index 2177cdd01b3d65d3f655cadb7b28c6362b23ce72..d409ba46566f6114298fd08c87d0cb5a6d87af92 100644 GIT binary patch literal 140630 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 hrp?IS>igVH@9TA5fch(LI>>3`a_z>5F? delta 3272 zcmeHJi&Il)7Uv`pLWFYRl7Kvlw(GbfR)Y-efV3o~mQX-I1e8)rK#_-}%R?w^m9Dkw z>bihu$|r9We4ws8fT})IVEq5+dx0-81r2R_!A#xuNLO) zF}&d!66gLK25%_>)Hf9q0OFcRIq<~gDEGF-h$w~n#Fb z*mTZ}$&UEn^Qj&87u;Vq8T*vv8(`nLN@nuSESD6~hLCuPUH%c(`%PGSzKR*1lb3Yo ziASxi;e_2{{p%lM#07i!X(r&vN_})*X$$|x;fo1mQ#gpxzeQlqXFliO{6d-8J8>~X zcG7C9r3)%CF;G0CfYF%{nU@Sv6*w1KXoyte_bHfJ#wk;ke5TyzLBqqL(L07u89P_k zw+7$@arhLZIRD}<=E_1vy~UQRZf(w@@u;^`QcxKt!OTk{?7g_3ITd+4eXu8~J|H6h zw*Bo}D^S)pZIq^4qo(!mX8P5e8$SO}VXs@p&g}_VPC}^RBEN}$31(qrrm)Scg*X~) z$J6l8|6Dw7UTs81WH85!&jgjT&zA44aMHA2^$lLIC7dQ`F{}8~wr|<7+0A1MM{d~M zJ0%4*2KbxfV$ES@V!NqICU2Rrh@A(R(j;~L zo%j1(?wSOTGd+Twul_*CAt6>CAi>b~B+~8yEEU<^JG3ysZ9P-)j;zB_U2}u>!CGs> zqRU?~mF^cFs8SxCJ;~d2{j4G{o{m{Ue4>HD;Pzc={qXg$JoSv8Uh0{GwUNn@>Cx+> zGvd}ds$!RJbc|6ZD>IT~5|xZgoYfl+ZG|_6=1b>ZSX><2q5m7I?j3*d;uXt3WKO$J!{ZoeXD%Ji{q|0?VmV9}wh7b;i{ZJe4_~DgUJIbPIMJcX*@FKAxnjiTPjecg~9@V*E+c6PV9*HKa zGihT?D*`a>s2^$KGEUpMQD)a#;nHJ%C>>+riO%awBHcwrQ!mQmwo&sx!>0F!yvi9( z>V+78JP~(1evw{??^SxDbex4Q>Z!n6l>yj2&Z4}k67!z02-Ugh{*;C5S}WJ5C)M6K zK$+V!%)y!<;-5>CPlw<{pQydidxC}YROEuAYG3?;B5sV+n=!jK4UbH+F!T~*g~=xZ zF=pzQ9b$YkmH6+bVYwN(;P)rPNmCw$trR+8a$OJ!&8Kiw3r*_3AT|XrK_Pb6r;wW* zHSM4GZ8+IhNMVoGp#&{4_>>=!@hQa(nJX-T+%KZAlR{Ul_}3cpk75cnTDYPikSr>p zL;0nZBMpgUd=|Ce)p{RmRFNuu3csh&iM~9>s5Lu0vH_Bl3)p@L+ufj{E*D`xz*v0N;?;w5U;+BBw6LX;oVzj(%-W&(~Q z^E|$D`-JV7cbeMDum72qpq;8tM@SRi_+4Yzk06rnWT zf<)Nz49HOhFoJqc(xiYzf--J3tbmoGEV|m~`1O1sszr9)fYMx*;!ER5$Dc4|x9vq-)L# diff --git a/.gradle/8.10/executionHistory/executionHistory.lock b/.gradle/8.10/executionHistory/executionHistory.lock index 0ce4c9646ca85d214092028f7db63bee6e79e803..ce0d2726fbdc1282e7b6194327b2dc1e36a939c4 100644 GIT binary patch literal 17 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 BKUx3) delta 141 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 Do$KZD delta 95 zcmZ3wjIntt;|3E6LBYo2gLgx!|MxS1fye8~r4kC0k4kt?$QOc`p#NN=r=s t3dEr@5|edhB__uLajKle@~ literal 17 UcmZQ(PG7Ze^~s_h1_&?)05hosdjJ3c diff --git a/.gradle/buildOutputCleanup/outputFiles.bin b/.gradle/buildOutputCleanup/outputFiles.bin index 4ed6f06d6395816365de6075047efe060d9cdefb..6bb372f9cd8a8608171f13da8cec8ef19b3382b5 100644 GIT binary patch delta 1141 zcmYk(e=L-79KdlZam29E-5Cy3r^U|1+#fB{+~a;QlTm)Q#c7>LT@&M`5)GAo>DMN+ zu=V3ym#|bf?5)DKfA7OfLWVD3>gJ>h+Ida$6R6R`@D=E!?)UiqJR;LGTbt~4ZFiYXkW zUE+bREFg7ReXf{%oE$-?}pDhOPG4zg8H@5j_gz#rzu!e&!1E1A>JO4WE;`8Xw zhGMPdT?L^MZan?5qT>67f7ed@i-ZR6UKUQ&rhEGUVXp}IB4J+LF`-QHwu9D!V zP&$%O7KdvXH#1ff6k5Pwe3$WTIyJV~-~wJnBrJ}?)VM|%S(7$2id-wN>F+@f`a{_=)0md`qBaoKN|)=a1c=ISe7y-pMphc=z$}C19R1R9pEBT zzwwuNxh(Jpd`6O2c;Ws*e~NP$QkQJ;CwR4x)qV4O__hAH^2sQ z6vnA{sSx}0*))hJ(2vi12>b1Vi)j~~B)M!3SEg<2zA0WZLtKWjY_KL{rua-e#7(4} z&5;%K+o?3>Wp9YzVtj6lQBnRuWrFw!dTH(<<7Yov4((zox+1xChu8RU9e5%-UahRQ lH>rJAcB*-WF71(&-kQY4NJ%CoBe7K2#(5O3|KFnp`~?~HBYyw@ delta 101 zcmZ2Gi*f1{#tkMCf>J$k&K~)_j;ah`;PH6!QVEO6b0rfdi%Jz)VNXchxkT=8KRpVJ+3eU0OJ-WAOHXW diff --git a/.gradle/file-system.probe b/.gradle/file-system.probe index ac4beb46220d110a11f9e5f196fa452a079e920d..f13d4e3a8d8d9a3425bbb02439ea55435101fcab 100644 GIT binary patch literal 8 PcmZQzV4Nj9WkwqS2F?Ou literal 8 PcmZQzV4Nl3y6qtV25bU| diff --git a/.run/ParticipationServiceApplication.run.xml b/.run/ParticipationServiceApplication.run.xml new file mode 100644 index 0000000..624b419 --- /dev/null +++ b/.run/ParticipationServiceApplication.run.xml @@ -0,0 +1,71 @@ + + + + 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..e82842f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/ParticipationServiceApplication.java @@ -0,0 +1,25 @@ +package com.kt.event.participation; + +import org.springframework.boot.SpringApplication; +import org.springframework.boot.autoconfigure.SpringBootApplication; +import org.springframework.data.jpa.repository.config.EnableJpaAuditing; + +/** + * Participation Service Application + * - 이벤트 참여 및 당첨자 관리 서비스 + * - Port: 8084 + * - Database: PostgreSQL (participation_db) + * - Cache: Redis + * - Messaging: Kafka (participant-events topic) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@SpringBootApplication +@EnableJpaAuditing +public class ParticipationServiceApplication { + + public static void main(String[] args) { + SpringApplication.run(ParticipationServiceApplication.class, args); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java new file mode 100644 index 0000000..e1e906f --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ErrorResponse.java @@ -0,0 +1,38 @@ +package com.kt.event.participation.application.dto; + +import java.time.LocalDateTime; + +/** + * API 오류 응답 DTO + */ +public class ErrorResponse { + + private boolean success; + private String errorCode; + private String message; + private LocalDateTime timestamp; + + public ErrorResponse(String errorCode, String message) { + this.success = false; + this.errorCode = errorCode; + this.message = message; + this.timestamp = LocalDateTime.now(); + } + + // Getters + public boolean isSuccess() { + return success; + } + + public String getErrorCode() { + return errorCode; + } + + public String getMessage() { + return message; + } + + public LocalDateTime getTimestamp() { + return timestamp; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java new file mode 100644 index 0000000..b37245a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantDto.java @@ -0,0 +1,76 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 참여자 정보 DTO + * 참여자 목록 조회 시 사용되는 기본 참여자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantDto { + + /** + * 참여자 ID + */ + private String participantId; + + /** + * 참여자 이름 + */ + private String name; + + /** + * 마스킹된 전화번호 + * 예: 010-****-5678 + */ + private String maskedPhoneNumber; + + /** + * 참여자 이메일 + */ + private String email; + + /** + * 유입 경로 + * 예: ONLINE, STORE_VISIT + */ + private String entryPath; + + /** + * 참여 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime participatedAt; + + /** + * 당첨 여부 + */ + private Boolean isWinner; + + /** + * 당첨 일시 + * 당첨되지 않은 경우 null + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime wonAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 수 + */ + private Integer bonusEntries; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java new file mode 100644 index 0000000..1833db9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipantListResponse.java @@ -0,0 +1,54 @@ +package com.kt.event.participation.application.dto; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.util.List; + +/** + * 참여자 목록 응답 DTO + * 페이징된 참여자 목록과 페이지 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantListResponse { + + /** + * 참여자 목록 + */ + private List participants; + + /** + * 전체 참여자 수 + */ + private Integer totalElements; + + /** + * 전체 페이지 수 + */ + private Integer totalPages; + + /** + * 현재 페이지 번호 (0부터 시작) + */ + private Integer currentPage; + + /** + * 페이지 크기 + */ + private Integer pageSize; + + /** + * 다음 페이지 존재 여부 + */ + private Boolean hasNext; + + /** + * 이전 페이지 존재 여부 + */ + private Boolean hasPrevious; +} 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..0a106cb --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationRequest.java @@ -0,0 +1,67 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import jakarta.validation.constraints.Pattern; +import jakarta.validation.constraints.Size; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 이벤트 참여 요청 DTO + * 고객이 이벤트에 참여할 때 전달하는 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationRequest { + + /** + * 참여자 이름 + * 필수 입력, 2-50자 제한 + */ + @NotBlank(message = "이름은 필수 입력입니다") + @Size(min = 2, max = 50, message = "이름은 2-50자 사이여야 합니다") + private String name; + + /** + * 참여자 전화번호 + * 필수 입력, 하이픈 포함 형식 (예: 010-1234-5678) + */ + @NotBlank(message = "전화번호는 필수 입력입니다") + @Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다 (예: 010-1234-5678)") + private String phoneNumber; + + /** + * 참여자 이메일 + * 선택 입력, 이메일 형식 검증 + */ + @Email(message = "이메일 형식이 올바르지 않습니다") + private String email; + + /** + * 마케팅 정보 수신 동의 + * 선택, 기본값 false + */ + @Builder.Default + private Boolean agreeMarketing = false; + + /** + * 개인정보 수집 및 이용 동의 + * 필수 동의 + */ + @NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다") + private Boolean agreePrivacy; + + /** + * 매장 방문 여부 + * 매장 방문 시 보너스 응모권 추가 제공 + * 기본값 false + */ + @Builder.Default + private Boolean storeVisited = false; +} 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..69b5125 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/ParticipationResponse.java @@ -0,0 +1,64 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 이벤트 참여 응답 DTO + * 참여 완료 후 반환되는 참여자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipationResponse { + + /** + * 참여자 ID + * 고유 식별자 (예: prt_20250123_001) + */ + private String participantId; + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 참여자 이름 + */ + private String name; + + /** + * 참여자 전화번호 + */ + private String phoneNumber; + + /** + * 참여자 이메일 + */ + private String email; + + /** + * 참여 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime participatedAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 수 + * 기본 1회, 매장 방문 시 +1 추가 + */ + private Integer bonusEntries; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java new file mode 100644 index 0000000..b9218db --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawRequest.java @@ -0,0 +1,35 @@ +package com.kt.event.participation.application.dto; + +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +/** + * 당첨자 추첨 요청 DTO + * 당첨자 추첨 시 필요한 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDrawRequest { + + /** + * 당첨자 수 + * 필수 입력, 최소 1명 이상 + */ + @NotNull(message = "당첨자 수는 필수 입력입니다") + @Min(value = 1, message = "당첨자 수는 최소 1명 이상이어야 합니다") + private Integer winnerCount; + + /** + * 매장 방문 보너스 적용 여부 + * 매장 방문자에게 가중치 부여 + * 기본값 true + */ + @Builder.Default + private Boolean visitBonusApplied = true; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java new file mode 100644 index 0000000..50de1fe --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDrawResponse.java @@ -0,0 +1,59 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; +import java.util.List; + +/** + * 당첨자 추첨 응답 DTO + * 추첨 완료 후 반환되는 당첨자 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDrawResponse { + + /** + * 당첨자 목록 + */ + private List winners; + + /** + * 추첨 로그 ID + * 추첨 이력 추적용 고유 식별자 + */ + private String drawLogId; + + /** + * 응답 메시지 + */ + private String message; + + /** + * 이벤트 ID + */ + private String eventId; + + /** + * 전체 참여자 수 + */ + private Integer totalParticipants; + + /** + * 당첨자 수 + */ + private Integer winnerCount; + + /** + * 추첨 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime drawnAt; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java new file mode 100644 index 0000000..cf05044 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/dto/WinnerDto.java @@ -0,0 +1,70 @@ +package com.kt.event.participation.application.dto; + +import com.fasterxml.jackson.annotation.JsonFormat; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * 당첨자 정보 DTO + * 당첨자 목록 및 추첨 결과에 사용되는 당첨자 기본 정보 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class WinnerDto { + + /** + * 참여자 ID + */ + private String participantId; + + /** + * 당첨자 이름 + */ + private String name; + + /** + * 마스킹된 전화번호 + * 예: 010-****-5678 + */ + private String maskedPhoneNumber; + + /** + * 당첨자 이메일 + */ + private String email; + + /** + * 응모 번호 + * 추첨 시 사용된 번호 + */ + private Integer applicationNumber; + + /** + * 당첨 순위 + * 1부터 시작 + */ + private Integer rank; + + /** + * 당첨 일시 + * ISO 8601 형식 (yyyy-MM-dd'T'HH:mm:ss) + */ + @JsonFormat(pattern = "yyyy-MM-dd'T'HH:mm:ss") + private LocalDateTime wonAt; + + /** + * 매장 방문 여부 + */ + private Boolean storeVisited; + + /** + * 보너스 응모권 적용 여부 + */ + private Boolean bonusApplied; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java b/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java new file mode 100644 index 0000000..853ec74 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/LotteryAlgorithm.java @@ -0,0 +1,117 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.domain.participant.Participant; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +import java.security.SecureRandom; +import java.util.ArrayList; +import java.util.List; + +/** + * 당첨자 추첨 알고리즘 + * - Fisher-Yates Shuffle 알고리즘 사용 + * - 암호학적 난수 생성 (Crypto.randomBytes 대신 SecureRandom 사용) + * - 매장 방문 가산점 적용 + * + * 시간 복잡도: O(n log n) + * 공간 복잡도: O(n) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Component +public class LotteryAlgorithm { + + private static final SecureRandom secureRandom = new SecureRandom(); + + /** + * 당첨자 추첨 실행 + * + * @param participants 전체 참여자 목록 + * @param winnerCount 당첨 인원 + * @param visitBonusApplied 매장 방문 가산점 적용 여부 + * @param visitBonusWeight 매장 방문 가중치 (기본 2.0) + * @return 당첨자 목록 + */ + public List executeLottery( + List participants, + int winnerCount, + boolean visitBonusApplied, + double visitBonusWeight + ) { + log.info("Starting lottery execution - Total participants: {}, Winner count: {}, Visit bonus: {}", + participants.size(), winnerCount, visitBonusApplied); + + // Step 1: 가산점 적용 (매장 방문 시) + List weightedParticipants = applyWeights(participants, visitBonusApplied, visitBonusWeight); + + // Step 2: Fisher-Yates Shuffle + List shuffled = fisherYatesShuffle(weightedParticipants); + + // Step 3: 상위 N명 선정 + List winners = shuffled.subList(0, Math.min(winnerCount, shuffled.size())); + + log.info("Lottery execution completed - Winners selected: {}", winners.size()); + return winners; + } + + /** + * Step 1: 가산점 적용 + * - 매장 방문 고객은 가중치만큼 참여자 목록에 중복 추가 + * + * @param participants 참여자 목록 + * @param visitBonusApplied 가산점 적용 여부 + * @param visitBonusWeight 가중치 + * @return 가중치 적용된 참여자 목록 + */ + private List applyWeights(List participants, boolean visitBonusApplied, double visitBonusWeight) { + if (!visitBonusApplied) { + return new ArrayList<>(participants); + } + + List weighted = new ArrayList<>(); + for (Participant p : participants) { + // 매장 방문 고객은 가중치만큼 추가 + if (p.getStoreVisited() != null && p.getStoreVisited()) { + int bonusEntries = (int) visitBonusWeight; + for (int i = 0; i < bonusEntries; i++) { + weighted.add(p); + } + } else { + // 비방문 고객은 1회 추가 + weighted.add(p); + } + } + + log.debug("Applied visit bonus - Original size: {}, Weighted size: {}", participants.size(), weighted.size()); + return weighted; + } + + /** + * Step 2: Fisher-Yates Shuffle 알고리즘 + * - 암호학적 난수 생성 (SecureRandom) + * - 시간 복잡도: O(n) + * + * @param participants 참여자 목록 + * @return 셔플된 참여자 목록 + */ + private List fisherYatesShuffle(List participants) { + List shuffled = new ArrayList<>(participants); + int n = shuffled.size(); + + // Fisher-Yates shuffle + for (int i = n - 1; i > 0; i--) { + // 암호학적으로 안전한 난수 생성 (0 ~ i 범위) + int j = secureRandom.nextInt(i + 1); + + // Swap + Participant temp = shuffled.get(i); + shuffled.set(i, shuffled.get(j)); + shuffled.set(j, temp); + } + + return shuffled; + } +} 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..6e07e3c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/ParticipationService.java @@ -0,0 +1,403 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.application.dto.*; +import com.kt.event.participation.common.exception.*; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import com.kt.event.participation.infrastructure.kafka.KafkaProducerService; +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import com.kt.event.participation.infrastructure.redis.RedisCacheService; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.time.format.DateTimeFormatter; +import java.util.List; +import java.util.Random; +import java.util.stream.Collectors; + +/** + * 이벤트 참여 서비스 + * - 참여자 등록 및 조회 + * - 중복 참여 검증 (Redis Cache + DB) + * - Kafka 이벤트 발행 + * + * 비즈니스 원칙: + * - 중복 참여 방지: 이벤트ID + 전화번호 조합으로 중복 검증 + * - 응모 번호 생성: EVT-{timestamp}-{random} 형식 + * - 캐시 우선: Redis 캐시로 성능 최적화 (Best Effort) + * - 비동기 이벤트: Kafka로 참여 이벤트 발행 (Best Effort) + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class ParticipationService { + + private final ParticipantRepository participantRepository; + private final RedisCacheService redisCacheService; + private final KafkaProducerService kafkaProducerService; + + @Value("${app.participation.draw-days-after-end:3}") + private int drawDaysAfterEnd; + + @Value("${app.participation.visit-bonus-weight:2.0}") + private double visitBonusWeight; + + @Value("${app.cache.duplicate-check-ttl:604800}") + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") + private long participantListTtl; + + private static final Random random = new Random(); + + /** + * 이벤트 참여자 등록 + * + * Flow: + * 1. 중복 참여 검증 (Redis Cache → DB) + * 2. 응모 번호 생성 (EVT-{timestamp}-{random}) + * 3. 참여자 저장 + * 4. 중복 체크 캐시 저장 (TTL: 7일) + * 5. Kafka 이벤트 발행 (Best Effort) + * 6. 응답 반환 (추첨일: 이벤트 종료 + 3일) + * + * @param eventId 이벤트 ID + * @param request 참여 요청 정보 + * @return 참여 응답 (응모 번호, 참여 일시, 추첨일 등) + * @throws DuplicateParticipationException 중복 참여 시 + */ + @Transactional + public ParticipationResponse registerParticipant(String eventId, ParticipationRequest request) { + log.info("Registering participant - eventId: {}, name: {}, phone: {}", + eventId, request.getName(), maskPhoneNumber(request.getPhoneNumber())); + + // Step 1: 중복 참여 검증 (Cache → DB) + validateDuplicateParticipation(eventId, request.getPhoneNumber()); + + // Step 2: 응모 번호 생성 + String applicationNumber = generateApplicationNumber(); + + // Step 3: 참여자 엔티티 생성 및 저장 + Participant participant = Participant.builder() + .eventId(eventId) + .name(request.getName()) + .phoneNumber(request.getPhoneNumber()) + .email(request.getEmail()) + .entryPath(determineEntryPath(request)) + .applicationNumber(applicationNumber) + .participatedAt(LocalDateTime.now()) + .storeVisited(request.getStoreVisited() != null ? request.getStoreVisited() : false) + .agreeMarketing(request.getAgreeMarketing() != null ? request.getAgreeMarketing() : false) + .agreePrivacy(request.getAgreePrivacy()) + .isWinner(false) + .bonusEntries(1) + .build(); + + // 매장 방문 보너스 응모권 적용 + participant.applyVisitBonus(visitBonusWeight); + + Participant saved = participantRepository.save(participant); + + log.info("Participant registered successfully - participantId: {}, applicationNumber: {}", + saved.getParticipantId(), saved.getApplicationNumber()); + + // Step 4: 중복 체크 캐시 저장 (Best Effort) + try { + redisCacheService.cacheDuplicateCheck( + Long.parseLong(eventId), + request.getPhoneNumber(), + duplicateCheckTtl + ); + } catch (Exception e) { + log.warn("Failed to cache duplicate check - eventId: {}, phone: {}", + eventId, maskPhoneNumber(request.getPhoneNumber()), e); + } + + // Step 5: Kafka 이벤트 발행 (Best Effort) + publishParticipantRegisteredEvent(saved); + + // Step 6: 응답 반환 + return ParticipationResponse.builder() + .participantId(String.valueOf(saved.getParticipantId())) + .eventId(saved.getEventId()) + .name(saved.getName()) + .phoneNumber(saved.getPhoneNumber()) + .email(saved.getEmail()) + .participatedAt(saved.getParticipatedAt()) + .storeVisited(saved.getStoreVisited()) + .bonusEntries(saved.getBonusEntries()) + .build(); + } + + /** + * 이벤트 참여자 목록 조회 (필터링 + 페이징) + * + * Flow: + * 1. 캐시 조회 (Cache Hit → 즉시 반환) + * 2. Cache Miss → DB 조회 + * 3. 전화번호 마스킹 + * 4. 캐시 저장 (TTL: 10분) + * 5. 응답 반환 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 필터 (nullable) + * @param isWinner 당첨 여부 필터 (nullable) + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + public ParticipantListResponse getParticipantList( + String eventId, + String entryPath, + Boolean isWinner, + Pageable pageable) { + + log.info("Fetching participant list - eventId: {}, entryPath: {}, isWinner: {}, page: {}", + eventId, entryPath, isWinner, pageable.getPageNumber()); + + // Step 1: 캐시 키 생성 + String cacheKey = buildCacheKey(eventId, entryPath, isWinner, pageable); + + // Step 2: 캐시 조회 (Cache Hit → 즉시 반환) + try { + var cachedData = redisCacheService.getParticipantList(cacheKey); + if (cachedData.isPresent()) { + log.debug("Cache hit - cacheKey: {}", cacheKey); + return (ParticipantListResponse) cachedData.get(); + } + } catch (Exception e) { + log.warn("Failed to retrieve from cache - cacheKey: {}", cacheKey, e); + } + + // Step 3: Cache Miss → DB 조회 + Page participantPage = participantRepository.findParticipants( + eventId, entryPath, isWinner, pageable); + + // Step 4: DTO 변환 및 전화번호 마스킹 + List participants = participantPage.getContent().stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + + ParticipantListResponse response = ParticipantListResponse.builder() + .participants(participants) + .totalElements((int) participantPage.getTotalElements()) + .totalPages(participantPage.getTotalPages()) + .currentPage(participantPage.getNumber()) + .pageSize(participantPage.getSize()) + .hasNext(participantPage.hasNext()) + .hasPrevious(participantPage.hasPrevious()) + .build(); + + // Step 5: 캐시 저장 (Best Effort) + try { + redisCacheService.cacheParticipantList(cacheKey, response, participantListTtl); + } catch (Exception e) { + log.warn("Failed to cache participant list - cacheKey: {}", cacheKey, e); + } + + log.info("Participant list fetched successfully - eventId: {}, totalElements: {}", + eventId, response.getTotalElements()); + + return response; + } + + /** + * 참여자 검색 (이름 또는 전화번호) + * + * @param eventId 이벤트 ID + * @param keyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 검색된 참여자 목록 (페이징) + */ + public ParticipantListResponse searchParticipants( + String eventId, + String keyword, + Pageable pageable) { + + log.info("Searching participants - eventId: {}, keyword: {}, page: {}", + eventId, keyword, pageable.getPageNumber()); + + Page participantPage = participantRepository.searchParticipants( + eventId, keyword, pageable); + + List participants = participantPage.getContent().stream() + .map(this::convertToDto) + .collect(Collectors.toList()); + + ParticipantListResponse response = ParticipantListResponse.builder() + .participants(participants) + .totalElements((int) participantPage.getTotalElements()) + .totalPages(participantPage.getTotalPages()) + .currentPage(participantPage.getNumber()) + .pageSize(participantPage.getSize()) + .hasNext(participantPage.hasNext()) + .hasPrevious(participantPage.hasPrevious()) + .build(); + + log.info("Participants searched successfully - eventId: {}, keyword: {}, totalElements: {}", + eventId, keyword, response.getTotalElements()); + + return response; + } + + // === Private Helper Methods === + + /** + * 중복 참여 검증 + * + * Flow: + * 1. Redis Cache 조회 (Cache Hit → 중복 예외) + * 2. Cache Miss → DB 조회 + * 3. DB에 존재 → 중복 예외 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @throws DuplicateParticipationException 중복 참여 시 + */ + private void validateDuplicateParticipation(String eventId, String phoneNumber) { + // Step 1: Cache 조회 + try { + Boolean isDuplicate = redisCacheService.checkDuplicateParticipation( + Long.parseLong(eventId), phoneNumber); + if (Boolean.TRUE.equals(isDuplicate)) { + log.warn("Duplicate participation detected from cache - eventId: {}, phone: {}", + eventId, maskPhoneNumber(phoneNumber)); + throw new DuplicateParticipationException( + String.format("이미 참여한 이벤트입니다. (이벤트: %s, 전화번호: %s)", eventId, maskPhoneNumber(phoneNumber))); + } + } catch (DuplicateParticipationException e) { + throw e; + } catch (Exception e) { + log.warn("Failed to check duplicate from cache - eventId: {}, phone: {}", + eventId, maskPhoneNumber(phoneNumber), e); + } + + // Step 2: DB 조회 + participantRepository.findByEventIdAndPhoneNumber(eventId, phoneNumber) + .ifPresent(participant -> { + log.warn("Duplicate participation detected from DB - eventId: {}, phone: {}, participantId: {}", + eventId, maskPhoneNumber(phoneNumber), participant.getParticipantId()); + throw new DuplicateParticipationException( + String.format("이미 참여한 이벤트입니다. (이벤트: %s, 전화번호: %s)", eventId, maskPhoneNumber(phoneNumber))); + }); + } + + /** + * 응모 번호 생성 + * + * 형식: EVT-{timestamp}-{random} + * 예: EVT-20250123143022-A7B9 + * + * @return 응모 번호 + */ + private String generateApplicationNumber() { + String timestamp = LocalDateTime.now() + .format(DateTimeFormatter.ofPattern("yyyyMMddHHmmss")); + String randomSuffix = String.format("%04X", random.nextInt(0x10000)); + return String.format("EVT-%s-%s", timestamp, randomSuffix); + } + + /** + * 참여 경로 결정 + * + * @param request 참여 요청 + * @return 참여 경로 + */ + private String determineEntryPath(ParticipationRequest request) { + // TODO: 실제로는 요청 헤더나 파라미터에서 참여 경로를 추출 + // 현재는 매장 방문 여부로 간단히 결정 + if (Boolean.TRUE.equals(request.getStoreVisited())) { + return "STORE_VISIT"; + } + return "WEB"; + } + + /** + * Participant 엔티티를 DTO로 변환 + * + * @param participant 참여자 엔티티 + * @return 참여자 DTO (전화번호 마스킹 처리됨) + */ + private ParticipantDto convertToDto(Participant participant) { + return ParticipantDto.builder() + .participantId(String.valueOf(participant.getParticipantId())) + .name(participant.getName()) + .maskedPhoneNumber(participant.getMaskedPhoneNumber()) + .email(participant.getEmail()) + .entryPath(participant.getEntryPath()) + .participatedAt(participant.getParticipatedAt()) + .isWinner(participant.getIsWinner()) + .wonAt(participant.getWonAt()) + .storeVisited(participant.getStoreVisited()) + .bonusEntries(participant.getBonusEntries()) + .build(); + } + + /** + * 전화번호 마스킹 + * + * @param phoneNumber 전화번호 + * @return 마스킹된 전화번호 (예: 010-****-5678) + */ + private String maskPhoneNumber(String phoneNumber) { + if (phoneNumber == null || phoneNumber.length() < 13) { + return phoneNumber; + } + return phoneNumber.substring(0, 4) + "****" + phoneNumber.substring(8); + } + + /** + * 캐시 키 생성 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 + * @param isWinner 당첨 여부 + * @param pageable 페이징 정보 + * @return 캐시 키 + */ + private String buildCacheKey(String eventId, String entryPath, Boolean isWinner, Pageable pageable) { + return String.format("%s:entryPath=%s:isWinner=%s:page=%d:size=%d", + eventId, + entryPath != null ? entryPath : "all", + isWinner != null ? isWinner : "all", + pageable.getPageNumber(), + pageable.getPageSize() + ); + } + + /** + * Kafka 참여자 등록 이벤트 발행 + * + * @param participant 참여자 엔티티 + */ + private void publishParticipantRegisteredEvent(Participant participant) { + try { + ParticipantRegisteredEvent event = ParticipantRegisteredEvent.builder() + .participantId(participant.getParticipantId()) + .eventId(Long.parseLong(participant.getEventId())) + .phoneNumber(participant.getPhoneNumber()) + .entryPath(participant.getEntryPath()) + .registeredAt(participant.getParticipatedAt()) + .build(); + + kafkaProducerService.publishParticipantRegistered(event); + + log.info("Participant registered event published - participantId: {}, eventId: {}", + participant.getParticipantId(), participant.getEventId()); + + } catch (Exception e) { + log.error("Failed to publish participant registered event - participantId: {}, eventId: {}", + participant.getParticipantId(), participant.getEventId(), e); + // 이벤트 발행 실패는 참여 등록 실패로 이어지지 않음 (Best Effort) + } + } +} 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..d4698c6 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/application/service/WinnerDrawService.java @@ -0,0 +1,312 @@ +package com.kt.event.participation.application.service; + +import com.kt.event.participation.application.dto.WinnerDrawRequest; +import com.kt.event.participation.application.dto.WinnerDrawResponse; +import com.kt.event.participation.application.dto.WinnerDto; +import com.kt.event.participation.common.exception.AlreadyDrawnException; +import com.kt.event.participation.common.exception.InsufficientParticipantsException; +import com.kt.event.participation.domain.draw.DrawLog; +import com.kt.event.participation.domain.draw.DrawLogRepository; +import com.kt.event.participation.domain.participant.Participant; +import com.kt.event.participation.domain.participant.ParticipantRepository; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.stream.Collectors; + +/** + * 당첨자 추첨 서비스 + * - 당첨자 추첨 실행 + * - 추첨 이력 관리 + * - 당첨자 조회 + * + * 비즈니스 원칙: + * - 중복 추첨 방지: 이벤트별 1회만 추첨 가능 + * - 추첨 알고리즘: Fisher-Yates Shuffle (공정성 보장) + * - 매장 방문 가산점: 설정에 따라 가중치 부여 + * - 트랜잭션 보장: 당첨자 업데이트와 추첨 로그 저장은 원자성 보장 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@Service +@RequiredArgsConstructor +@Transactional(readOnly = true) +public class WinnerDrawService { + + private final ParticipantRepository participantRepository; + private final DrawLogRepository drawLogRepository; + private final LotteryAlgorithm lotteryAlgorithm; + + @Value("${app.participation.visit-bonus-weight:2.0}") + private double visitBonusWeight; + + /** + * 당첨자 추첨 실행 + * + * Flow: + * 1. 중복 추첨 검증 (이벤트별 1회만 가능) + * 2. 미당첨 참여자 조회 + * 3. 참여자 수 검증 (당첨 인원보다 적으면 예외) + * 4. 추첨 알고리즘 실행 (Fisher-Yates Shuffle) + * 5. 당첨자 업데이트 (트랜잭션) + * 6. 추첨 로그 저장 + * 7. 응답 반환 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 정보 + * @return 추첨 결과 (당첨자 목록, 추첨 로그 ID 등) + * @throws AlreadyDrawnException 이미 추첨이 완료된 경우 + * @throws InsufficientParticipantsException 참여자가 부족한 경우 + */ + @Transactional + public WinnerDrawResponse drawWinners(String eventId, WinnerDrawRequest request) { + log.info("Starting winner draw - eventId: {}, winnerCount: {}, visitBonusApplied: {}", + eventId, request.getWinnerCount(), request.getVisitBonusApplied()); + + // Step 1: 중복 추첨 검증 + validateDuplicateDraw(eventId); + + // Step 2: 미당첨 참여자 조회 + List participants = participantRepository + .findByEventIdAndIsWinnerOrderByParticipatedAtAsc(eventId, false); + + log.info("Eligible participants for draw - eventId: {}, count: {}", eventId, participants.size()); + + // Step 3: 참여자 수 검증 + if (participants.size() < request.getWinnerCount()) { + String errorMsg = String.format( + "참여자가 부족합니다. (필요: %d명, 현재: %d명)", + request.getWinnerCount(), participants.size()); + log.error("Insufficient participants - eventId: {}, {}", eventId, errorMsg); + throw new InsufficientParticipantsException(errorMsg); + } + + // Step 4: 추첨 알고리즘 실행 + List winners; + try { + winners = lotteryAlgorithm.executeLottery( + participants, + request.getWinnerCount(), + request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true, + visitBonusWeight + ); + + log.info("Lottery algorithm executed successfully - eventId: {}, winnersCount: {}", + eventId, winners.size()); + + } catch (Exception e) { + log.error("Failed to execute lottery algorithm - eventId: {}", eventId, e); + saveFailedDrawLog(eventId, request, participants.size(), e.getMessage()); + throw new RuntimeException("추첨 실행 중 오류가 발생했습니다: " + e.getMessage(), e); + } + + // Step 5: 당첨자 업데이트 (트랜잭션) + LocalDateTime drawnAt = LocalDateTime.now(); + winners.forEach(winner -> { + winner.markAsWinner(); + participantRepository.save(winner); + }); + + log.info("Winners marked and saved - eventId: {}, count: {}", eventId, winners.size()); + + // Step 6: 추첨 로그 저장 + DrawLog drawLog = saveSuccessDrawLog(eventId, request, participants.size(), winners.size(), drawnAt); + + // Step 7: 응답 반환 + List winnerDtos = winners.stream() + .map(this::convertToWinnerDto) + .collect(Collectors.toList()); + + WinnerDrawResponse response = WinnerDrawResponse.builder() + .winners(winnerDtos) + .drawLogId(String.valueOf(drawLog.getDrawLogId())) + .message(String.format("추첨이 완료되었습니다. 총 %d명의 당첨자가 선정되었습니다.", winners.size())) + .eventId(eventId) + .totalParticipants(participants.size()) + .winnerCount(winners.size()) + .drawnAt(drawnAt) + .build(); + + log.info("Winner draw completed successfully - eventId: {}, drawLogId: {}, winnersCount: {}", + eventId, drawLog.getDrawLogId(), winners.size()); + + return response; + } + + /** + * 당첨자 목록 조회 + * + * @param eventId 이벤트 ID + * @return 당첨자 목록 (전화번호 마스킹 처리됨) + */ + public List getWinners(String eventId) { + log.info("Fetching winners - eventId: {}", eventId); + + List winners = participantRepository + .findByEventIdAndIsWinnerOrderByWonAtDesc(eventId, true); + + List winnerDtos = winners.stream() + .map(this::convertToWinnerDto) + .collect(Collectors.toList()); + + log.info("Winners fetched successfully - eventId: {}, count: {}", eventId, winnerDtos.size()); + + return winnerDtos; + } + + // === Private Helper Methods === + + /** + * 중복 추첨 검증 + * + * 이벤트별 1회만 추첨 가능 + * 이미 추첨이 완료된 경우 예외 발생 + * + * @param eventId 이벤트 ID + * @throws AlreadyDrawnException 이미 추첨이 완료된 경우 + */ + private void validateDuplicateDraw(String eventId) { + drawLogRepository.findByEventId(eventId) + .ifPresent(drawLog -> { + if (Boolean.TRUE.equals(drawLog.getIsSuccess())) { + String errorMsg = String.format( + "이미 추첨이 완료되었습니다. (추첨일시: %s, 당첨자: %d명)", + drawLog.getDrawnAt(), drawLog.getWinnerCount()); + log.warn("Duplicate draw detected - eventId: {}, drawLogId: {}, drawnAt: {}", + eventId, drawLog.getDrawLogId(), drawLog.getDrawnAt()); + throw new AlreadyDrawnException(errorMsg); + } + }); + } + + /** + * 추첨 성공 로그 저장 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 + * @param totalParticipants 전체 참여자 수 + * @param winnerCount 당첨자 수 + * @param drawnAt 추첨 일시 + * @return 저장된 추첨 로그 + */ + private DrawLog saveSuccessDrawLog( + String eventId, + WinnerDrawRequest request, + int totalParticipants, + int winnerCount, + LocalDateTime drawnAt) { + + DrawLog drawLog = DrawLog.builder() + .eventId(eventId) + .drawMethod("RANDOM") + .algorithm("FISHER_YATES_SHUFFLE") + .visitBonusApplied(request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true) + .winnerCount(winnerCount) + .totalParticipants(totalParticipants) + .drawnAt(drawnAt) + .drawnBy("SYSTEM") // TODO: 실제로는 인증된 사용자 ID 사용 + .isSuccess(true) + .errorMessage(null) + .settings(buildSettingsJson(request)) + .build(); + + DrawLog saved = drawLogRepository.save(drawLog); + + log.info("Draw log saved successfully - drawLogId: {}, eventId: {}, isSuccess: true", + saved.getDrawLogId(), eventId); + + return saved; + } + + /** + * 추첨 실패 로그 저장 + * + * @param eventId 이벤트 ID + * @param request 추첨 요청 + * @param totalParticipants 전체 참여자 수 + * @param errorMessage 에러 메시지 + */ + private void saveFailedDrawLog( + String eventId, + WinnerDrawRequest request, + int totalParticipants, + String errorMessage) { + + try { + DrawLog drawLog = DrawLog.builder() + .eventId(eventId) + .drawMethod("RANDOM") + .algorithm("FISHER_YATES_SHUFFLE") + .visitBonusApplied(request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true) + .winnerCount(0) + .totalParticipants(totalParticipants) + .drawnAt(LocalDateTime.now()) + .drawnBy("SYSTEM") + .isSuccess(false) + .errorMessage(errorMessage) + .settings(buildSettingsJson(request)) + .build(); + + DrawLog saved = drawLogRepository.save(drawLog); + + log.warn("Failed draw log saved - drawLogId: {}, eventId: {}, error: {}", + saved.getDrawLogId(), eventId, errorMessage); + + } catch (Exception e) { + log.error("Failed to save failed draw log - eventId: {}", eventId, e); + } + } + + /** + * 추첨 설정 JSON 생성 + * + * @param request 추첨 요청 + * @return JSON 문자열 + */ + private String buildSettingsJson(WinnerDrawRequest request) { + return String.format( + "{\"winnerCount\":%d,\"visitBonusApplied\":%b,\"visitBonusWeight\":%.1f}", + request.getWinnerCount(), + request.getVisitBonusApplied() != null ? request.getVisitBonusApplied() : true, + visitBonusWeight + ); + } + + /** + * Participant 엔티티를 WinnerDto로 변환 + * + * @param participant 참여자 엔티티 + * @return 당첨자 DTO (전화번호 마스킹 처리됨) + */ + private WinnerDto convertToWinnerDto(Participant participant) { + return WinnerDto.builder() + .participantId(String.valueOf(participant.getParticipantId())) + .name(participant.getName()) + .maskedPhoneNumber(participant.getMaskedPhoneNumber()) + .email(participant.getEmail()) + .applicationNumber(parseApplicationNumber(participant.getApplicationNumber())) + .wonAt(participant.getWonAt()) + .storeVisited(participant.getStoreVisited()) + .bonusApplied(participant.getBonusEntries() > 1) + .build(); + } + + /** + * 응모 번호를 Integer로 파싱 + * 형식: EVT-20250123143022-A7B9 + * + * @param applicationNumber 응모 번호 문자열 + * @return 해시된 정수값 + */ + private Integer parseApplicationNumber(String applicationNumber) { + // 응모 번호 문자열을 해시하여 정수로 변환 + return applicationNumber != null ? applicationNumber.hashCode() : 0; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java new file mode 100644 index 0000000..4fb3756 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/AlreadyDrawnException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이미 추첨 완료 예외 + * 이미 추첨이 완료된 이벤트에 대해 다시 추첨하려고 할 때 발생 + */ +public class AlreadyDrawnException extends ParticipationException { + + private static final String ERROR_CODE = "ALREADY_DRAWN"; + + public AlreadyDrawnException(String message) { + super(ERROR_CODE, message); + } + + public AlreadyDrawnException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java new file mode 100644 index 0000000..56bf3bb --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/DuplicateParticipationException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 중복 참여 예외 + * 사용자가 이미 참여한 이벤트에 다시 참여하려고 할 때 발생 + */ +public class DuplicateParticipationException extends ParticipationException { + + private static final String ERROR_CODE = "DUPLICATE_PARTICIPATION"; + + public DuplicateParticipationException(String message) { + super(ERROR_CODE, message); + } + + public DuplicateParticipationException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java new file mode 100644 index 0000000..cfcf4db --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotActiveException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이벤트 진행 불가 상태 예외 + * 이벤트가 ACTIVE 상태가 아니어서 참여할 수 없을 때 발생 + */ +public class EventNotActiveException extends ParticipationException { + + private static final String ERROR_CODE = "EVENT_NOT_ACTIVE"; + + public EventNotActiveException(String message) { + super(ERROR_CODE, message); + } + + public EventNotActiveException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java new file mode 100644 index 0000000..6381b8d --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/EventNotFoundException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 이벤트 없음 예외 + * 요청한 이벤트 ID가 존재하지 않을 때 발생 + */ +public class EventNotFoundException extends ParticipationException { + + private static final String ERROR_CODE = "EVENT_NOT_FOUND"; + + public EventNotFoundException(String message) { + super(ERROR_CODE, message); + } + + public EventNotFoundException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java new file mode 100644 index 0000000..9a1892a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/GlobalExceptionHandler.java @@ -0,0 +1,110 @@ +package com.kt.event.participation.common.exception; + +import com.kt.event.participation.application.dto.ErrorResponse; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.validation.FieldError; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import java.util.stream.Collectors; + +/** + * 전역 예외 처리 핸들러 + * 모든 컨트롤러에서 발생하는 예외를 처리 + */ +@RestControllerAdvice +public class GlobalExceptionHandler { + + private static final Logger logger = LoggerFactory.getLogger(GlobalExceptionHandler.class); + + /** + * 중복 참여 예외 처리 + */ + @ExceptionHandler(DuplicateParticipationException.class) + public ResponseEntity handleDuplicateParticipation(DuplicateParticipationException ex) { + logger.warn("중복 참여 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /** + * 이벤트 없음 예외 처리 + */ + @ExceptionHandler(EventNotFoundException.class) + public ResponseEntity handleEventNotFound(EventNotFoundException ex) { + logger.warn("이벤트 없음 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.NOT_FOUND).body(errorResponse); + } + + /** + * 이벤트 진행 불가 상태 예외 처리 + */ + @ExceptionHandler(EventNotActiveException.class) + public ResponseEntity handleEventNotActive(EventNotActiveException ex) { + logger.warn("이벤트 비활성 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 이미 추첨 완료 예외 처리 + */ + @ExceptionHandler(AlreadyDrawnException.class) + public ResponseEntity handleAlreadyDrawn(AlreadyDrawnException ex) { + logger.warn("이미 추첨 완료 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.CONFLICT).body(errorResponse); + } + + /** + * 참여자 수 부족 예외 처리 + */ + @ExceptionHandler(InsufficientParticipantsException.class) + public ResponseEntity handleInsufficientParticipants(InsufficientParticipantsException ex) { + logger.warn("참여자 수 부족 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 기본 커스텀 예외 처리 + */ + @ExceptionHandler(ParticipationException.class) + public ResponseEntity handleParticipationException(ParticipationException ex) { + logger.warn("참여 서비스 예외: {}", ex.getMessage()); + ErrorResponse errorResponse = new ErrorResponse(ex.getErrorCode(), ex.getMessage()); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 유효성 검증 실패 예외 처리 + */ + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity handleValidationException(MethodArgumentNotValidException ex) { + String errorMessage = ex.getBindingResult().getFieldErrors().stream() + .map(FieldError::getDefaultMessage) + .collect(Collectors.joining(", ")); + + logger.warn("유효성 검증 실패: {}", errorMessage); + ErrorResponse errorResponse = new ErrorResponse("VALIDATION_ERROR", errorMessage); + return ResponseEntity.status(HttpStatus.BAD_REQUEST).body(errorResponse); + } + + /** + * 처리되지 않은 모든 예외 처리 + */ + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception ex) { + logger.error("서버 내부 오류: ", ex); + ErrorResponse errorResponse = new ErrorResponse( + "INTERNAL_SERVER_ERROR", + "서버 내부 오류가 발생했습니다." + ); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR).body(errorResponse); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java new file mode 100644 index 0000000..1ab1b7a --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/InsufficientParticipantsException.java @@ -0,0 +1,18 @@ +package com.kt.event.participation.common.exception; + +/** + * 참여자 수 부족 예외 + * 추첨을 진행하기에 참여자 수가 부족할 때 발생 + */ +public class InsufficientParticipantsException extends ParticipationException { + + private static final String ERROR_CODE = "INSUFFICIENT_PARTICIPANTS"; + + public InsufficientParticipantsException(String message) { + super(ERROR_CODE, message); + } + + public InsufficientParticipantsException(String message, Throwable cause) { + super(ERROR_CODE, message, cause); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java b/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java new file mode 100644 index 0000000..b48138e --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/common/exception/ParticipationException.java @@ -0,0 +1,24 @@ +package com.kt.event.participation.common.exception; + +/** + * 참여 서비스 기본 예외 클래스 + * 모든 커스텀 예외의 부모 클래스 + */ +public class ParticipationException extends RuntimeException { + + private final String errorCode; + + public ParticipationException(String errorCode, String message) { + super(message); + this.errorCode = errorCode; + } + + public ParticipationException(String errorCode, String message, Throwable cause) { + super(message, cause); + this.errorCode = errorCode; + } + + public String getErrorCode() { + return errorCode; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java b/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java new file mode 100644 index 0000000..664df84 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/common/BaseEntity.java @@ -0,0 +1,37 @@ +package com.kt.event.participation.domain.common; + +import jakarta.persistence.*; +import lombok.Getter; +import org.springframework.data.annotation.CreatedDate; +import org.springframework.data.annotation.LastModifiedDate; +import org.springframework.data.jpa.domain.support.AuditingEntityListener; + +import java.time.LocalDateTime; + +/** + * 공통 Base Entity + * - 생성일시, 수정일시 자동 관리 + * - JPA Auditing 활용 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Getter +@MappedSuperclass +@EntityListeners(AuditingEntityListener.class) +public abstract class BaseEntity { + + /** + * 생성일시 + */ + @CreatedDate + @Column(name = "created_at", nullable = false, updatable = false) + private LocalDateTime createdAt; + + /** + * 수정일시 + */ + @LastModifiedDate + @Column(name = "updated_at", nullable = false) + private LocalDateTime updatedAt; +} 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..cc44733 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLog.java @@ -0,0 +1,134 @@ +package com.kt.event.participation.domain.draw; + +import com.kt.event.participation.domain.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 추첨 로그 엔티티 + * - 추첨 이력 관리 + * - 감사 추적 (Audit Trail) + * - 추첨 알고리즘 및 설정 기록 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Entity +@Table(name = "draw_logs", + indexes = { + @Index(name = "idx_draw_log_event", columnList = "event_id"), + @Index(name = "idx_draw_log_drawn_at", columnList = "drawn_at DESC") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class DrawLog extends BaseEntity { + + /** + * 추첨 로그 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "draw_log_id") + private Long drawLogId; + + /** + * 이벤트 ID + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 추첨 방법 + * - RANDOM (무작위 추첨) + * - FCFS (선착순) + */ + @Column(name = "draw_method", nullable = false, length = 20) + @Builder.Default + private String drawMethod = "RANDOM"; + + /** + * 추첨 알고리즘 + * - FISHER_YATES_SHUFFLE (Fisher-Yates 셔플) + * - CRYPTO_RANDOM (암호학적 난수 기반) + */ + @Column(name = "algorithm", nullable = false, length = 50) + @Builder.Default + private String algorithm = "FISHER_YATES_SHUFFLE"; + + /** + * 매장 방문 가산점 적용 여부 + */ + @Column(name = "visit_bonus_applied", nullable = false) + @Builder.Default + private Boolean visitBonusApplied = false; + + /** + * 당첨 인원 + */ + @Column(name = "winner_count", nullable = false) + private Integer winnerCount; + + /** + * 전체 참여자 수 (추첨 시점 기준) + */ + @Column(name = "total_participants", nullable = false) + private Integer totalParticipants; + + /** + * 추첨 일시 + */ + @Column(name = "drawn_at", nullable = false) + private LocalDateTime drawnAt; + + /** + * 추첨 실행자 (사장님 ID) + */ + @Column(name = "drawn_by", length = 50) + private String drawnBy; + + /** + * 추첨 성공 여부 + */ + @Column(name = "is_success", nullable = false) + @Builder.Default + private Boolean isSuccess = true; + + /** + * 에러 메시지 (실패 시) + */ + @Column(name = "error_message", columnDefinition = "TEXT") + private String errorMessage; + + /** + * 추첨 설정 (JSON) + * - 추가 설정 정보 저장 + */ + @Column(name = "settings", columnDefinition = "TEXT") + private String settings; + + // === Business Methods === + + /** + * 추첨 실패 처리 + * @param errorMessage 에러 메시지 + */ + public void markAsFailed(String errorMessage) { + this.isSuccess = false; + this.errorMessage = errorMessage; + } + + /** + * 추첨 성공 처리 + * @param winnerCount 당첨 인원 + */ + public void markAsSuccess(int winnerCount) { + this.isSuccess = true; + this.winnerCount = winnerCount; + this.drawnAt = LocalDateTime.now(); + } +} 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..d3a66e9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/draw/DrawLogRepository.java @@ -0,0 +1,46 @@ +package com.kt.event.participation.domain.draw; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 추첨 로그 Repository + * - 추첨 이력 CRUD + * - 중복 추첨 검증 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Repository +public interface DrawLogRepository extends JpaRepository { + + /** + * 이벤트별 추첨 로그 조회 + * - 중복 추첨 방지를 위해 사용 + * + * @param eventId 이벤트 ID + * @return 추첨 로그 (존재하지 않으면 empty) + */ + Optional findByEventId(String eventId); + + /** + * 이벤트별 추첨 이력 전체 조회 + * - 추첨 일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @return 추첨 로그 목록 + */ + List findByEventIdOrderByDrawnAtDesc(String eventId); + + /** + * 성공한 추첨 이력 조회 + * + * @param eventId 이벤트 ID + * @param isSuccess 성공 여부 (true) + * @return 추첨 로그 목록 + */ + List findByEventIdAndIsSuccessOrderByDrawnAtDesc(String eventId, Boolean isSuccess); +} 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..fa328e8 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/Participant.java @@ -0,0 +1,161 @@ +package com.kt.event.participation.domain.participant; + +import com.kt.event.participation.domain.common.BaseEntity; +import jakarta.persistence.*; +import lombok.*; + +import java.time.LocalDateTime; + +/** + * 이벤트 참여자 엔티티 + * - 이벤트 참여 정보 관리 + * - 중복 참여 방지 (eventId + phoneNumber 복합 유니크) + * - 당첨자 정보 포함 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Entity +@Table(name = "participants", + uniqueConstraints = { + @UniqueConstraint(name = "uk_participant_event_phone", columnNames = {"event_id", "phone_number"}) + }, + indexes = { + @Index(name = "idx_participant_event_filters", columnList = "event_id, entry_path, is_winner, participated_at DESC"), + @Index(name = "idx_participant_phone", columnList = "phone_number"), + @Index(name = "idx_participant_event_winner", columnList = "event_id, is_winner") + } +) +@Getter +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@AllArgsConstructor +@Builder +public class Participant extends BaseEntity { + + /** + * 참여자 ID (Primary Key) + */ + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + @Column(name = "participant_id") + private Long participantId; + + /** + * 이벤트 ID (외래키는 다른 서비스이므로 논리적 연관만) + */ + @Column(name = "event_id", nullable = false, length = 50) + private String eventId; + + /** + * 참여자 이름 + */ + @Column(name = "name", nullable = false, length = 100) + private String name; + + /** + * 전화번호 (중복 참여 방지 키) + */ + @Column(name = "phone_number", nullable = false, length = 20) + private String phoneNumber; + + /** + * 이메일 + */ + @Column(name = "email", length = 255) + private String email; + + /** + * 참여 경로 + * - SNS, STORE_VISIT, BLOG, TV, etc. + */ + @Column(name = "entry_path", nullable = false, length = 50) + private String entryPath; + + /** + * 응모 번호 + * - 형식: EVT-{timestamp}-{random} + */ + @Column(name = "application_number", nullable = false, unique = true, length = 50) + private String applicationNumber; + + /** + * 참여 일시 + */ + @Column(name = "participated_at", nullable = false) + private LocalDateTime participatedAt; + + /** + * 매장 방문 여부 + */ + @Column(name = "store_visited", nullable = false) + @Builder.Default + private Boolean storeVisited = false; + + /** + * 마케팅 수신 동의 + */ + @Column(name = "agree_marketing", nullable = false) + @Builder.Default + private Boolean agreeMarketing = false; + + /** + * 개인정보 수집/이용 동의 + */ + @Column(name = "agree_privacy", nullable = false) + @Builder.Default + private Boolean agreePrivacy = true; + + /** + * 당첨 여부 + */ + @Column(name = "is_winner", nullable = false) + @Builder.Default + private Boolean isWinner = false; + + /** + * 당첨 일시 + */ + @Column(name = "won_at") + private LocalDateTime wonAt; + + /** + * 보너스 응모권 수 + * - 매장 방문 시 추가 응모권 부여 + */ + @Column(name = "bonus_entries", nullable = false) + @Builder.Default + private Integer bonusEntries = 1; + + // === Business Methods === + + /** + * 당첨자로 변경 + */ + public void markAsWinner() { + this.isWinner = true; + this.wonAt = LocalDateTime.now(); + } + + /** + * 매장 방문 여부에 따라 보너스 응모권 부여 + * @param visitBonusWeight 매장 방문 가중치 (기본 2.0) + */ + public void applyVisitBonus(double visitBonusWeight) { + if (this.storeVisited != null && this.storeVisited) { + this.bonusEntries = (int) visitBonusWeight; + } else { + this.bonusEntries = 1; + } + } + + /** + * 전화번호 마스킹 + * @return 마스킹된 전화번호 (예: 010-****-5678) + */ + public String getMaskedPhoneNumber() { + if (phoneNumber == null || phoneNumber.length() < 13) { + return phoneNumber; + } + return phoneNumber.substring(0, 4) + "****" + phoneNumber.substring(8); + } +} 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..17e085c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/domain/participant/ParticipantRepository.java @@ -0,0 +1,132 @@ +package com.kt.event.participation.domain.participant; + +import org.springframework.data.domain.Page; +import org.springframework.data.domain.Pageable; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; +import org.springframework.stereotype.Repository; + +import java.util.List; +import java.util.Optional; + +/** + * 참여자 Repository + * - 참여자 CRUD 및 조회 기능 + * - 중복 참여 검증 + * - 당첨자 관리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Repository +public interface ParticipantRepository extends JpaRepository { + + /** + * 중복 참여 검증 + * - 이벤트ID + 전화번호로 중복 참여 확인 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 참여자 정보 (존재하지 않으면 empty) + */ + Optional findByEventIdAndPhoneNumber(String eventId, String phoneNumber); + + /** + * 이벤트별 참여자 목록 조회 (페이징) + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + Page findByEventIdOrderByParticipatedAtDesc(String eventId, Pageable pageable); + + /** + * 이벤트별 참여자 목록 조회 (필터링 + 페이징) + * - 참여 경로 필터 + * - 당첨 여부 필터 + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param entryPath 참여 경로 (nullable) + * @param isWinner 당첨 여부 (nullable) + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + @Query("SELECT p FROM Participant p WHERE p.eventId = :eventId " + + "AND (:entryPath IS NULL OR p.entryPath = :entryPath) " + + "AND (:isWinner IS NULL OR p.isWinner = :isWinner) " + + "ORDER BY p.participatedAt DESC") + Page findParticipants( + @Param("eventId") String eventId, + @Param("entryPath") String entryPath, + @Param("isWinner") Boolean isWinner, + Pageable pageable + ); + + /** + * 이벤트별 참여자 검색 (이름 또는 전화번호) + * - LIKE 검색 지원 + * - 참여일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param searchKeyword 검색 키워드 + * @param pageable 페이징 정보 + * @return 참여자 목록 (페이징) + */ + @Query("SELECT p FROM Participant p WHERE p.eventId = :eventId " + + "AND (p.name LIKE %:searchKeyword% OR p.phoneNumber LIKE %:searchKeyword%) " + + "ORDER BY p.participatedAt DESC") + Page searchParticipants( + @Param("eventId") String eventId, + @Param("searchKeyword") String searchKeyword, + Pageable pageable + ); + + /** + * 이벤트별 미당첨 참여자 전체 조회 + * - 추첨 알고리즘에서 사용 + * - 참여일시 오름차순 정렬 (공정성) + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (false) + * @return 미당첨 참여자 전체 목록 + */ + List findByEventIdAndIsWinnerOrderByParticipatedAtAsc(String eventId, Boolean isWinner); + + /** + * 이벤트별 당첨자 목록 조회 + * - 당첨 일시 내림차순 정렬 + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (true) + * @return 당첨자 목록 + */ + List findByEventIdAndIsWinnerOrderByWonAtDesc(String eventId, Boolean isWinner); + + /** + * 이벤트별 전체 참여자 수 조회 + * + * @param eventId 이벤트 ID + * @return 전체 참여자 수 + */ + Long countByEventId(String eventId); + + /** + * 이벤트별 당첨자 수 조회 + * + * @param eventId 이벤트 ID + * @param isWinner 당첨 여부 (true) + * @return 당첨자 수 + */ + Long countByEventIdAndIsWinner(String eventId, Boolean isWinner); + + /** + * 응모 번호로 참여자 조회 + * + * @param applicationNumber 응모 번호 + * @return 참여자 정보 (존재하지 않으면 empty) + */ + Optional findByApplicationNumber(String applicationNumber); +} 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..b69bee6 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/KafkaProducerService.java @@ -0,0 +1,59 @@ +package com.kt.event.participation.infrastructure.kafka; + +import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.kafka.core.KafkaTemplate; +import org.springframework.kafka.support.SendResult; +import org.springframework.stereotype.Service; + +import java.util.concurrent.CompletableFuture; + +/** + * Kafka Producer 서비스 + * + * 참가자 등록 이벤트를 Kafka 토픽에 발행 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class KafkaProducerService { + + private final KafkaTemplate kafkaTemplate; + + @Value("${spring.kafka.topics.participant-registered}") + private String participantTopic; + + /** + * 참가자 등록 이벤트 발행 + * + * @param event 참가자 등록 이벤트 + */ + public void publishParticipantRegistered(ParticipantRegisteredEvent event) { + try { + log.info("Publishing participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId()); + + // Kafka 메시지 전송 (비동기) + CompletableFuture> future = + kafkaTemplate.send(participantTopic, String.valueOf(event.getEventId()), event); + + // 전송 결과 처리 + future.whenComplete((result, ex) -> { + if (ex == null) { + log.info("Participant registered event published successfully: eventId={}, participantId={}, offset={}", + event.getEventId(), event.getParticipantId(), result.getRecordMetadata().offset()); + } else { + log.error("Failed to publish participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId(), ex); + } + }); + + } catch (Exception e) { + log.error("Error publishing participant registered event: eventId={}, participantId={}", + event.getEventId(), event.getParticipantId(), e); + // 이벤트 발행 실패는 참가자 등록 실패로 이어지지 않음 (Best Effort) + } + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java new file mode 100644 index 0000000..cba5ede --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/config/KafkaConfig.java @@ -0,0 +1,22 @@ +package com.kt.event.participation.infrastructure.kafka.config; + +import org.springframework.context.annotation.Configuration; +import org.springframework.kafka.annotation.EnableKafka; + +/** + * Kafka 설정 + * + * Spring Boot의 Auto Configuration을 사용하므로 별도 Bean 설정 불필요 + * application.yml에서 설정 관리: + * - spring.kafka.bootstrap-servers + * - spring.kafka.producer.key-serializer + * - spring.kafka.producer.value-serializer + * - spring.kafka.producer.acks + * - spring.kafka.producer.retries + */ +@EnableKafka +@Configuration +public class KafkaConfig { + // Spring Boot Auto Configuration 사용 + // 필요 시 KafkaTemplate Bean 커스터마이징 가능 +} 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..e340d76 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/kafka/event/ParticipantRegisteredEvent.java @@ -0,0 +1,46 @@ +package com.kt.event.participation.infrastructure.kafka.event; + +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +import java.time.LocalDateTime; + +/** + * Kafka Event: 참가자 등록 이벤트 + * + * 참가자가 이벤트에 등록되었을 때 발행되는 이벤트 + * 이벤트 알림 서비스 등에서 소비하여 SMS/카카오톡 발송 등의 작업 수행 + */ +@Data +@Builder +@NoArgsConstructor +@AllArgsConstructor +public class ParticipantRegisteredEvent { + + /** + * 참가자 ID (PK) + */ + private Long participantId; + + /** + * 이벤트 ID + */ + private Long eventId; + + /** + * 참가자 전화번호 + */ + private String phoneNumber; + + /** + * 유입 경로 (QR, LINK, DIRECT 등) + */ + private String entryPath; + + /** + * 등록 일시 + */ + private LocalDateTime registeredAt; +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java new file mode 100644 index 0000000..026a5a9 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/RedisCacheService.java @@ -0,0 +1,166 @@ +package com.kt.event.participation.infrastructure.redis; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.stereotype.Service; + +import java.util.Optional; +import java.util.concurrent.TimeUnit; + +/** + * Redis 캐시 서비스 + * + * 중복 참여 체크 및 참가자 목록 캐싱 기능 제공 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class RedisCacheService { + + private final RedisTemplate redisTemplate; + + @Value("${app.cache.duplicate-check-ttl:604800}") // 기본 7일 + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") // 기본 10분 + private long participantListTtl; + + private static final String DUPLICATE_CHECK_PREFIX = "duplicate:"; + private static final String PARTICIPANT_LIST_PREFIX = "participants:"; + + /** + * 중복 참여 여부 확인 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 중복 참여 여부 (true: 중복, false: 중복 아님) + */ + public Boolean checkDuplicateParticipation(Long eventId, String phoneNumber) { + try { + String key = buildDuplicateCheckKey(eventId, phoneNumber); + Boolean exists = redisTemplate.hasKey(key); + + log.debug("Duplicate participation check: eventId={}, phoneNumber={}, isDuplicate={}", + eventId, phoneNumber, exists); + + return Boolean.TRUE.equals(exists); + + } catch (Exception e) { + log.error("Error checking duplicate participation: eventId={}, phoneNumber={}", + eventId, phoneNumber, e); + // Redis 장애 시 중복 체크 실패로 처리하지 않음 (DB에서 확인) + return false; + } + } + + /** + * 중복 참여 정보 캐싱 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @param ttl TTL (초 단위, null일 경우 기본값 사용) + */ + public void cacheDuplicateCheck(Long eventId, String phoneNumber, Long ttl) { + try { + String key = buildDuplicateCheckKey(eventId, phoneNumber); + long effectiveTtl = (ttl != null) ? ttl : duplicateCheckTtl; + + redisTemplate.opsForValue().set(key, "1", effectiveTtl, TimeUnit.SECONDS); + + log.debug("Cached duplicate check: eventId={}, phoneNumber={}, ttl={}", + eventId, phoneNumber, effectiveTtl); + + } catch (Exception e) { + log.error("Error caching duplicate check: eventId={}, phoneNumber={}", + eventId, phoneNumber, e); + // 캐싱 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 참가자 목록 캐싱 + * + * @param key 캐시 키 + * @param data 캐싱할 데이터 + * @param ttl TTL (초 단위, null일 경우 기본값 사용) + */ + public void cacheParticipantList(String key, Object data, Long ttl) { + try { + String cacheKey = buildParticipantListKey(key); + long effectiveTtl = (ttl != null) ? ttl : participantListTtl; + + redisTemplate.opsForValue().set(cacheKey, data, effectiveTtl, TimeUnit.SECONDS); + + log.debug("Cached participant list: key={}, ttl={}", cacheKey, effectiveTtl); + + } catch (Exception e) { + log.error("Error caching participant list: key={}", key, e); + // 캐싱 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 참가자 목록 조회 + * + * @param key 캐시 키 + * @return 캐싱된 데이터 (Optional) + */ + public Optional getParticipantList(String key) { + try { + String cacheKey = buildParticipantListKey(key); + Object data = redisTemplate.opsForValue().get(cacheKey); + + log.debug("Retrieved participant list from cache: key={}, found={}", + cacheKey, data != null); + + return Optional.ofNullable(data); + + } catch (Exception e) { + log.error("Error retrieving participant list from cache: key={}", key, e); + return Optional.empty(); + } + } + + /** + * 참가자 목록 캐시 무효화 + * + * @param eventId 이벤트 ID + */ + public void invalidateParticipantListCache(Long eventId) { + try { + // 이벤트 ID로 시작하는 모든 참가자 목록 캐시 삭제 + String pattern = buildParticipantListKey(eventId + "*"); + redisTemplate.keys(pattern).forEach(key -> { + redisTemplate.delete(key); + log.debug("Invalidated participant list cache: key={}", key); + }); + + } catch (Exception e) { + log.error("Error invalidating participant list cache: eventId={}", eventId, e); + // 캐시 무효화 실패는 중요하지 않음 (Best Effort) + } + } + + /** + * 중복 체크 캐시 키 생성 + * + * @param eventId 이벤트 ID + * @param phoneNumber 전화번호 + * @return 캐시 키 + */ + private String buildDuplicateCheckKey(Long eventId, String phoneNumber) { + return DUPLICATE_CHECK_PREFIX + eventId + ":" + phoneNumber; + } + + /** + * 참가자 목록 캐시 키 생성 + * + * @param key 키 + * @return 캐시 키 + */ + private String buildParticipantListKey(String key) { + return PARTICIPANT_LIST_PREFIX + key; + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java new file mode 100644 index 0000000..3e109c0 --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/infrastructure/redis/config/RedisConfig.java @@ -0,0 +1,104 @@ +package com.kt.event.participation.infrastructure.redis.config; + +import com.fasterxml.jackson.annotation.JsonTypeInfo; +import com.fasterxml.jackson.databind.ObjectMapper; +import com.fasterxml.jackson.databind.SerializationFeature; +import com.fasterxml.jackson.datatype.jsr310.JavaTimeModule; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.cache.CacheManager; +import org.springframework.cache.annotation.EnableCaching; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.data.redis.cache.RedisCacheConfiguration; +import org.springframework.data.redis.cache.RedisCacheManager; +import org.springframework.data.redis.connection.RedisConnectionFactory; +import org.springframework.data.redis.core.RedisTemplate; +import org.springframework.data.redis.serializer.GenericJackson2JsonRedisSerializer; +import org.springframework.data.redis.serializer.RedisSerializationContext; +import org.springframework.data.redis.serializer.StringRedisSerializer; + +import java.time.Duration; + +/** + * Redis 설정 + * + * Redis 캐시 및 RedisTemplate 설정 + */ +@Configuration +@EnableCaching +public class RedisConfig { + + @Value("${app.cache.duplicate-check-ttl:604800}") // 기본 7일 + private long duplicateCheckTtl; + + @Value("${app.cache.participant-list-ttl:600}") // 기본 10분 + private long participantListTtl; + + /** + * RedisTemplate 설정 + * + * @param connectionFactory Redis 연결 팩토리 + * @return RedisTemplate + */ + @Bean + public RedisTemplate redisTemplate(RedisConnectionFactory connectionFactory) { + RedisTemplate template = new RedisTemplate<>(); + template.setConnectionFactory(connectionFactory); + + // Key Serializer: String + StringRedisSerializer stringSerializer = new StringRedisSerializer(); + template.setKeySerializer(stringSerializer); + template.setHashKeySerializer(stringSerializer); + + // Value Serializer: JSON + GenericJackson2JsonRedisSerializer jsonSerializer = createJsonSerializer(); + template.setValueSerializer(jsonSerializer); + template.setHashValueSerializer(jsonSerializer); + + template.afterPropertiesSet(); + return template; + } + + /** + * CacheManager 설정 + * + * @param connectionFactory Redis 연결 팩토리 + * @return CacheManager + */ + @Bean + public CacheManager cacheManager(RedisConnectionFactory connectionFactory) { + // 기본 캐시 설정 (participant-list 용) + RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig() + .entryTtl(Duration.ofSeconds(participantListTtl)) + .serializeKeysWith(RedisSerializationContext.SerializationPair.fromSerializer( + new StringRedisSerializer())) + .serializeValuesWith(RedisSerializationContext.SerializationPair.fromSerializer( + createJsonSerializer())); + + return RedisCacheManager.builder(connectionFactory) + .cacheDefaults(defaultConfig) + .build(); + } + + /** + * JSON Serializer 생성 + * + * @return GenericJackson2JsonRedisSerializer + */ + private GenericJackson2JsonRedisSerializer createJsonSerializer() { + ObjectMapper objectMapper = new ObjectMapper(); + + // Java 8 날짜/시간 타입 지원 + objectMapper.registerModule(new JavaTimeModule()); + objectMapper.disable(SerializationFeature.WRITE_DATES_AS_TIMESTAMPS); + + // 타입 정보 포함 (역직렬화 시 타입 안전성 보장) + objectMapper.activateDefaultTyping( + objectMapper.getPolymorphicTypeValidator(), + ObjectMapper.DefaultTyping.NON_FINAL, + JsonTypeInfo.As.PROPERTY + ); + + return new GenericJackson2JsonRedisSerializer(objectMapper); + } +} 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..5a9b95e --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/ParticipationController.java @@ -0,0 +1,181 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.participation.application.dto.ParticipantListResponse; +import com.kt.event.participation.application.dto.ParticipationRequest; +import com.kt.event.participation.application.dto.ParticipationResponse; +import com.kt.event.participation.application.service.ParticipationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.data.domain.PageRequest; +import org.springframework.data.domain.Pageable; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +/** + * 이벤트 참여 컨트롤러 + * - 이벤트 참여 등록 + * - 참여자 목록 조회 (필터링 + 페이징) + * - 참여자 검색 + * + * RESTful API 설계 원칙: + * - Base Path: /events/{eventId} + * - HTTP Method 사용: POST (등록), GET (조회) + * - HTTP Status Code: 201 (생성), 200 (조회), 400 (잘못된 요청), 404 (찾을 수 없음) + * - Request Validation: @Valid 사용하여 요청 검증 + * - Error Handling: GlobalExceptionHandler에서 처리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/events/{eventId}") +@RequiredArgsConstructor +public class ParticipationController { + + private final ParticipationService participationService; + + /** + * 이벤트 참여 + * + *

고객이 이벤트에 참여합니다.

+ * + *

비즈니스 로직:

+ *
    + *
  • 중복 참여 검증 (전화번호 기반)
  • + *
  • 이벤트 진행 상태 검증
  • + *
  • 응모 번호 생성 (EVT-{timestamp}-{random})
  • + *
  • Kafka 이벤트 발행 (ParticipantRegistered)
  • + *
+ * + *

Response:

+ *
    + *
  • 201 Created: 참여 성공
  • + *
  • 400 Bad Request: 유효하지 않은 요청, 중복 참여
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
  • 409 Conflict: 이벤트 진행 불가 상태
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param request 참여 요청 정보 (이름, 전화번호, 이메일, 개인정보 동의 등) + * @return 참여 응답 (참여자 ID, 응모 번호, 참여 일시 등) + */ + @PostMapping("/participate") + public ResponseEntity participateEvent( + @PathVariable("eventId") String eventId, + @Valid @RequestBody ParticipationRequest request) { + + log.info("POST /events/{}/participate - name: {}, storeVisited: {}", + eventId, request.getName(), request.getStoreVisited()); + + ParticipationResponse response = participationService.registerParticipant(eventId, request); + + log.info("Event participation successful - eventId: {}, participantId: {}", + eventId, response.getParticipantId()); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } + + /** + * 참여자 목록 조회 + * + *

이벤트의 참여자 목록을 조회합니다.

+ * + *

기능:

+ *
    + *
  • 페이징 지원 (기본: page=0, size=20)
  • + *
  • 참여일시 기준 정렬 (최신순)
  • + *
  • 매장 방문 여부 필터링 (선택)
  • + *
  • 당첨 여부 필터링 (선택)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 조회 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (기본값: 20, 최대: 100) + * @param storeVisited 매장 방문 여부 필터 (nullable) + * @param isWinner 당첨 여부 필터 (nullable) + * @return 참여자 목록 (페이징 정보 포함) + */ + @GetMapping("/participants") + public ResponseEntity getParticipants( + @PathVariable("eventId") String eventId, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size, + @RequestParam(value = "storeVisited", required = false) Boolean storeVisited, + @RequestParam(value = "isWinner", required = false) Boolean isWinner) { + + log.info("GET /events/{}/participants - page: {}, size: {}, storeVisited: {}, isWinner: {}", + eventId, page, size, storeVisited, isWinner); + + // 페이지 크기 제한 (최대 100) + int validatedSize = Math.min(size, 100); + + Pageable pageable = PageRequest.of(page, validatedSize); + + // 참여 경로는 API 스펙에 없으므로 null로 전달 + ParticipantListResponse response = participationService.getParticipantList( + eventId, null, isWinner, pageable); + + log.info("Participant list fetched successfully - eventId: {}, totalElements: {}", + eventId, response.getTotalElements()); + + return ResponseEntity.ok(response); + } + + /** + * 참여자 검색 + * + *

이벤트의 참여자를 이름 또는 전화번호로 검색합니다.

+ * + *

기능:

+ *
    + *
  • 이름 또는 전화번호로 검색 (부분 일치)
  • + *
  • 페이징 지원 (기본: page=0, size=20)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 검색 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param keyword 검색 키워드 (이름 또는 전화번호) + * @param page 페이지 번호 (0부터 시작, 기본값: 0) + * @param size 페이지 크기 (기본값: 20, 최대: 100) + * @return 검색된 참여자 목록 (페이징 정보 포함) + */ + @GetMapping("/participants/search") + public ResponseEntity searchParticipants( + @PathVariable("eventId") String eventId, + @RequestParam("keyword") String keyword, + @RequestParam(value = "page", defaultValue = "0") int page, + @RequestParam(value = "size", defaultValue = "20") int size) { + + log.info("GET /events/{}/participants/search - keyword: {}, page: {}, size: {}", + eventId, keyword, page, size); + + // 페이지 크기 제한 (최대 100) + int validatedSize = Math.min(size, 100); + + Pageable pageable = PageRequest.of(page, validatedSize); + + ParticipantListResponse response = participationService.searchParticipants( + eventId, keyword, pageable); + + log.info("Participants searched successfully - eventId: {}, keyword: {}, totalElements: {}", + eventId, keyword, response.getTotalElements()); + + return ResponseEntity.ok(response); + } +} diff --git a/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java new file mode 100644 index 0000000..6de791c --- /dev/null +++ b/participation-service/src/main/java/com/kt/event/participation/presentation/controller/WinnerController.java @@ -0,0 +1,114 @@ +package com.kt.event.participation.presentation.controller; + +import com.kt.event.participation.application.dto.WinnerDrawRequest; +import com.kt.event.participation.application.dto.WinnerDrawResponse; +import com.kt.event.participation.application.dto.WinnerDto; +import com.kt.event.participation.application.service.WinnerDrawService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.util.List; + +/** + * 당첨자 추첨 및 관리 컨트롤러 + * - 당첨자 추첨 실행 + * - 당첨자 목록 조회 + * + * RESTful API 설계 원칙: + * - Base Path: /events/{eventId} + * - HTTP Method 사용: POST (추첨), GET (조회) + * - HTTP Status Code: 200 (성공), 400 (잘못된 요청), 404 (찾을 수 없음), 409 (중복 추첨) + * - Request Validation: @Valid 사용하여 요청 검증 + * - Error Handling: GlobalExceptionHandler에서 처리 + * + * @author Digital Garage Team + * @since 2025-10-23 + */ +@Slf4j +@RestController +@RequestMapping("/events/{eventId}") +@RequiredArgsConstructor +public class WinnerController { + + private final WinnerDrawService winnerDrawService; + + /** + * 당첨자 추첨 + * + *

이벤트 당첨자를 추첨합니다.

+ * + *

비즈니스 로직:

+ *
    + *
  • 중복 추첨 검증 (이벤트별 1회만 가능)
  • + *
  • 참여자 수 검증 (당첨자 수보다 많아야 함)
  • + *
  • Fisher-Yates Shuffle 알고리즘 사용
  • + *
  • 매장 방문 보너스 가중치 적용 (선택)
  • + *
  • 추첨 로그 저장 (감사 추적)
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 추첨 성공
  • + *
  • 400 Bad Request: 유효하지 않은 요청 (당첨자 수가 참여자 수보다 많음)
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음
  • + *
  • 409 Conflict: 이미 추첨 완료
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @param request 추첨 요청 정보 (당첨자 수, 매장 방문 보너스 적용 여부) + * @return 추첨 결과 (당첨자 목록, 추첨 일시, 추첨 로그 ID 등) + */ + @PostMapping("/draw-winners") + public ResponseEntity drawWinners( + @PathVariable("eventId") String eventId, + @Valid @RequestBody WinnerDrawRequest request) { + + log.info("POST /events/{}/draw-winners - winnerCount: {}, visitBonusApplied: {}", + eventId, request.getWinnerCount(), request.getVisitBonusApplied()); + + WinnerDrawResponse response = winnerDrawService.drawWinners(eventId, request); + + log.info("Winners drawn successfully - eventId: {}, drawLogId: {}, winnerCount: {}", + eventId, response.getDrawLogId(), response.getWinnerCount()); + + return ResponseEntity.ok(response); + } + + /** + * 당첨자 목록 조회 + * + *

이벤트의 당첨자 목록을 조회합니다.

+ * + *

기능:

+ *
    + *
  • 당첨 순위별 정렬 (당첨 일시 내림차순)
  • + *
  • 전화번호 마스킹 처리 (개인정보 보호)
  • + *
  • 응모 번호 포함
  • + *
+ * + *

Response:

+ *
    + *
  • 200 OK: 조회 성공
  • + *
  • 404 Not Found: 이벤트를 찾을 수 없음 또는 당첨자가 없음
  • + *
+ * + * @param eventId 이벤트 ID (Path Variable) + * @return 당첨자 목록 (당첨 순위, 이름, 마스킹된 전화번호, 당첨 일시 등) + */ + @GetMapping("/winners") + public ResponseEntity> getWinners( + @PathVariable("eventId") String eventId) { + + log.info("GET /events/{}/winners", eventId); + + List winners = winnerDrawService.getWinners(eventId); + + log.info("Winners fetched successfully - eventId: {}, count: {}", + eventId, winners.size()); + + return ResponseEntity.ok(winners); + } +} diff --git a/participation-service/src/main/resources/application.yml b/participation-service/src/main/resources/application.yml new file mode 100644 index 0000000..7fc673d --- /dev/null +++ b/participation-service/src/main/resources/application.yml @@ -0,0 +1,88 @@ +spring: + application: + name: participation-service + + datasource: + url: jdbc:mysql://${DB_HOST:localhost}:${DB_PORT:3306}/${DB_NAME:participation_db}?useSSL=false&serverTimezone=UTC&characterEncoding=UTF-8 + username: ${DB_USER:root} + password: ${DB_PASSWORD:password} + driver-class-name: com.mysql.cj.jdbc.Driver + hikari: + maximum-pool-size: ${DB_POOL_SIZE:10} + minimum-idle: ${DB_MIN_IDLE:5} + connection-timeout: ${DB_CONN_TIMEOUT:30000} + idle-timeout: ${DB_IDLE_TIMEOUT:600000} + max-lifetime: ${DB_MAX_LIFETIME:1800000} + + jpa: + hibernate: + ddl-auto: ${JPA_DDL_AUTO:validate} + show-sql: ${JPA_SHOW_SQL:false} + properties: + hibernate: + format_sql: true + use_sql_comments: true + dialect: org.hibernate.dialect.MySQL8Dialect + + # Redis Configuration + data: + redis: + host: ${REDIS_HOST:localhost} + port: ${REDIS_PORT:6379} + password: ${REDIS_PASSWORD:} + timeout: ${REDIS_TIMEOUT:3000} + lettuce: + pool: + max-active: ${REDIS_POOL_MAX_ACTIVE:8} + max-idle: ${REDIS_POOL_MAX_IDLE:8} + min-idle: ${REDIS_POOL_MIN_IDLE:2} + max-wait: ${REDIS_POOL_MAX_WAIT:3000} + + # Kafka Configuration + kafka: + bootstrap-servers: ${KAFKA_BOOTSTRAP_SERVERS:localhost:9092} + producer: + key-serializer: org.apache.kafka.common.serialization.StringSerializer + value-serializer: org.springframework.kafka.support.serializer.JsonSerializer + acks: ${KAFKA_PRODUCER_ACKS:all} + retries: ${KAFKA_PRODUCER_RETRIES:3} + properties: + max.in.flight.requests.per.connection: 1 + enable.idempotence: true + # Topic Names + topics: + participant-registered: participant-events + +server: + port: ${SERVER_PORT:8084} + servlet: + context-path: / + error: + include-message: always + include-binding-errors: always + +# Logging +logging: + level: + root: ${LOG_LEVEL_ROOT:INFO} + com.kt.event.participation: ${LOG_LEVEL_APP:DEBUG} + org.springframework.data.redis: ${LOG_LEVEL_REDIS:INFO} + org.springframework.kafka: ${LOG_LEVEL_KAFKA:INFO} + pattern: + console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n" + file: + name: ${LOG_FILE_PATH:./logs}/participation-service.log + max-size: ${LOG_FILE_MAX_SIZE:10MB} + max-history: ${LOG_FILE_MAX_HISTORY:30} + +# Application-specific Configuration +app: + cache: + duplicate-check-ttl: ${CACHE_DUPLICATE_TTL:604800} # 7 days in seconds + participant-list-ttl: ${CACHE_PARTICIPANT_TTL:600} # 10 minutes in seconds + lottery: + algorithm: FISHER_YATES_SHUFFLE + visit-bonus-weight: ${LOTTERY_VISIT_BONUS:2.0} # 매장 방문 고객 가중치 + security: + phone-mask-pattern: "***-****-***" # 전화번호 마스킹 패턴