-
Notifications
You must be signed in to change notification settings - Fork 0
Feat/vote 투표 관련 구현 #103
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Feat/vote 투표 관련 구현 #103
Changes from all commits
33cd5d7
0ed1529
aaaf4f4
7e477f2
16d87c7
4b7b777
04a5244
712cfec
90d65f1
a090bc0
ea80f9d
c087f51
a5de8a1
10983fa
a7c204d
a410c36
4f863f4
0aa08cb
75cafd9
a8cf1d2
1a3e8d3
6a8abdb
6e3ea7c
537d652
f101ded
622d5a8
811870b
f3be66f
b356061
f8269f3
2433c9d
2ecd352
85d9395
c10b9bb
b43f871
4940ee2
b4f7522
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,25 @@ | ||
| package com.ject.vs.common.exception; | ||
|
|
||
| import lombok.Getter; | ||
|
|
||
| @Getter | ||
| public class BusinessException extends RuntimeException { | ||
| private final ErrorCode errorCode; | ||
|
|
||
| public BusinessException(ErrorCode errorCode) { | ||
| super(errorCode.getMessage()); | ||
| this.errorCode = errorCode; | ||
| } | ||
|
|
||
| public String getErrorCode() { | ||
| return this.errorCode.getCode(); | ||
| } | ||
|
|
||
| public String getErrorMessage() { | ||
| return this.errorCode.getMessage(); | ||
| } | ||
|
|
||
| public Integer getStatusCode() { | ||
| return this.errorCode.getStatusCode(); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,7 @@ | ||
| package com.ject.vs.common.exception; | ||
|
|
||
| public interface ErrorCode { | ||
| String getCode(); | ||
| String getMessage(); | ||
| Integer getStatusCode(); | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,29 @@ | ||
| package com.ject.vs.common.exception; | ||
|
|
||
| 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; | ||
|
|
||
| @RestControllerAdvice | ||
| public class GlobalExceptionHandler { | ||
|
|
||
| @ExceptionHandler(BusinessException.class) | ||
| public ResponseEntity<ErrorResponse> handleBusiness(BusinessException e) { | ||
| return ResponseEntity.status(e.getStatusCode()) | ||
| .body(new ErrorResponse(e.getErrorCode(), e.getErrorMessage())); | ||
| } | ||
|
|
||
| @ExceptionHandler(MethodArgumentNotValidException.class) | ||
| public ResponseEntity<ErrorResponse> handleValidation(MethodArgumentNotValidException e) { | ||
| String message = e.getBindingResult().getFieldErrors().stream() | ||
| .map(FieldError::getDefaultMessage) | ||
| .findFirst() | ||
| .orElse("입력값이 올바르지 않습니다"); | ||
| return ResponseEntity.status(400).body(new ErrorResponse("INVALID_INPUT", message)); | ||
| } | ||
|
|
||
| public record ErrorResponse(String code, String message) { | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| package com.ject.vs.config; | ||
|
|
||
| import java.lang.annotation.ElementType; | ||
| import java.lang.annotation.Retention; | ||
| import java.lang.annotation.RetentionPolicy; | ||
| import java.lang.annotation.Target; | ||
|
|
||
| @Target(ElementType.PARAMETER) | ||
| @Retention(RetentionPolicy.RUNTIME) | ||
| public @interface AnonymousId { | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,61 @@ | ||
| package com.ject.vs.config; | ||
|
|
||
| import jakarta.servlet.http.Cookie; | ||
| import jakarta.servlet.http.HttpServletRequest; | ||
| import jakarta.servlet.http.HttpServletResponse; | ||
| import org.springframework.http.HttpHeaders; | ||
| import org.springframework.http.ResponseCookie; | ||
| import org.springframework.stereotype.Component; | ||
| import org.springframework.web.bind.support.WebDataBinderFactory; | ||
| import org.springframework.web.context.request.NativeWebRequest; | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
| import org.springframework.web.method.support.ModelAndViewContainer; | ||
|
|
||
| import java.time.Duration; | ||
| import java.util.Arrays; | ||
| import java.util.UUID; | ||
|
|
||
| @Component | ||
| public class AnonymousIdResolver implements HandlerMethodArgumentResolver { | ||
|
|
||
| private static final String COOKIE_NAME = "anonymous_id"; | ||
| private static final Duration MAX_AGE = Duration.ofDays(365); | ||
|
|
||
| @Override | ||
| public boolean supportsParameter(org.springframework.core.MethodParameter parameter) { | ||
| return parameter.hasParameterAnnotation(AnonymousId.class) | ||
| && parameter.getParameterType().equals(String.class); | ||
| } | ||
|
|
||
| @Override | ||
| public String resolveArgument(org.springframework.core.MethodParameter parameter, | ||
| ModelAndViewContainer mavContainer, | ||
| NativeWebRequest webRequest, | ||
| WebDataBinderFactory binderFactory) { | ||
| HttpServletRequest req = webRequest.getNativeRequest(HttpServletRequest.class); | ||
| HttpServletResponse res = webRequest.getNativeResponse(HttpServletResponse.class); | ||
|
|
||
| String existing = extractCookie(req, COOKIE_NAME); | ||
| if (existing != null) return existing; | ||
|
|
||
| String newId = UUID.randomUUID().toString(); | ||
| ResponseCookie cookie = ResponseCookie.from(COOKIE_NAME, newId) | ||
| .httpOnly(true) | ||
| .secure(true) | ||
| .sameSite("None") | ||
| .maxAge(MAX_AGE) | ||
| .path("/") | ||
| .build(); | ||
| res.addHeader(HttpHeaders.SET_COOKIE, cookie.toString()); | ||
| return newId; | ||
| } | ||
|
|
||
| private String extractCookie(HttpServletRequest req, String name) { | ||
| if (req == null || req.getCookies() == null) return null; | ||
| return Arrays.stream(req.getCookies()) | ||
| .filter(c -> name.equals(c.getName())) | ||
| .map(Cookie::getValue) | ||
| .findFirst() | ||
| .orElse(null); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,35 @@ | ||
| package com.ject.vs.config; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.scheduling.annotation.EnableAsync; | ||
| import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; | ||
|
|
||
| import java.util.concurrent.Executor; | ||
|
|
||
| @Configuration | ||
| @EnableAsync | ||
| public class AsyncConfig { | ||
|
|
||
| @Bean("voteCloseExecutor") | ||
| public Executor voteCloseExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(4); | ||
| executor.setQueueCapacity(100); | ||
| executor.setThreadNamePrefix("vote-close-"); | ||
| executor.initialize(); | ||
| return executor; | ||
| } | ||
|
|
||
| @Bean("aiInsightExecutor") | ||
| public Executor aiInsightExecutor() { | ||
| ThreadPoolTaskExecutor executor = new ThreadPoolTaskExecutor(); | ||
| executor.setCorePoolSize(2); | ||
| executor.setMaxPoolSize(4); | ||
| executor.setQueueCapacity(50); | ||
| executor.setThreadNamePrefix("ai-insight-"); | ||
| executor.initialize(); | ||
| return executor; | ||
|
Junhyukkkk marked this conversation as resolved.
|
||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,15 @@ | ||
| package com.ject.vs.config; | ||
|
|
||
| import org.springframework.context.annotation.Bean; | ||
| import org.springframework.context.annotation.Configuration; | ||
|
|
||
| import java.time.Clock; | ||
|
|
||
| @Configuration | ||
| public class ClockConfig { | ||
|
|
||
| @Bean | ||
| public Clock systemClock() { | ||
| return Clock.systemUTC(); | ||
| } | ||
|
Comment on lines
+12
to
+14
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 👍 |
||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,20 @@ | ||
| package com.ject.vs.config; | ||
|
|
||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.annotation.Configuration; | ||
| import org.springframework.web.method.support.HandlerMethodArgumentResolver; | ||
| import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; | ||
|
|
||
| import java.util.List; | ||
|
|
||
| @Configuration | ||
| @RequiredArgsConstructor | ||
| public class WebConfig implements WebMvcConfigurer { | ||
|
|
||
| private final AnonymousIdResolver anonymousIdResolver; | ||
|
|
||
| @Override | ||
| public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) { | ||
| resolvers.add(anonymousIdResolver); | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -6,7 +6,6 @@ | |
| import lombok.Getter; | ||
|
|
||
| import java.time.LocalDate; | ||
| import java.time.Year; | ||
|
|
||
| @Entity | ||
| @Getter | ||
|
|
@@ -15,6 +14,11 @@ public class User { | |
| @Id @GeneratedValue(strategy = GenerationType.IDENTITY) | ||
| private Long id; | ||
|
|
||
| private String sub; | ||
|
|
||
| private String gender; | ||
|
|
||
| private LocalDate birthDate; | ||
|
Comment on lines
+18
to
+21
Collaborator
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. TODO로 수정해야한다고 남겨주면 좋을 것 같네, 성윤님 작업 범위와 겹치는 것 같아서!
Member
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 좋습니다 |
||
| private String email; | ||
| // 아직 유저에 대한 정보 확정 아님 | ||
| private String nickname; | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,36 @@ | ||
| package com.ject.vs.vote.adapter.handler; | ||
|
|
||
| import com.ject.vs.vote.event.VoteEndedEvent; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.context.event.EventListener; | ||
| import org.springframework.scheduling.annotation.Async; | ||
| import org.springframework.stereotype.Component; | ||
|
|
||
| @Component | ||
| @RequiredArgsConstructor | ||
| public class VoteEventHandler { | ||
|
|
||
| /** | ||
| * VoteCloseScheduler 요기서 해당 이벤트가 발행됨\ | ||
| * | ||
| * EventListener는 동기로 동작해 그래서 처리량을 위해 async를 사용합니다. | ||
| * | ||
| * 다만 다음과 같은 고민할 부분이 존재합니다. | ||
| * | ||
| * 1. 비동기로 처리할 때, 유실되면 어떻게 처리할거에요? | ||
| * 2. 스케줄러에 의해 트리거 되는데, 1분 주기보다 처리 시간이 길어지면 같은 처리가 동시에 진행될 수 있어 보여요 어떻게 처리할거에요? | ||
| * 3. 멱등하게 어떻게 처리할 수 있을까요? | ||
| * | ||
| * | ||
| * 트랜잭션 아웃박스 패턴을 보면 좋을듯! | ||
| * 분산락을 사용할 수 있어보임 | ||
| * | ||
| * 아웃박스를 구현할 떄, 발행 기준으로 저장할래? 아니면 컨슈밍 기준으로 만들래? | ||
| */ | ||
| @EventListener(classes = VoteEndedEvent.class) | ||
| @Async("voteCloseExecutor") | ||
| public void handleVoteEnded(VoteEndedEvent event){ | ||
| // log 추가하면 좋아보임 | ||
| // ai 분석을 시작하면 좋아보임 | ||
| } | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| package com.ject.vs.vote.adapter.web; | ||
|
|
||
| import com.ject.vs.config.AnonymousId; | ||
| import com.ject.vs.vote.adapter.web.dto.FreeVotesResponse; | ||
| import com.ject.vs.vote.port.GuestFreeVoteService; | ||
| import lombok.RequiredArgsConstructor; | ||
| import org.springframework.web.bind.annotation.GetMapping; | ||
| import org.springframework.web.bind.annotation.RequestMapping; | ||
| import org.springframework.web.bind.annotation.RestController; | ||
|
|
||
| @RestController | ||
| @RequestMapping("/api/me") | ||
| @RequiredArgsConstructor | ||
| public class GuestFreeVoteController { | ||
|
|
||
| private final GuestFreeVoteService guestFreeVoteService; | ||
|
|
||
| @GetMapping("/free-votes") | ||
| public FreeVotesResponse getFreeVotes(@AnonymousId String anonymousId) { | ||
| return new FreeVotesResponse(guestFreeVoteService.remaining(anonymousId)); | ||
| } | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
기존에 사용자가 로그인해있어도 anonymous_id가 부여될 것 같아요.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
회원 도메인 구현 하고 있는 @KII1ua랑 같이 논의하고 처리할게요