mirror of
https://github.com/ktds-dg0501/kt-event-marketing.git
synced 2026-06-12 23:19:10 +00:00
Participation Service 백엔드 개발 완료
- 이벤트 참여 API 구현 - 참여자 목록/상세 조회 API 구현 - 당첨자 추첨 및 조회 API 구현 - PostgreSQL 데이터베이스 연동 - Kafka 이벤트 발행 연동 - 로깅 설정 및 실행 프로파일 추가 - .gradle 폴더 Git 추적 제거 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
@@ -1,7 +1,50 @@
|
||||
plugins {
|
||||
id 'java'
|
||||
id 'org.springframework.boot'
|
||||
id 'io.spring.dependency-management'
|
||||
}
|
||||
|
||||
group = 'com.kt.event'
|
||||
version = '1.0.0'
|
||||
sourceCompatibility = '21'
|
||||
|
||||
configurations {
|
||||
compileOnly {
|
||||
extendsFrom annotationProcessor
|
||||
}
|
||||
}
|
||||
|
||||
repositories {
|
||||
mavenCentral()
|
||||
}
|
||||
|
||||
dependencies {
|
||||
// Kafka for event publishing
|
||||
// Common 모듈
|
||||
implementation project(':common')
|
||||
|
||||
// Spring Boot Starters
|
||||
implementation 'org.springframework.boot:spring-boot-starter-web'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
|
||||
implementation 'org.springframework.boot:spring-boot-starter-validation'
|
||||
implementation 'org.springframework.kafka:spring-kafka'
|
||||
|
||||
// PostgreSQL
|
||||
runtimeOnly 'org.postgresql:postgresql'
|
||||
|
||||
// Lombok
|
||||
compileOnly 'org.projectlombok:lombok'
|
||||
annotationProcessor 'org.projectlombok:lombok'
|
||||
|
||||
// Jackson for JSON
|
||||
implementation 'com.fasterxml.jackson.core:jackson-databind'
|
||||
implementation 'com.fasterxml.jackson.datatype:jackson-datatype-jsr310'
|
||||
|
||||
// Test
|
||||
testImplementation 'org.springframework.boot:spring-boot-starter-test'
|
||||
testImplementation 'org.springframework.kafka:spring-kafka-test'
|
||||
testRuntimeOnly 'org.junit.platform:junit-platform-launcher'
|
||||
}
|
||||
|
||||
tasks.named('test') {
|
||||
useJUnitPlatform()
|
||||
}
|
||||
|
||||
+23
@@ -0,0 +1,23 @@
|
||||
package com.kt.event.participation;
|
||||
|
||||
import org.springframework.boot.SpringApplication;
|
||||
import org.springframework.boot.autoconfigure.SpringBootApplication;
|
||||
import org.springframework.data.jpa.repository.config.EnableJpaAuditing;
|
||||
|
||||
/**
|
||||
* Participation Service Main Application
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@SpringBootApplication(scanBasePackages = {
|
||||
"com.kt.event.participation",
|
||||
"com.kt.event.common"
|
||||
})
|
||||
@EnableJpaAuditing
|
||||
public class ParticipationServiceApplication {
|
||||
|
||||
public static void main(String[] args) {
|
||||
SpringApplication.run(ParticipationServiceApplication.class, args);
|
||||
}
|
||||
}
|
||||
+21
@@ -0,0 +1,21 @@
|
||||
package com.kt.event.participation.application.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DrawWinnersRequest {
|
||||
|
||||
@NotNull(message = "당첨자 수는 필수입니다")
|
||||
@Min(value = 1, message = "당첨자 수는 최소 1명 이상이어야 합니다")
|
||||
private Integer winnerCount;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean applyStoreVisitBonus = true;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.kt.event.participation.application.dto;
|
||||
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.List;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DrawWinnersResponse {
|
||||
|
||||
private String eventId;
|
||||
private Integer totalParticipants;
|
||||
private Integer winnerCount;
|
||||
private LocalDateTime drawnAt;
|
||||
private List<WinnerSummary> winners;
|
||||
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public static class WinnerSummary {
|
||||
private String participantId;
|
||||
private String name;
|
||||
private String phoneNumber;
|
||||
private Integer rank;
|
||||
}
|
||||
}
|
||||
+34
@@ -0,0 +1,34 @@
|
||||
package com.kt.event.participation.application.dto;
|
||||
|
||||
import jakarta.validation.constraints.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 이벤트 참여 요청 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ParticipationRequest {
|
||||
|
||||
@NotBlank(message = "이름은 필수입니다")
|
||||
@Size(min = 2, max = 50, message = "이름은 2자 이상 50자 이하여야 합니다")
|
||||
private String name;
|
||||
|
||||
@NotBlank(message = "전화번호는 필수입니다")
|
||||
@Pattern(regexp = "^\\d{3}-\\d{3,4}-\\d{4}$", message = "전화번호 형식이 올바르지 않습니다")
|
||||
private String phoneNumber;
|
||||
|
||||
@Email(message = "이메일 형식이 올바르지 않습니다")
|
||||
private String email;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean agreeMarketing = false;
|
||||
|
||||
@NotNull(message = "개인정보 수집 및 이용 동의는 필수입니다")
|
||||
private Boolean agreePrivacy;
|
||||
|
||||
@Builder.Default
|
||||
private Boolean storeVisited = false;
|
||||
}
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
package com.kt.event.participation.application.dto;
|
||||
|
||||
import com.kt.event.participation.domain.participant.Participant;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 이벤트 참여 응답 DTO
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ParticipationResponse {
|
||||
|
||||
private String participantId;
|
||||
private String eventId;
|
||||
private String name;
|
||||
private String phoneNumber;
|
||||
private String email;
|
||||
private LocalDateTime participatedAt;
|
||||
private Boolean storeVisited;
|
||||
private Integer bonusEntries;
|
||||
private Boolean isWinner;
|
||||
|
||||
public static ParticipationResponse from(Participant participant) {
|
||||
return ParticipationResponse.builder()
|
||||
.participantId(participant.getParticipantId())
|
||||
.eventId(participant.getEventId())
|
||||
.name(participant.getName())
|
||||
.phoneNumber(participant.getPhoneNumber())
|
||||
.email(participant.getEmail())
|
||||
.participatedAt(participant.getCreatedAt())
|
||||
.storeVisited(participant.getStoreVisited())
|
||||
.bonusEntries(participant.getBonusEntries())
|
||||
.isWinner(participant.getIsWinner())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+117
@@ -0,0 +1,117 @@
|
||||
package com.kt.event.participation.application.service;
|
||||
|
||||
import com.kt.event.common.dto.PageResponse;
|
||||
import com.kt.event.participation.application.dto.ParticipationRequest;
|
||||
import com.kt.event.participation.application.dto.ParticipationResponse;
|
||||
import com.kt.event.participation.domain.participant.Participant;
|
||||
import com.kt.event.participation.domain.participant.ParticipantRepository;
|
||||
import com.kt.event.participation.exception.ParticipationException.*;
|
||||
import com.kt.event.participation.infrastructure.kafka.KafkaProducerService;
|
||||
import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
/**
|
||||
* 이벤트 참여 서비스
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class ParticipationService {
|
||||
|
||||
private final ParticipantRepository participantRepository;
|
||||
private final KafkaProducerService kafkaProducerService;
|
||||
|
||||
/**
|
||||
* 이벤트 참여
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request 참여 요청
|
||||
* @return 참여 응답
|
||||
*/
|
||||
@Transactional
|
||||
public ParticipationResponse participate(String eventId, ParticipationRequest request) {
|
||||
log.info("이벤트 참여 시작 - eventId: {}, phoneNumber: {}", eventId, request.getPhoneNumber());
|
||||
|
||||
// 중복 참여 체크
|
||||
if (participantRepository.existsByEventIdAndPhoneNumber(eventId, request.getPhoneNumber())) {
|
||||
throw new DuplicateParticipationException();
|
||||
}
|
||||
|
||||
// 참여자 ID 생성
|
||||
Long maxId = participantRepository.findMaxIdByEventId(eventId).orElse(0L);
|
||||
String participantId = Participant.generateParticipantId(eventId, maxId + 1);
|
||||
|
||||
// 참여자 저장
|
||||
Participant participant = Participant.builder()
|
||||
.participantId(participantId)
|
||||
.eventId(eventId)
|
||||
.name(request.getName())
|
||||
.phoneNumber(request.getPhoneNumber())
|
||||
.email(request.getEmail())
|
||||
.storeVisited(request.getStoreVisited())
|
||||
.bonusEntries(Participant.calculateBonusEntries(request.getStoreVisited()))
|
||||
.agreeMarketing(request.getAgreeMarketing())
|
||||
.agreePrivacy(request.getAgreePrivacy())
|
||||
.isWinner(false)
|
||||
.build();
|
||||
|
||||
participant = participantRepository.save(participant);
|
||||
log.info("참여자 저장 완료 - participantId: {}", participantId);
|
||||
|
||||
// Kafka 이벤트 발행
|
||||
kafkaProducerService.publishParticipantRegistered(
|
||||
ParticipantRegisteredEvent.from(participant)
|
||||
);
|
||||
|
||||
return ParticipationResponse.from(participant);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 목록 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param storeVisited 매장 방문 여부 필터 (nullable)
|
||||
* @param pageable 페이징 정보
|
||||
* @return 참여자 목록
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ParticipationResponse> getParticipants(
|
||||
String eventId, Boolean storeVisited, Pageable pageable) {
|
||||
|
||||
Page<Participant> participantPage;
|
||||
if (storeVisited != null) {
|
||||
participantPage = participantRepository
|
||||
.findByEventIdAndStoreVisitedOrderByCreatedAtDesc(eventId, storeVisited, pageable);
|
||||
} else {
|
||||
participantPage = participantRepository
|
||||
.findByEventIdOrderByCreatedAtDesc(eventId, pageable);
|
||||
}
|
||||
|
||||
Page<ParticipationResponse> responsePage = participantPage.map(ParticipationResponse::from);
|
||||
return PageResponse.of(responsePage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 상세 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param participantId 참여자 ID
|
||||
* @return 참여자 정보
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public ParticipationResponse getParticipant(String eventId, String participantId) {
|
||||
Participant participant = participantRepository
|
||||
.findByEventIdAndParticipantId(eventId, participantId)
|
||||
.orElseThrow(ParticipantNotFoundException::new);
|
||||
|
||||
return ParticipationResponse.from(participant);
|
||||
}
|
||||
}
|
||||
+158
@@ -0,0 +1,158 @@
|
||||
package com.kt.event.participation.application.service;
|
||||
|
||||
import com.kt.event.common.dto.PageResponse;
|
||||
import com.kt.event.participation.application.dto.DrawWinnersRequest;
|
||||
import com.kt.event.participation.application.dto.DrawWinnersResponse;
|
||||
import com.kt.event.participation.application.dto.DrawWinnersResponse.WinnerSummary;
|
||||
import com.kt.event.participation.application.dto.ParticipationResponse;
|
||||
import com.kt.event.participation.domain.draw.DrawLog;
|
||||
import com.kt.event.participation.domain.draw.DrawLogRepository;
|
||||
import com.kt.event.participation.domain.participant.Participant;
|
||||
import com.kt.event.participation.domain.participant.ParticipantRepository;
|
||||
import com.kt.event.participation.exception.ParticipationException.*;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.stereotype.Service;
|
||||
import org.springframework.transaction.annotation.Transactional;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
import java.util.ArrayList;
|
||||
import java.util.Collections;
|
||||
import java.util.List;
|
||||
import java.util.stream.Collectors;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨 서비스
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class WinnerDrawService {
|
||||
|
||||
private final ParticipantRepository participantRepository;
|
||||
private final DrawLogRepository drawLogRepository;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param request 추첨 요청
|
||||
* @return 추첨 결과
|
||||
*/
|
||||
@Transactional
|
||||
public DrawWinnersResponse drawWinners(String eventId, DrawWinnersRequest request) {
|
||||
log.info("당첨자 추첨 시작 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount());
|
||||
|
||||
// 이미 추첨이 완료되었는지 확인
|
||||
if (drawLogRepository.existsByEventId(eventId)) {
|
||||
throw new AlreadyDrawnException();
|
||||
}
|
||||
|
||||
// 참여자 목록 조회
|
||||
List<Participant> participants = participantRepository.findByEventIdAndIsWinnerFalse(eventId);
|
||||
long participantCount = participants.size();
|
||||
|
||||
// 참여자 수 검증
|
||||
if (participantCount < request.getWinnerCount()) {
|
||||
throw new InsufficientParticipantsException(participantCount, request.getWinnerCount());
|
||||
}
|
||||
|
||||
// 가중치 적용 추첨 풀 생성
|
||||
List<Participant> drawPool = createDrawPool(participants, request.getApplyStoreVisitBonus());
|
||||
|
||||
// 추첨 실행
|
||||
Collections.shuffle(drawPool);
|
||||
List<Participant> winners = drawPool.stream()
|
||||
.distinct()
|
||||
.limit(request.getWinnerCount())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
// 당첨자 업데이트
|
||||
LocalDateTime now = LocalDateTime.now();
|
||||
for (int i = 0; i < winners.size(); i++) {
|
||||
winners.get(i).markAsWinner(i + 1);
|
||||
}
|
||||
participantRepository.saveAll(winners);
|
||||
|
||||
// 추첨 로그 저장
|
||||
DrawLog drawLog = DrawLog.builder()
|
||||
.eventId(eventId)
|
||||
.totalParticipants((int) participantCount)
|
||||
.winnerCount(request.getWinnerCount())
|
||||
.applyStoreVisitBonus(request.getApplyStoreVisitBonus())
|
||||
.algorithm("WEIGHTED_RANDOM")
|
||||
.drawnAt(now)
|
||||
.drawnBy("SYSTEM")
|
||||
.build();
|
||||
drawLogRepository.save(drawLog);
|
||||
|
||||
log.info("당첨자 추첨 완료 - eventId: {}, winners: {}", eventId, winners.size());
|
||||
|
||||
// 응답 생성
|
||||
List<WinnerSummary> winnerSummaries = winners.stream()
|
||||
.map(w -> WinnerSummary.builder()
|
||||
.participantId(w.getParticipantId())
|
||||
.name(w.getName())
|
||||
.phoneNumber(w.getPhoneNumber())
|
||||
.rank(w.getWinnerRank())
|
||||
.build())
|
||||
.collect(Collectors.toList());
|
||||
|
||||
return DrawWinnersResponse.builder()
|
||||
.eventId(eventId)
|
||||
.totalParticipants((int) participantCount)
|
||||
.winnerCount(winners.size())
|
||||
.drawnAt(now)
|
||||
.winners(winnerSummaries)
|
||||
.build();
|
||||
}
|
||||
|
||||
/**
|
||||
* 당첨자 목록 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param pageable 페이징 정보
|
||||
* @return 당첨자 목록
|
||||
*/
|
||||
@Transactional(readOnly = true)
|
||||
public PageResponse<ParticipationResponse> getWinners(String eventId, Pageable pageable) {
|
||||
// 추첨 완료 확인
|
||||
if (!drawLogRepository.existsByEventId(eventId)) {
|
||||
throw new NoWinnersYetException();
|
||||
}
|
||||
|
||||
Page<Participant> winnerPage = participantRepository
|
||||
.findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(eventId, pageable);
|
||||
|
||||
Page<ParticipationResponse> responsePage = winnerPage.map(ParticipationResponse::from);
|
||||
return PageResponse.of(responsePage);
|
||||
}
|
||||
|
||||
/**
|
||||
* 추첨 풀 생성 (매장 방문 보너스 적용)
|
||||
*
|
||||
* @param participants 참여자 목록
|
||||
* @param applyBonus 보너스 적용 여부
|
||||
* @return 추첨 풀
|
||||
*/
|
||||
private List<Participant> createDrawPool(List<Participant> participants, Boolean applyBonus) {
|
||||
if (!applyBonus) {
|
||||
return new ArrayList<>(participants);
|
||||
}
|
||||
|
||||
List<Participant> pool = new ArrayList<>();
|
||||
for (Participant participant : participants) {
|
||||
// 보너스 응모권 수만큼 추첨 풀에 추가
|
||||
int entries = participant.getBonusEntries();
|
||||
for (int i = 0; i < entries; i++) {
|
||||
pool.add(participant);
|
||||
}
|
||||
}
|
||||
return pool;
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
package com.kt.event.participation.domain.draw;
|
||||
|
||||
import com.kt.event.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨 로그 엔티티
|
||||
* 추첨 이력 관리 및 재추첨 방지
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "draw_logs",
|
||||
indexes = {
|
||||
@Index(name = "idx_event_id", columnList = "event_id")
|
||||
}
|
||||
)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class DrawLog extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 이벤트 ID
|
||||
*/
|
||||
@Column(name = "event_id", nullable = false, length = 50)
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 전체 참여자 수
|
||||
*/
|
||||
@Column(name = "total_participants", nullable = false)
|
||||
private Integer totalParticipants;
|
||||
|
||||
/**
|
||||
* 당첨자 수
|
||||
*/
|
||||
@Column(name = "winner_count", nullable = false)
|
||||
private Integer winnerCount;
|
||||
|
||||
/**
|
||||
* 매장 방문 보너스 적용 여부
|
||||
*/
|
||||
@Column(name = "apply_store_visit_bonus", nullable = false)
|
||||
private Boolean applyStoreVisitBonus;
|
||||
|
||||
/**
|
||||
* 추첨 알고리즘
|
||||
*/
|
||||
@Column(name = "algorithm", nullable = false, length = 50)
|
||||
private String algorithm;
|
||||
|
||||
/**
|
||||
* 추첨 일시
|
||||
*/
|
||||
@Column(name = "drawn_at", nullable = false)
|
||||
private java.time.LocalDateTime drawnAt;
|
||||
|
||||
/**
|
||||
* 추첨 실행자 ID (관리자 또는 시스템)
|
||||
*/
|
||||
@Column(name = "drawn_by", length = 50)
|
||||
private String drawnBy;
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
package com.kt.event.participation.domain.draw;
|
||||
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 추첨 로그 리포지토리
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Repository
|
||||
public interface DrawLogRepository extends JpaRepository<DrawLog, Long> {
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 추첨 로그 조회
|
||||
* 이미 추첨이 진행되었는지 확인
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 추첨 로그 Optional
|
||||
*/
|
||||
Optional<DrawLog> findByEventId(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 추첨 여부 확인
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 추첨 여부
|
||||
*/
|
||||
boolean existsByEventId(String eventId);
|
||||
}
|
||||
+162
@@ -0,0 +1,162 @@
|
||||
package com.kt.event.participation.domain.participant;
|
||||
|
||||
import com.kt.event.common.entity.BaseTimeEntity;
|
||||
import jakarta.persistence.*;
|
||||
import lombok.*;
|
||||
|
||||
/**
|
||||
* 이벤트 참여자 엔티티
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Entity
|
||||
@Table(name = "participants",
|
||||
indexes = {
|
||||
@Index(name = "idx_event_id", columnList = "event_id"),
|
||||
@Index(name = "idx_event_phone", columnList = "event_id, phone_number")
|
||||
},
|
||||
uniqueConstraints = {
|
||||
@UniqueConstraint(name = "uk_event_phone", columnNames = {"event_id", "phone_number"})
|
||||
}
|
||||
)
|
||||
@Getter
|
||||
@NoArgsConstructor(access = AccessLevel.PROTECTED)
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class Participant extends BaseTimeEntity {
|
||||
|
||||
@Id
|
||||
@GeneratedValue(strategy = GenerationType.IDENTITY)
|
||||
private Long id;
|
||||
|
||||
/**
|
||||
* 참여자 ID (외부 노출용)
|
||||
* 예: prt_20250123_001
|
||||
*/
|
||||
@Column(name = "participant_id", nullable = false, unique = true, length = 50)
|
||||
private String participantId;
|
||||
|
||||
/**
|
||||
* 이벤트 ID
|
||||
* Event Service의 이벤트 식별자
|
||||
*/
|
||||
@Column(name = "event_id", nullable = false, length = 50)
|
||||
private String eventId;
|
||||
|
||||
/**
|
||||
* 참여자 이름
|
||||
*/
|
||||
@Column(name = "name", nullable = false, length = 50)
|
||||
private String name;
|
||||
|
||||
/**
|
||||
* 참여자 전화번호
|
||||
* 중복 참여 체크 키로 사용
|
||||
*/
|
||||
@Column(name = "phone_number", nullable = false, length = 20)
|
||||
private String phoneNumber;
|
||||
|
||||
/**
|
||||
* 참여자 이메일
|
||||
*/
|
||||
@Column(name = "email", length = 100)
|
||||
private String email;
|
||||
|
||||
/**
|
||||
* 매장 방문 여부
|
||||
* true일 경우 보너스 응모권 부여
|
||||
*/
|
||||
@Column(name = "store_visited", nullable = false)
|
||||
private Boolean storeVisited;
|
||||
|
||||
/**
|
||||
* 보너스 응모권 수
|
||||
* 기본 1, 매장 방문 시 +1
|
||||
*/
|
||||
@Column(name = "bonus_entries", nullable = false)
|
||||
private Integer bonusEntries;
|
||||
|
||||
/**
|
||||
* 마케팅 정보 수신 동의
|
||||
*/
|
||||
@Column(name = "agree_marketing", nullable = false)
|
||||
private Boolean agreeMarketing;
|
||||
|
||||
/**
|
||||
* 개인정보 수집 및 이용 동의 (필수)
|
||||
*/
|
||||
@Column(name = "agree_privacy", nullable = false)
|
||||
private Boolean agreePrivacy;
|
||||
|
||||
/**
|
||||
* 당첨 여부
|
||||
*/
|
||||
@Column(name = "is_winner", nullable = false)
|
||||
private Boolean isWinner;
|
||||
|
||||
/**
|
||||
* 당첨 순위 (당첨자일 경우)
|
||||
*/
|
||||
@Column(name = "winner_rank")
|
||||
private Integer winnerRank;
|
||||
|
||||
/**
|
||||
* 당첨 일시
|
||||
*/
|
||||
@Column(name = "won_at")
|
||||
private java.time.LocalDateTime wonAt;
|
||||
|
||||
/**
|
||||
* 참여자 ID 생성
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param sequenceNumber 순번
|
||||
* @return 생성된 참여자 ID
|
||||
*/
|
||||
public static String generateParticipantId(String eventId, Long sequenceNumber) {
|
||||
// evt_20250123_001 → prt_20250123_001
|
||||
String dateTime = eventId.substring(4, 12); // 20250123
|
||||
return String.format("prt_%s_%03d", dateTime, sequenceNumber);
|
||||
}
|
||||
|
||||
/**
|
||||
* 보너스 응모권 계산
|
||||
*
|
||||
* @param storeVisited 매장 방문 여부
|
||||
* @return 보너스 응모권 수
|
||||
*/
|
||||
public static Integer calculateBonusEntries(Boolean storeVisited) {
|
||||
return storeVisited ? 2 : 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* 당첨자로 설정
|
||||
*
|
||||
* @param rank 당첨 순위
|
||||
*/
|
||||
public void markAsWinner(Integer rank) {
|
||||
this.isWinner = true;
|
||||
this.winnerRank = rank;
|
||||
this.wonAt = java.time.LocalDateTime.now();
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 생성 전 유효성 검증
|
||||
*/
|
||||
@PrePersist
|
||||
public void prePersist() {
|
||||
if (this.agreePrivacy == null || !this.agreePrivacy) {
|
||||
throw new IllegalStateException("개인정보 수집 및 이용 동의는 필수입니다");
|
||||
}
|
||||
if (this.bonusEntries == null) {
|
||||
this.bonusEntries = calculateBonusEntries(this.storeVisited);
|
||||
}
|
||||
if (this.isWinner == null) {
|
||||
this.isWinner = false;
|
||||
}
|
||||
if (this.agreeMarketing == null) {
|
||||
this.agreeMarketing = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
+109
@@ -0,0 +1,109 @@
|
||||
package com.kt.event.participation.domain.participant;
|
||||
|
||||
import org.springframework.data.domain.Page;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.jpa.repository.JpaRepository;
|
||||
import org.springframework.data.jpa.repository.Query;
|
||||
import org.springframework.data.repository.query.Param;
|
||||
import org.springframework.stereotype.Repository;
|
||||
|
||||
import java.util.List;
|
||||
import java.util.Optional;
|
||||
|
||||
/**
|
||||
* 참여자 리포지토리
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Repository
|
||||
public interface ParticipantRepository extends JpaRepository<Participant, Long> {
|
||||
|
||||
/**
|
||||
* 참여자 ID로 조회
|
||||
*
|
||||
* @param participantId 참여자 ID
|
||||
* @return 참여자 Optional
|
||||
*/
|
||||
Optional<Participant> findByParticipantId(String participantId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID와 전화번호로 중복 참여 체크
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param phoneNumber 전화번호
|
||||
* @return 참여 여부
|
||||
*/
|
||||
boolean existsByEventIdAndPhoneNumber(String eventId, String phoneNumber);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 참여자 목록 조회 (페이징)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param pageable 페이징 정보
|
||||
* @return 참여자 페이지
|
||||
*/
|
||||
Page<Participant> findByEventIdOrderByCreatedAtDesc(String eventId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 이벤트 ID와 매장 방문 여부로 참여자 목록 조회 (페이징)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param storeVisited 매장 방문 여부
|
||||
* @param pageable 페이징 정보
|
||||
* @return 참여자 페이지
|
||||
*/
|
||||
Page<Participant> findByEventIdAndStoreVisitedOrderByCreatedAtDesc(
|
||||
String eventId, Boolean storeVisited, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 전체 참여자 수 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 참여자 수
|
||||
*/
|
||||
long countByEventId(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 당첨자 목록 조회 (페이징)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param pageable 페이징 정보
|
||||
* @return 당첨자 페이지
|
||||
*/
|
||||
Page<Participant> findByEventIdAndIsWinnerTrueOrderByWinnerRankAsc(String eventId, Pageable pageable);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 당첨자 수 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 당첨자 수
|
||||
*/
|
||||
long countByEventIdAndIsWinnerTrue(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 참여자 ID 최대값 조회 (순번 생성용)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 최대 ID
|
||||
*/
|
||||
@Query("SELECT MAX(p.id) FROM Participant p WHERE p.eventId = :eventId")
|
||||
Optional<Long> findMaxIdByEventId(@Param("eventId") String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID로 비당첨자 목록 조회 (추첨용)
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @return 비당첨자 목록
|
||||
*/
|
||||
List<Participant> findByEventIdAndIsWinnerFalse(String eventId);
|
||||
|
||||
/**
|
||||
* 이벤트 ID와 참여자 ID로 조회
|
||||
*
|
||||
* @param eventId 이벤트 ID
|
||||
* @param participantId 참여자 ID
|
||||
* @return 참여자 Optional
|
||||
*/
|
||||
Optional<Participant> findByEventIdAndParticipantId(String eventId, String participantId);
|
||||
}
|
||||
+85
@@ -0,0 +1,85 @@
|
||||
package com.kt.event.participation.exception;
|
||||
|
||||
import com.kt.event.common.exception.BusinessException;
|
||||
import com.kt.event.common.exception.ErrorCode;
|
||||
|
||||
/**
|
||||
* 참여 관련 비즈니스 예외
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
public class ParticipationException extends BusinessException {
|
||||
|
||||
public ParticipationException(ErrorCode errorCode) {
|
||||
super(errorCode);
|
||||
}
|
||||
|
||||
public ParticipationException(ErrorCode errorCode, String message) {
|
||||
super(errorCode, message);
|
||||
}
|
||||
|
||||
/**
|
||||
* 중복 참여 예외
|
||||
*/
|
||||
public static class DuplicateParticipationException extends ParticipationException {
|
||||
public DuplicateParticipationException() {
|
||||
super(ErrorCode.DUPLICATE_PARTICIPATION, "이미 참여하신 이벤트입니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트를 찾을 수 없음 예외
|
||||
*/
|
||||
public static class EventNotFoundException extends ParticipationException {
|
||||
public EventNotFoundException() {
|
||||
super(ErrorCode.EVENT_001, "이벤트를 찾을 수 없습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이벤트가 활성 상태가 아님 예외
|
||||
*/
|
||||
public static class EventNotActiveException extends ParticipationException {
|
||||
public EventNotActiveException() {
|
||||
super(ErrorCode.EVENT_NOT_ACTIVE, "현재 참여할 수 없는 이벤트입니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자를 찾을 수 없음 예외
|
||||
*/
|
||||
public static class ParticipantNotFoundException extends ParticipationException {
|
||||
public ParticipantNotFoundException() {
|
||||
super(ErrorCode.PARTICIPANT_NOT_FOUND, "참여자를 찾을 수 없습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 이미 추첨이 완료됨 예외
|
||||
*/
|
||||
public static class AlreadyDrawnException extends ParticipationException {
|
||||
public AlreadyDrawnException() {
|
||||
super(ErrorCode.ALREADY_DRAWN, "이미 당첨자 추첨이 완료되었습니다");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 수 부족 예외
|
||||
*/
|
||||
public static class InsufficientParticipantsException extends ParticipationException {
|
||||
public InsufficientParticipantsException(long participantCount, int winnerCount) {
|
||||
super(ErrorCode.INSUFFICIENT_PARTICIPANTS,
|
||||
String.format("참여자 수(%d)가 당첨자 수(%d)보다 적습니다", participantCount, winnerCount));
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 당첨자가 없음 예외
|
||||
*/
|
||||
public static class NoWinnersYetException extends ParticipationException {
|
||||
public NoWinnersYetException() {
|
||||
super(ErrorCode.NO_WINNERS_YET, "아직 당첨자 추첨이 진행되지 않았습니다");
|
||||
}
|
||||
}
|
||||
}
|
||||
+32
@@ -0,0 +1,32 @@
|
||||
package com.kt.event.participation.infrastructure.config;
|
||||
|
||||
import org.springframework.context.annotation.Bean;
|
||||
import org.springframework.context.annotation.Configuration;
|
||||
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||
import org.springframework.security.web.SecurityFilterChain;
|
||||
|
||||
/**
|
||||
* Security Configuration for Participation Service
|
||||
* 이벤트 참여 API는 공개 API로 인증 불필요
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Configuration
|
||||
@EnableWebSecurity
|
||||
public class SecurityConfig {
|
||||
|
||||
@Bean
|
||||
public SecurityFilterChain filterChain(HttpSecurity http) throws Exception {
|
||||
http
|
||||
.csrf(csrf -> csrf.disable())
|
||||
.sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||
.authorizeHttpRequests(auth -> auth
|
||||
.anyRequest().permitAll()
|
||||
);
|
||||
|
||||
return http.build();
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.kt.event.participation.infrastructure.kafka;
|
||||
|
||||
import com.kt.event.participation.infrastructure.kafka.event.ParticipantRegisteredEvent;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.kafka.core.KafkaTemplate;
|
||||
import org.springframework.stereotype.Service;
|
||||
|
||||
/**
|
||||
* Kafka Producer 서비스
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@Service
|
||||
@RequiredArgsConstructor
|
||||
public class KafkaProducerService {
|
||||
|
||||
private static final String PARTICIPANT_REGISTERED_TOPIC = "participant-registered-events";
|
||||
|
||||
private final KafkaTemplate<String, Object> kafkaTemplate;
|
||||
|
||||
/**
|
||||
* 참여자 등록 이벤트 발행
|
||||
*
|
||||
* @param event 참여자 등록 이벤트
|
||||
*/
|
||||
public void publishParticipantRegistered(ParticipantRegisteredEvent event) {
|
||||
try {
|
||||
kafkaTemplate.send(PARTICIPANT_REGISTERED_TOPIC, event.getEventId(), event);
|
||||
log.info("Kafka 이벤트 발행 성공 - topic: {}, participantId: {}",
|
||||
PARTICIPANT_REGISTERED_TOPIC, event.getParticipantId());
|
||||
} catch (Exception e) {
|
||||
log.error("Kafka 이벤트 발행 실패 - participantId: {}", event.getParticipantId(), e);
|
||||
// 이벤트 발행 실패는 서비스 로직에 영향을 주지 않음
|
||||
}
|
||||
}
|
||||
}
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
package com.kt.event.participation.infrastructure.kafka.event;
|
||||
|
||||
import com.kt.event.participation.domain.participant.Participant;
|
||||
import lombok.*;
|
||||
|
||||
import java.time.LocalDateTime;
|
||||
|
||||
/**
|
||||
* 참여자 등록 Kafka 이벤트
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Getter
|
||||
@NoArgsConstructor
|
||||
@AllArgsConstructor
|
||||
@Builder
|
||||
public class ParticipantRegisteredEvent {
|
||||
|
||||
private String participantId;
|
||||
private String eventId;
|
||||
private String name;
|
||||
private String phoneNumber;
|
||||
private Boolean storeVisited;
|
||||
private Integer bonusEntries;
|
||||
private LocalDateTime participatedAt;
|
||||
|
||||
public static ParticipantRegisteredEvent from(Participant participant) {
|
||||
return ParticipantRegisteredEvent.builder()
|
||||
.participantId(participant.getParticipantId())
|
||||
.eventId(participant.getEventId())
|
||||
.name(participant.getName())
|
||||
.phoneNumber(participant.getPhoneNumber())
|
||||
.storeVisited(participant.getStoreVisited())
|
||||
.bonusEntries(participant.getBonusEntries())
|
||||
.participatedAt(participant.getCreatedAt())
|
||||
.build();
|
||||
}
|
||||
}
|
||||
+79
@@ -0,0 +1,79 @@
|
||||
package com.kt.event.participation.presentation.controller;
|
||||
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import com.kt.event.common.dto.PageResponse;
|
||||
import com.kt.event.participation.application.dto.ParticipationRequest;
|
||||
import com.kt.event.participation.application.dto.ParticipationResponse;
|
||||
import com.kt.event.participation.application.service.ParticipationService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.HttpStatus;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 이벤트 참여 컨트롤러
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping
|
||||
@RequiredArgsConstructor
|
||||
public class ParticipationController {
|
||||
|
||||
private final ParticipationService participationService;
|
||||
|
||||
/**
|
||||
* 이벤트 참여
|
||||
* POST /events/{eventId}/participate
|
||||
*/
|
||||
@PostMapping("/events/{eventId}/participate")
|
||||
public ResponseEntity<ApiResponse<ParticipationResponse>> participate(
|
||||
@PathVariable String eventId,
|
||||
@Valid @RequestBody ParticipationRequest request) {
|
||||
|
||||
log.info("이벤트 참여 요청 - eventId: {}", eventId);
|
||||
ParticipationResponse response = participationService.participate(eventId, request);
|
||||
|
||||
return ResponseEntity
|
||||
.status(HttpStatus.CREATED)
|
||||
.body(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 목록 조회
|
||||
* GET /events/{eventId}/participants
|
||||
*/
|
||||
@GetMapping("/events/{eventId}/participants")
|
||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getParticipants(
|
||||
@PathVariable String eventId,
|
||||
@RequestParam(required = false) Boolean storeVisited,
|
||||
@PageableDefault(size = 20) Pageable pageable) {
|
||||
|
||||
log.info("참여자 목록 조회 요청 - eventId: {}, storeVisited: {}", eventId, storeVisited);
|
||||
PageResponse<ParticipationResponse> response =
|
||||
participationService.getParticipants(eventId, storeVisited, pageable);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 참여자 상세 조회
|
||||
* GET /events/{eventId}/participants/{participantId}
|
||||
*/
|
||||
@GetMapping("/events/{eventId}/participants/{participantId}")
|
||||
public ResponseEntity<ApiResponse<ParticipationResponse>> getParticipant(
|
||||
@PathVariable String eventId,
|
||||
@PathVariable String participantId) {
|
||||
|
||||
log.info("참여자 상세 조회 요청 - eventId: {}, participantId: {}", eventId, participantId);
|
||||
ParticipationResponse response = participationService.getParticipant(eventId, participantId);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
+60
@@ -0,0 +1,60 @@
|
||||
package com.kt.event.participation.presentation.controller;
|
||||
|
||||
import com.kt.event.common.dto.ApiResponse;
|
||||
import com.kt.event.common.dto.PageResponse;
|
||||
import com.kt.event.participation.application.dto.DrawWinnersRequest;
|
||||
import com.kt.event.participation.application.dto.DrawWinnersResponse;
|
||||
import com.kt.event.participation.application.dto.ParticipationResponse;
|
||||
import com.kt.event.participation.application.service.WinnerDrawService;
|
||||
import jakarta.validation.Valid;
|
||||
import lombok.RequiredArgsConstructor;
|
||||
import lombok.extern.slf4j.Slf4j;
|
||||
import org.springframework.data.domain.Pageable;
|
||||
import org.springframework.data.web.PageableDefault;
|
||||
import org.springframework.http.ResponseEntity;
|
||||
import org.springframework.web.bind.annotation.*;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨 컨트롤러
|
||||
*
|
||||
* @author Digital Garage Team
|
||||
* @since 2025-01-24
|
||||
*/
|
||||
@Slf4j
|
||||
@RestController
|
||||
@RequestMapping
|
||||
@RequiredArgsConstructor
|
||||
public class WinnerController {
|
||||
|
||||
private final WinnerDrawService winnerDrawService;
|
||||
|
||||
/**
|
||||
* 당첨자 추첨
|
||||
* POST /events/{eventId}/draw-winners
|
||||
*/
|
||||
@PostMapping("/events/{eventId}/draw-winners")
|
||||
public ResponseEntity<ApiResponse<DrawWinnersResponse>> drawWinners(
|
||||
@PathVariable String eventId,
|
||||
@Valid @RequestBody DrawWinnersRequest request) {
|
||||
|
||||
log.info("당첨자 추첨 요청 - eventId: {}, winnerCount: {}", eventId, request.getWinnerCount());
|
||||
DrawWinnersResponse response = winnerDrawService.drawWinners(eventId, request);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
|
||||
/**
|
||||
* 당첨자 목록 조회
|
||||
* GET /events/{eventId}/winners
|
||||
*/
|
||||
@GetMapping("/events/{eventId}/winners")
|
||||
public ResponseEntity<ApiResponse<PageResponse<ParticipationResponse>>> getWinners(
|
||||
@PathVariable String eventId,
|
||||
@PageableDefault(size = 20) Pageable pageable) {
|
||||
|
||||
log.info("당첨자 목록 조회 요청 - eventId: {}", eventId);
|
||||
PageResponse<ParticipationResponse> response = winnerDrawService.getWinners(eventId, pageable);
|
||||
|
||||
return ResponseEntity.ok(ApiResponse.success(response));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user