Skip to content

Commit

Permalink
Merge pull request #43 from softeerbootcamp4th/feature/40-draw-event-…
Browse files Browse the repository at this point in the history
…logic

[feat] 추첨 로직 구현(#40)
  • Loading branch information
blaxsior authored Aug 9, 2024
2 parents 0f73039 + 26c2ae9 commit 29df496
Show file tree
Hide file tree
Showing 39 changed files with 1,188 additions and 14 deletions.
11 changes: 11 additions & 0 deletions infra/dev/db/docker-compose.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
services:
mysql:
image: mysql
ports:
- 3306:3306
environment:
MYSQL_ROOT_PASSWORD: test
redis:
image: redis
ports:
- 6379:6379
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
@NoArgsConstructor
public class Admin {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
@GeneratedValue(strategy = GenerationType.IDENTITY)
private Long id;

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package hyundai.softeer.orange.comment.dto;

public interface WriteCommentCountDto {
Long getEventUserId();
Long getCount();
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package hyundai.softeer.orange.comment.repository;

import hyundai.softeer.orange.comment.dto.WriteCommentCountDto;
import hyundai.softeer.orange.comment.entity.Comment;
import org.springframework.data.domain.Page;
import org.springframework.data.domain.Pageable;
Expand All @@ -26,4 +27,13 @@ public interface CommentRepository extends JpaRepository<Comment, Long> {
countProjection = "c.id", // 어떤 값으로 count 셀건지 지정. 지정 안하면 count(c.*)가 되어 문제 발생.
nativeQuery = true)
Page<Comment> findAllByEventId(@Param("eventId") String eventId, Pageable pageable);

// 이름 매핑 필요. 필드 이름과 직접 매핑.
@Query(value = "SELECT c.event_user_id as eventUserId, COUNT(c.event_user_id) as count " +
"FROM comment c " +
"JOIN event_frame ef ON c.event_frame_id = ef.id " +
"JOIN event_metadata e ON ef.id = e.event_frame_id " +
"WHERE e.id = :eventRawId " +
"GROUP BY c.event_user_id " , nativeQuery = true)
List<WriteCommentCountDto> countPerEventUserByEventId(@Param("eventRawId") Long eventRawId);
}
1 change: 1 addition & 0 deletions src/main/java/hyundai/softeer/orange/common/ErrorCode.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@ public enum ErrorCode {
USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "사용자를 찾을 수 없습니다."),
SHORT_URL_NOT_FOUND(HttpStatus.NOT_FOUND, false, "단축 URL을 찾을 수 없습니다."),
FCFS_EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "선착순 이벤트를 찾을 수 없습니다."),
DRAW_EVENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "추첨 이벤트를 찾을 수 없습니다."),
EVENT_FRAME_NOT_FOUND(HttpStatus.NOT_FOUND, false, "이벤트 프레임을 찾을 수 없습니다."),
EVENT_USER_NOT_FOUND(HttpStatus.NOT_FOUND, false, "이벤트 사용자를 찾을 수 없습니다."),
COMMENT_NOT_FOUND(HttpStatus.NOT_FOUND, false, "기대평을 찾을 수 없습니다."),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,25 @@
import hyundai.softeer.orange.event.url.exception.UrlException;
import hyundai.softeer.orange.eventuser.exception.EventUserException;

import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.MessageSource;
import org.springframework.context.i18n.LocaleContextHolder;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.http.converter.HttpMessageNotReadableException;
import org.springframework.web.bind.MethodArgumentNotValidException;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.ResponseStatus;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.method.annotation.MethodArgumentTypeMismatchException;

import java.util.HashMap;
import java.util.Locale;
import java.util.Map;

@RestControllerAdvice
public class GlobalExceptionHandler {
@Autowired
private MessageSource messageSource;

@ExceptionHandler(MethodArgumentNotValidException.class) // 요청의 유효성 검사 실패 시
@ResponseStatus(HttpStatus.BAD_REQUEST) // 400 Bad Request로 응답 반환
Expand All @@ -39,10 +44,15 @@ public ResponseEntity<Map<String, String>> handleInValidRequestException(MethodA
// TODO: messages.properties에 예외 메시지 커스터마이징할 수 있게 방법 찾아보기
@ExceptionHandler(MethodArgumentTypeMismatchException.class)
@ResponseStatus(HttpStatus.BAD_REQUEST)
public ResponseEntity<Map<String, String>> handleInValidRequestException(MethodArgumentTypeMismatchException e) {
Map<String, String> errors = new HashMap<>();
errors.put(e.getName(), e.getLocalizedMessage());
return ResponseEntity.badRequest().body(errors);
public Map<String,String> handleInValidRequestException(MethodArgumentTypeMismatchException e) {
String code = e.getErrorCode();
String fieldName = e.getName();
Locale locale = LocaleContextHolder.getLocale(); // 현재 스레드의 로케일 정보를 가져온다.
String errorMessage = messageSource.getMessage(code, null, locale); // 국제화 된 메시지를 가져온다.

Map<String, String> error = new HashMap<>();
error.put(fieldName, errorMessage);
return error;
}

@ExceptionHandler({CommentException.class, AdminException.class, EventUserException.class, FcfsEventException.class, UrlException.class, InternalServerException.class})
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
package hyundai.softeer.orange.event.draw;

import hyundai.softeer.orange.event.draw.component.score.actionHandler.ActionHandler;
import hyundai.softeer.orange.event.draw.enums.DrawEventAction;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;

import java.util.Collections;
import java.util.HashMap;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;

@Configuration
public class DrawEventConfig {
private static final String NamePattern = "(.+?)_ActionHandler";

// actionHandler을 Action 타입으로 가져올 수 있도록 설정
@Bean(name = "actionHandlerMap")
public Map<DrawEventAction, ActionHandler> actionHandlerMap(Map<String, ActionHandler> handlers) {
Map<DrawEventAction, ActionHandler> actionHandlerMap = new HashMap<>();
Pattern pattern = Pattern.compile(NamePattern);

for(var entry: handlers.entrySet()) {
Matcher matcher = pattern.matcher(entry.getKey());
if (!matcher.find()) throw new RuntimeException("no matched action");

String key = matcher.group(1);
ActionHandler actionHandler = entry.getValue();

actionHandlerMap.put(DrawEventAction.valueOf(key), actionHandler);
}
// 런타임에 변경 못하게 제한
return Collections.unmodifiableMap(actionHandlerMap);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package hyundai.softeer.orange.event.draw.component.picker;

import org.springframework.context.annotation.Primary;
import org.springframework.stereotype.Component;

import java.util.*;

@Primary
@Component
public class AccSumBasedWinnerPicker implements WinnerPicker {

@Override
public List<PickTarget> pick(List<PickTarget> items, long count) {
long maxPickCount = Math.min(items.size(), count);
// TODO: 둘 중 하나를 선택하는 최적 조건 분석하기.
if (count - maxPickCount <= 10 || // 두 값 차이가 작을 때
((double) count / maxPickCount) <= 1.1 ) {
return pickMany(items, maxPickCount);
} else {
return pickManyUsingSet(items, maxPickCount);
}
}

protected List<PickTarget> pickMany(List<PickTarget> targets, long count) {
List<PickTarget> pickedTargets = new ArrayList<>();
// 추첨에 참여하는 객체들이 존재하는 set
Set<PickTarget> targetSet = new HashSet<>(targets);

for(int i = 0; i < count; i++) {
RandomItem[] items = getAccumulatedItems(targetSet);
long bound = items[items.length - 1].score;
long targetScore = new Random().nextLong(1, bound + 1);
int pickedIdx = binarySearch(items, targetScore);
PickTarget pickedTarget = items[pickedIdx].target;

targetSet.remove(pickedTarget);
pickedTargets.add(pickedTarget);
}

return pickedTargets;
}

/**
* set 자료구조를 이용하여 가중합을 1번만 계산하는 타겟 선택 방식
* @param targets 선택 대상이 되는 배열
* @param count
* @return
*/
protected List<PickTarget> pickManyUsingSet(List<PickTarget> targets, long count) {
// 가중합 배열
RandomItem[] items = getAccumulatedItems(targets);
List<PickTarget> pickedTargets = new ArrayList<>();
long bound = items[items.length - 1].score;
// 이미 선택된 대상이 존재하는 공간
Set<Integer> pickedIdxSet = new HashSet<>();

for(int i = 0; i < count; i++) {
int pickedIdx;
do {
long targetScore = new Random().nextLong(1, bound + 1);
pickedIdx = binarySearch(items, targetScore);
}while(pickedIdxSet.contains(pickedIdx));
// 방문했다고 마킹
pickedIdxSet.add(pickedIdx);
pickedTargets.add(targets.get(pickedIdx));
}

return pickedTargets;
}



/**
* 누적합 배열을 반환한다
* @param targets 추첨 대상들
* @return 누적합 형태로 표현된 RandomItem 배열
*/
protected RandomItem[] getAccumulatedItems(Collection<PickTarget> targets) {
RandomItem[] items = new RandomItem[targets.size()];
long score = 0;

int idx = 0;
for (PickTarget target : targets) {
score += target.score();
items[idx++] = new RandomItem(target, score);
}

return items;
}

/**
* 이진 탐색을 이용하여 누적합 배열에서 대상 인덱스를 얻는다.
* @param items 누적합 배열
* @param target 대상 점수
* @return 선택된 인덱스 값
*/
protected int binarySearch(RandomItem[] items, long target) {
int low = 0;
int high = items.length - 1;

while(low <= high) {
// >>>은 비트열 오른쪽으로 이동, 빈 공간은 0으로 채움
// 2로 나누는 로직을 >>>으로 처리
int mid = (low + high) >>> 1;

if(items[mid].score > target) {
high = mid - 1;
} else if(items[mid].score < target) {
low = mid + 1;
} else {
break;
}
}

return low;
}

/**
* 가중합을 기록해두는 객체
* @param target 추첨 대상
* @param score 누적합 점수
*/
protected record RandomItem(PickTarget target, long score) {}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
package hyundai.softeer.orange.event.draw.component.picker;

public record PickTarget(Long key, long score) {}
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
package hyundai.softeer.orange.event.draw.component.picker;

import java.util.List;

/**
* "추첨" 작업을 진행하는 서비스. 여러 추첨 방식이 존재할 수 있으므로, 추첨 과정은 별도 인터페이스로 분리.
*/
public interface WinnerPicker {
List<PickTarget> pick(List<PickTarget> items,long count);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package hyundai.softeer.orange.event.draw.component.score;

import hyundai.softeer.orange.event.draw.component.score.actionHandler.ActionHandler;
import hyundai.softeer.orange.event.draw.entity.DrawEventScorePolicy;
import hyundai.softeer.orange.event.draw.enums.DrawEventAction;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.HashMap;
import java.util.List;
import java.util.Map;

/**
* 특정 추첨 이벤트에 대한 점수를 계산하는 클래스. 저장은 안한다.
*/
@RequiredArgsConstructor
@Component
public class ScoreCalculator {
private final Map<DrawEventAction, ActionHandler> handlerMap;

public Map<Long, Long> calculate(long eventId, List<DrawEventScorePolicy> policies) {
Map<Long, Long> scoreMap = new HashMap<>();
for (var policy : policies) {
ActionHandler handler = handlerMap.get(policy.getAction());
handler.handle(scoreMap, eventId, policy.getScore());
}
return scoreMap;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package hyundai.softeer.orange.event.draw.component.score.actionHandler;

import java.util.Map;

/**
* 채점 정책에 대한 동작을 처리한다.
* <p>component 이름은 action 이름 + ActionHandler 형식을 띄어야 한다.</p>
* <p>액션 = {@link hyundai.softeer.orange.event.draw.enums.DrawEventAction}</p>
*/
public interface ActionHandler {
void handle(Map<Long, Long> scoreMap, long eventRawId, long score);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package hyundai.softeer.orange.event.draw.component.score.actionHandler;

import hyundai.softeer.orange.event.draw.repository.EventParticipationInfoRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Map;

@RequiredArgsConstructor
@Component("ParticipateEvent_ActionHandler")
public class ParticipateEventActionHandler implements ActionHandler {
private final EventParticipationInfoRepository repo;

@Override
public void handle(Map<Long, Long> scoreMap, long eventRawId, long score) {
var participateCounts = repo.countPerEventUserByEventId(eventRawId);
for (var p : participateCounts) {
long beforeScore = scoreMap.getOrDefault(p.getEventUserId(),0L);
scoreMap.put(p.getEventUserId(), beforeScore + p.getCount() * score);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package hyundai.softeer.orange.event.draw.component.score.actionHandler;

import hyundai.softeer.orange.comment.repository.CommentRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Component;

import java.util.Map;

@RequiredArgsConstructor
@Component("WriteComment_ActionHandler")
public class WriteCommentActionHandler implements ActionHandler {
private final CommentRepository repo;

@Override
public void handle(Map<Long, Long> scoreMap, long eventRawId, long score) {
var commentCounts = repo.countPerEventUserByEventId(eventRawId);
for (var c : commentCounts) {
long beforeScore = scoreMap.getOrDefault(c.getEventUserId(), 0L);
scoreMap.put(c.getEventUserId(), beforeScore + c.getCount() * score);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
package hyundai.softeer.orange.event.draw.dto;

import lombok.Getter;

@Getter
public class DrawEventWinningInfoBulkInsertDto {
private long eventUserId;
private long ranking;
private long drawEventId;

public static DrawEventWinningInfoBulkInsertDto of(long eventUserId, long ranking, long drawEventId) {
var dto = new DrawEventWinningInfoBulkInsertDto();
dto.eventUserId = eventUserId;
dto.ranking = ranking;
dto.drawEventId = drawEventId;
return dto;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
package hyundai.softeer.orange.event.draw.dto;

public interface EventParticipateCountDto {
long getEventUserId();
long getCount();
}
Loading

0 comments on commit 29df496

Please sign in to comment.