Skip to content

Commit

Permalink
Merge pull request #34 from softeerbootcamp4th/feature/33-feat-fcfs
Browse files Browse the repository at this point in the history
[feat] 선착순 이벤트 잔여 구현방안: Redis SortedSet + synchronized, DB Entity 기반 (#33)
  • Loading branch information
win-luck authored Aug 6, 2024
2 parents c3bb5b1 + 767f8f5 commit aeb7330
Show file tree
Hide file tree
Showing 21 changed files with 644 additions and 177 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,10 @@
import hyundai.softeer.orange.comment.dto.ResponseCommentsDto;
import hyundai.softeer.orange.comment.service.ApiService;
import hyundai.softeer.orange.comment.service.CommentService;
import hyundai.softeer.orange.common.ErrorResponse;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
Expand All @@ -25,17 +28,23 @@ public class CommentController {

@Tag(name = "Comment")
@GetMapping
@Operation(summary = "기대평 조회", description = "주기적으로 추출되는 긍정 기대평 목록을 조회한다.")
@Operation(summary = "기대평 조회", description = "주기적으로 추출되는 긍정 기대평 목록을 조회한다.", responses = {
@ApiResponse(responseCode = "200", description = "기대평 조회 성공",
content = @Content(schema = @Schema(implementation = ResponseCommentsDto.class)))
})
public ResponseEntity<ResponseCommentsDto> getComments() {
return ResponseEntity.ok(commentService.getComments());
}

@Tag(name = "Comment")
@PostMapping
@Operation(summary = "기대평 등록", description = "유저가 신규 기대평을 등록한다.", responses = {
@ApiResponse(responseCode = "200", description = "기대평 등록 성공"),
@ApiResponse(responseCode = "400", description = "기대평 등록 실패, 지나치게 부정적인 표현으로 간주될 때"),
@ApiResponse(responseCode = "409", description = "하루에 여러 번의 기대평을 작성하려 할 때")
@ApiResponse(responseCode = "200", description = "기대평 등록 성공",
content = @Content(schema = @Schema(implementation = Boolean.class))),
@ApiResponse(responseCode = "400", description = "기대평 등록 실패, 지나치게 부정적인 표현으로 간주될 때",
content = @Content(schema = @Schema(implementation = ErrorResponse.class))),
@ApiResponse(responseCode = "409", description = "하루에 여러 번의 기대평을 작성하려 할 때",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<Boolean> createComment(@RequestBody @Valid CreateCommentDto dto) {
boolean isPositive = apiService.analyzeComment(dto.getContent());
Expand Down
Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package hyundai.softeer.orange.event.fcfs.controller;

import hyundai.softeer.orange.common.ErrorResponse;
import hyundai.softeer.orange.event.fcfs.dto.ResponseFcfsResultDto;
import hyundai.softeer.orange.event.fcfs.service.FcfsAnswerService;
import hyundai.softeer.orange.event.fcfs.service.FcfsService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.media.Content;
import io.swagger.v3.oas.annotations.media.Schema;
import io.swagger.v3.oas.annotations.responses.ApiResponse;
import io.swagger.v3.oas.annotations.tags.Tag;
import lombok.RequiredArgsConstructor;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import org.springframework.web.bind.annotation.*;

@Tag(name = "fcfs", description = "선착순 이벤트 관련 API")
@RequiredArgsConstructor
Expand All @@ -18,14 +20,19 @@
public class FcfsController {

private final FcfsService fcfsService;
private final FcfsAnswerService fcfsAnswerService;

@Tag(name = "fcfs")
@PostMapping
@Operation(summary = "선착순 이벤트 참여", description = "선착순 이벤트에 참여한 결과(boolean)를 반환한다.", responses = {
@ApiResponse(responseCode = "200", description = "선착순 이벤트 당첨 성공 혹은 실패"),
@ApiResponse(responseCode = "400", description = "선착순 이벤트 시간이 아니거나, 요청 형식이 잘못된 경우"),
@ApiResponse(responseCode = "200", description = "선착순 이벤트 당첨 성공 혹은 실패",
content = @Content(schema = @Schema(implementation = ResponseFcfsResultDto.class))),
@ApiResponse(responseCode = "400", description = "선착순 이벤트 시간이 아니거나, 요청 형식이 잘못된 경우",
content = @Content(schema = @Schema(implementation = ErrorResponse.class)))
})
public ResponseEntity<Boolean> participate(@RequestParam Long eventSequence, @RequestParam String userId) {
return ResponseEntity.ok(fcfsService.participate(eventSequence, userId));
public ResponseEntity<ResponseFcfsResultDto> participate(@RequestParam Long eventSequence, @RequestParam String userId, @RequestParam String eventAnswer) {
boolean answerResult = fcfsAnswerService.judgeAnswer(eventSequence, eventAnswer);
boolean isWin = answerResult && fcfsService.participate(eventSequence, userId);
return ResponseEntity.ok(new ResponseFcfsResultDto(answerResult, isWin));
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
package hyundai.softeer.orange.event.fcfs.dto;

import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@NoArgsConstructor
@Builder
@AllArgsConstructor
@Getter
public class ResponseFcfsResultDto {

// 선착순 이벤트 정답 여부
private boolean answerResult;

// 선착순 이벤트 당첨 여부, answerResult가 false라면 무조건 false를 반환
private boolean isWinner;
}
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,7 @@

import hyundai.softeer.orange.event.common.entity.EventMetadata;
import jakarta.persistence.*;
import lombok.AllArgsConstructor;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;
import lombok.*;

import java.time.LocalDateTime;
import java.util.ArrayList;
Expand All @@ -15,7 +12,7 @@
@Getter
@Entity
@Builder
@NoArgsConstructor
@NoArgsConstructor(access = AccessLevel.PROTECTED)
@AllArgsConstructor
public class FcfsEvent {
@Id
Expand All @@ -38,6 +35,9 @@ public class FcfsEvent {
@JoinColumn(name = "event_metadata_id")
private EventMetadata eventMetaData;

@OneToMany(mappedBy = "fcfsEvent")
private final List<FcfsEventWinningInfo> infos = new ArrayList<>();

public void updateStartTime(LocalDateTime startTime) {
this.startTime = startTime;
}
Expand All @@ -54,9 +54,6 @@ public void updatePrizeInfo(String prizeInfo) {
this.prizeInfo = prizeInfo;
}

@OneToMany(mappedBy = "fcfsEvent")
private final List<FcfsEventWinningInfo> infos = new ArrayList<>();

public static FcfsEvent of(LocalDateTime startTime, LocalDateTime endTime, Long participantCount, String prizeInfo, EventMetadata eventMetadata) {
FcfsEvent fcfsEvent = new FcfsEvent();
fcfsEvent.startTime = startTime;
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,22 @@
package hyundai.softeer.orange.event.fcfs.repository;

import hyundai.softeer.orange.event.fcfs.entity.FcfsEvent;
import jakarta.persistence.LockModeType;
import org.springframework.data.jpa.repository.JpaRepository;
import org.springframework.data.jpa.repository.Lock;
import org.springframework.data.jpa.repository.Query;
import org.springframework.stereotype.Repository;

import java.time.LocalDateTime;
import java.util.List;
import java.util.Optional;

@Repository
public interface FcfsEventRepository extends JpaRepository<FcfsEvent, Long> {

List<FcfsEvent> findByStartTimeBetween(LocalDateTime startTime, LocalDateTime endTime);

@Lock(LockModeType.PESSIMISTIC_WRITE)
@Query("select e from FcfsEvent e left join fetch e.infos where e.id = :id")
Optional<FcfsEvent> findByIdWithLock(Long id);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,55 @@
package hyundai.softeer.orange.event.fcfs.service;

import hyundai.softeer.orange.common.ErrorCode;
import hyundai.softeer.orange.event.fcfs.entity.FcfsEvent;
import hyundai.softeer.orange.event.fcfs.entity.FcfsEventWinningInfo;
import hyundai.softeer.orange.event.fcfs.exception.FcfsEventException;
import hyundai.softeer.orange.event.fcfs.repository.FcfsEventRepository;
import hyundai.softeer.orange.event.fcfs.repository.FcfsEventWinningInfoRepository;
import hyundai.softeer.orange.eventuser.entity.EventUser;
import hyundai.softeer.orange.eventuser.repository.EventUserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

@Slf4j
@RequiredArgsConstructor
@Service
public class DbFcfsService implements FcfsService{

private final FcfsEventRepository fcfsEventRepository;
private final EventUserRepository eventUserRepository;
private final FcfsEventWinningInfoRepository fcfsEventWinningInfoRepository;

@Override
@Transactional
public boolean participate(Long eventSequence, String userId){
FcfsEvent fcfsEvent = fcfsEventRepository.findByIdWithLock(eventSequence)
.orElseThrow(() -> new FcfsEventException(ErrorCode.EVENT_NOT_FOUND));
EventUser eventUser = eventUserRepository.findByUserId(userId)
.orElseThrow(() -> new FcfsEventException(ErrorCode.EVENT_USER_NOT_FOUND));
// 이미 마감된 이벤트인지 확인
if(fcfsEvent.getInfos().size() >= fcfsEvent.getParticipantCount()){
return false;
}
validateParticipate(fcfsEvent, eventUser);

fcfsEventWinningInfoRepository.save(FcfsEventWinningInfo.of(fcfsEvent, eventUser));
return true;
}

private void validateParticipate(FcfsEvent fcfsEvent, EventUser eventUser){
// 잘못된 이벤트 참여 시간인지 검증
if(LocalDateTime.now().isBefore(fcfsEvent.getStartTime()) || LocalDateTime.now().isAfter(fcfsEvent.getEndTime())){
throw new FcfsEventException(ErrorCode.INVALID_EVENT_TIME);
}

// 이미 당첨된 사용자인지 확인
if(fcfsEvent.getInfos().stream().anyMatch(info -> info.getEventUser().equals(eventUser))){
throw new FcfsEventException(ErrorCode.ALREADY_WINNER);
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
package hyundai.softeer.orange.event.fcfs.service;

import hyundai.softeer.orange.common.ErrorCode;
import hyundai.softeer.orange.event.fcfs.exception.FcfsEventException;
import hyundai.softeer.orange.event.fcfs.util.FcfsUtil;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;

@Slf4j
@RequiredArgsConstructor
@Service
public class FcfsAnswerService {

private final StringRedisTemplate stringRedisTemplate;

public boolean judgeAnswer(Long eventSequence, String answer) {
String correctAnswer = stringRedisTemplate.opsForValue().get(FcfsUtil.answerFormatting(eventSequence.toString()));
if (correctAnswer == null) {
throw new FcfsEventException(ErrorCode.FCFS_EVENT_NOT_FOUND);
}
return correctAnswer.equals(answer);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@

import java.time.LocalDateTime;
import java.util.List;
import java.util.Random;
import java.util.Set;

@RequiredArgsConstructor
Expand All @@ -35,11 +36,7 @@ public class FcfsManageService {
@Transactional(readOnly = true)
public void registerFcfsEvents() {
List<FcfsEvent> events = fcfsEventRepository.findByStartTimeBetween(LocalDateTime.now(), LocalDateTime.now().plusDays(1));
events.forEach(event -> { // 당첨자 수, 마감 여부, 시작 시각을 저장
numberRedisTemplate.opsForValue().set(FcfsUtil.keyFormatting(event.getId().toString()), event.getParticipantCount().intValue());
booleanRedisTemplate.opsForValue().set(FcfsUtil.endFlagFormatting(event.getId().toString()), false);
stringRedisTemplate.opsForValue().set(FcfsUtil.startTimeFormatting(event.getId().toString()), event.getStartTime().toString());
});
events.forEach(this::prepareEventInfo);
}

// redis에 저장된 모든 선착순 이벤트의 당첨자 정보를 DB로 이관
Expand All @@ -60,22 +57,15 @@ public void registerWinners() {
FcfsEvent event = fcfsEventRepository.findById(Long.parseLong(eventId))
.orElseThrow(() -> new FcfsEventException(ErrorCode.FCFS_EVENT_NOT_FOUND));

List<EventUser> users = eventUserRepository.findAllById(
userIds.stream()
.map(Long::parseLong)
.toList());
List<EventUser> users = eventUserRepository.findAllByUserId(userIds.stream().toList());

List<FcfsEventWinningInfo> winningInfos = users
.stream()
.map(user -> FcfsEventWinningInfo.of(event, user))
.toList();

fcfsEventWinningInfoRepository.saveAll(winningInfos);

stringRedisTemplate.delete(FcfsUtil.startTimeFormatting(event.getId().toString()));
stringRedisTemplate.delete(FcfsUtil.winnerFormatting(event.getId().toString()));
numberRedisTemplate.delete(FcfsUtil.keyFormatting(eventId));
booleanRedisTemplate.delete(FcfsUtil.endFlagFormatting(eventId));
deleteEventInfo(eventId);
}
}

Expand All @@ -90,4 +80,22 @@ public List<ResponseFcfsWinnerDto> getFcfsWinnersInfo(Long eventSequence) {
.build())
.toList();
}

private void prepareEventInfo(FcfsEvent event) {
numberRedisTemplate.opsForValue().set(FcfsUtil.keyFormatting(event.getId().toString()), event.getParticipantCount().intValue());
booleanRedisTemplate.opsForValue().set(FcfsUtil.endFlagFormatting(event.getId().toString()), false);
stringRedisTemplate.opsForValue().set(FcfsUtil.startTimeFormatting(event.getId().toString()), event.getStartTime().toString());

// FIXME: 선착순 정답 생성 과정을 별도로 관리하는 것이 좋을 듯
// 현재 정책 상 1~4 중 하나의 숫자를 선정하여 현재 선착순 이벤트의 정답에 저장
int answer = new Random().nextInt(4) + 1;
stringRedisTemplate.opsForValue().set(FcfsUtil.answerFormatting(event.getId().toString()), String.valueOf(answer));
}

public void deleteEventInfo(String eventId) {
stringRedisTemplate.delete(FcfsUtil.startTimeFormatting(eventId));
stringRedisTemplate.delete(FcfsUtil.winnerFormatting(eventId));
numberRedisTemplate.delete(FcfsUtil.keyFormatting(eventId));
booleanRedisTemplate.delete(FcfsUtil.endFlagFormatting(eventId));
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -44,14 +44,14 @@ public boolean participate(Long eventSequence, String userId) {
throw new FcfsEventException(ErrorCode.INVALID_EVENT_TIME);
}

long timestamp = System.currentTimeMillis();
String script = "local count = redis.call('zcard', KEYS[1]) " +
"if count < tonumber(ARGV[1]) then " +
" redis.call('zadd', KEYS[1], ARGV[2], ARGV[3]) " +
" return redis.call('zcard', KEYS[1]) " +
"else " +
" return 0 " +
"end";
long timestamp = System.currentTimeMillis();
Long result = stringRedisTemplate.execute(
RedisScript.of(script, Long.class),
Collections.singletonList(FcfsUtil.winnerFormatting(eventSequence.toString())),
Expand Down
Loading

0 comments on commit aeb7330

Please sign in to comment.