Skip to content
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

DailyDefense 컨트롤러 작성 및 시험 타이머 기능 개발 #36

Merged
merged 45 commits into from
May 5, 2024
Merged
Show file tree
Hide file tree
Changes from 38 commits
Commits
Show all changes
45 commits
Select commit Hold shift + click to select a range
53768b2
feat: solved problemnumber set 반환
miiiinju1 Mar 30, 2024
fdd6973
feat: 사용자 로그인 여부에 따라 오늘의 문제 정보를 반환하는 service 및 테스트 개발
miiiinju1 Mar 30, 2024
bf2a2f4
feat: 오늘의 문제 시험 정보, 랭킹 정보 페이지로 반환하는 서비스
miiiinju1 Apr 1, 2024
ca91cc9
fix: RankUseCaseImpl 패키지 이동 및 AtomicLong long 캐스팅
miiiinju1 Apr 1, 2024
f90fe32
test: DailyDefenseInfoResponse Test 추가
miiiinju1 Apr 1, 2024
ae6ef73
refactor: DTO 생성 로직 Mapper로 분리
miiiinju1 Apr 2, 2024
8656861
feat: 오늘의 문제 정보 반환, 오늘의 문제 랭킹 Controller 및 테스트 작성
miiiinju1 Apr 2, 2024
0a57c33
:art: Mapper 기본 생성자 private 변경
miiiinju1 Apr 2, 2024
fcc7d74
:art: 필요없는 중괄호 삭제
miiiinju1 Apr 2, 2024
5ff52c0
:fire: Remove unused import 'StartDailyDefenseResponse'
miiiinju1 Apr 2, 2024
014182d
:art: Dailydefense mapper 생성
miiiinju1 Apr 2, 2024
ff0ea61
fix: @JsonInclude 어노테이션 필드로 이동
miiiinju1 Apr 3, 2024
4e6175c
fix: dailydefense 조회 시 problem fecth join
miiiinju1 Apr 3, 2024
2d9f64e
:sparkles: DailyDefense 시작 시 문제 content 포함 dto 변경
miiiinju1 Apr 7, 2024
3e3e868
:sparkles: 시험 시작 시 문제 본문 가져오게 구현, 테스트코드 작성
miiiinju1 Apr 8, 2024
cf4c7a0
fix: 응용서비스에서 Port 이용하게 변경
miiiinju1 Apr 8, 2024
45bb1f1
:bug: WebClient 정상적으로 mocking되도록 변경
miiiinju1 Apr 8, 2024
c21af05
:fire: Remove unused import
miiiinju1 Apr 8, 2024
c36ee7d
:fire: 사용하지 않는 throws 제거
miiiinju1 Apr 9, 2024
cccc8e5
:sparkles: Webclient retryWhen을 통해 재처리 로직 추가
miiiinju1 Apr 9, 2024
a7ba850
:recycle: Problem Content 책임에 따라 problem_information하위로 이동
miiiinju1 Apr 9, 2024
f31c451
:zap: TempCode hashmap enummap으로 변경
miiiinju1 Apr 9, 2024
42886fe
:fire: Remove unused import
miiiinju1 Apr 9, 2024
ddb9199
:art: 기본생성자 private으로 변경
miiiinju1 Apr 9, 2024
32cd24a
Google OAuth 적용 및 커스텀 예외 코드 추가 (#37)
aj4941 Apr 21, 2024
4965450
:sparkles: 시험 시작 시 문제 본문 가져오게 구현, 테스트코드 작성
miiiinju1 Apr 8, 2024
391bc83
:white_check_mark: Spring Security & WebMvcTest 충돌 해결
miiiinju1 Apr 22, 2024
57736cd
Merge branch 'dev' into feat/#30
miiiinju1 Apr 22, 2024
81758af
:fire: 충돌 해결
miiiinju1 Apr 22, 2024
d775630
:fire: Remove unused import
miiiinju1 Apr 22, 2024
1091e77
:sparkles: HandlerMethodArgumentResolver 추가 및 controller 반영
miiiinju1 Apr 22, 2024
2202b2b
:sparkles: SetAuthentication 로직 변경
miiiinju1 Apr 22, 2024
515a758
:art: GetDailydefenseInfo 로직 변경
miiiinju1 Apr 22, 2024
4e6852b
:art: 로그인 여부 관계없는 API로직 filter 반영
miiiinju1 Apr 23, 2024
bea3db7
:sparkles: 제한 시간 후 시험 자동 종료 로직 추가
miiiinju1 Apr 24, 2024
db548fc
:art: 도메인 서비스 구조 변경
miiiinju1 Apr 24, 2024
0a9891b
:art: ConcurrentHashMap 대신 구조적으로 타이머 등록 1회 보장
miiiinju1 Apr 26, 2024
d286429
:art: 롤백시 타이머 정상 제거를 위해 Timer Event 발행 방식 선택
miiiinju1 Apr 26, 2024
52a52e1
:white_check_mark: 롤백 테스트 추가
miiiinju1 Apr 29, 2024
340b587
:sparkles: RestDocs 추가 및 docstest 작성
miiiinju1 May 1, 2024
be50f0e
:memo: 오늘의 문제 랭킹 restdocs 추가
miiiinju1 May 1, 2024
fa1e930
:fire: Remove unused import
miiiinju1 May 1, 2024
8263edf
:fire: Remove unused import
miiiinju1 May 1, 2024
a6436aa
:white_check_mark: 테스트 환경 통합
miiiinju1 May 2, 2024
fb738f4
:art: DailyDefenseProblemStrategy로 이름 변경
miiiinju1 May 3, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 16 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -72,31 +72,41 @@ repositories {
}

dependencies {

// Spring Data Jpa
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'

// Spring Data Redis
implementation 'org.springframework.boot:spring-boot-starter-data-redis'

implementation 'org.springframework.boot:spring-boot-starter-oauth2-client'

// Spring Security
implementation 'org.springframework.boot:spring-boot-starter-security'
testImplementation 'org.springframework.security:spring-security-test'

// validation
implementation 'org.springframework.boot:spring-boot-starter-validation'

implementation 'org.springframework.boot:spring-boot-starter-web'

// WebFlux (WebClient)
implementation 'org.springframework.boot:spring-boot-starter-webflux'

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
// Spring Web
implementation 'org.springframework.boot:spring-boot-starter-web'

// JWT
implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.11.2'

runtimeOnly 'io.jsonwebtoken:jjwt-jackson:0.11.2'

// MariaDB
runtimeOnly 'org.mariadb.jdbc:mariadb-java-client'

// lombok
compileOnly 'org.projectlombok:lombok'
annotationProcessor 'org.projectlombok:lombok'

// Test Dependencies
testImplementation 'org.springframework.boot:spring-boot-starter-test'
testImplementation 'org.springframework.security:spring-security-test'
testImplementation 'com.h2database:h2'
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@

@Configuration
public class WebClientConfig {

@Bean
public WebClient webClient() {
return WebClient.builder().build();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package kr.co.morandi.backend.common.config;

import kr.co.morandi.backend.common.web.resolver.MemberHandlerMethodArgumentResolver;
import org.springframework.context.annotation.Bean;
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
public class WebMvcConfig implements WebMvcConfigurer {

@Bean
MemberHandlerMethodArgumentResolver memberHandlerMethodArgumentResolver() {
return new MemberHandlerMethodArgumentResolver();
}

@Override
public void addArgumentResolvers(List<HandlerMethodArgumentResolver> resolvers) {
resolvers.add(memberHandlerMethodArgumentResolver());
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,12 @@ public class MorandiException extends RuntimeException {
private final ErrorCode errorCode;

public MorandiException(ErrorCode errorCode) {
super(errorCode.getMessage());
this.errorCode = errorCode;
}

public MorandiException(ErrorCode errorCode, String message) {
super(message);
this.errorCode = errorCode;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,17 +7,13 @@
import kr.co.morandi.backend.member_management.infrastructure.config.cookie.utils.CookieUtils;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.ExceptionHandler;
import org.springframework.web.bind.annotation.RestControllerAdvice;
import org.springframework.web.servlet.NoHandlerFoundException;
import org.springframework.web.servlet.mvc.method.annotation.ResponseEntityExceptionHandler;

import java.net.URI;

import static kr.co.morandi.backend.common.exception.handler.exception.CommonErrorCode.INTERNAL_SERVER_ERROR;
import static kr.co.morandi.backend.member_management.infrastructure.config.jwt.constants.TokenType.REFRESH_TOKEN;

Expand All @@ -28,9 +24,6 @@ public class GlobalExceptionHandler extends ResponseEntityExceptionHandler {

private final CookieUtils cookieUtils;

@Value("${oauth2.signup-url}")
private String signupPath;

@ExceptionHandler(MorandiException.class)
public ResponseEntity<ErrorResponse> morandiExceptionHandler(MorandiException e) {
log.error(e.getErrorCode().name()+" : ", e.getErrorCode().getMessage() + " : ", e);
Expand All @@ -39,8 +32,7 @@ public ResponseEntity<ErrorResponse> morandiExceptionHandler(MorandiException e)
if (e.getErrorCode().getHttpStatus() == HttpStatus.UNAUTHORIZED) {
HttpHeaders headers = createUnauthorizedHeaders();

// 로그인 페이지로 리다이렉트
return new ResponseEntity<>(headers, HttpStatus.FOUND);
return new ResponseEntity<>(headers, HttpStatus.UNAUTHORIZED);
}

// 그 외의 에러가 발생한 경우
Expand All @@ -61,11 +53,9 @@ public ResponseEntity<String> handleAllException(Exception e) {
/**
* Unauthorized 에러가 발생한 경우
* Refresh Token 쿠키를 제거하고
* 로그인 페이지로 리다이렉트
*/
private HttpHeaders createUnauthorizedHeaders() {
HttpHeaders headers = new HttpHeaders();
headers.setLocation(URI.create(signupPath));
Cookie cookie = cookieUtils.removeCookie(REFRESH_TOKEN, null);
headers.add("Set-Cookie", cookie.toString());
return headers;
Expand Down
11 changes: 11 additions & 0 deletions src/main/java/kr/co/morandi/backend/common/web/MemberId.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package kr.co.morandi.backend.common.web;

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 MemberId {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,22 @@
package kr.co.morandi.backend.common.web.resolver;

import kr.co.morandi.backend.common.web.MemberId;
import kr.co.morandi.backend.member_management.infrastructure.config.security.utils.SecurityUtils;
import org.springframework.core.MethodParameter;
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;

public class MemberHandlerMethodArgumentResolver implements HandlerMethodArgumentResolver {
@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.getParameterAnnotation(MemberId.class) != null
&& parameter.getParameterType().equals(Long.class);
}

@Override
public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception {
return SecurityUtils.getCurrentMemberId();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package kr.co.morandi.backend.defense_information.application.dto.response;

import lombok.AccessLevel;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

import java.util.List;

@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class DailyDefenseInfoResponse {

private String defenseName;
private Integer problemCount;
private Long attemptCount;
private List<DailyDefenseProblemInfoResponse> problems;


@Builder
private DailyDefenseInfoResponse(String defenseName, Integer problemCount, Long attemptCount, List<DailyDefenseProblemInfoResponse> problems) {
this.defenseName = defenseName;
this.problemCount = problemCount;
this.attemptCount = attemptCount;
this.problems = problems;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
package kr.co.morandi.backend.defense_information.application.dto.response;

import com.fasterxml.jackson.annotation.JsonInclude;
import kr.co.morandi.backend.defense_information.domain.model.defense.ProblemTier;
import lombok.Builder;
import lombok.Getter;
import lombok.NoArgsConstructor;

@Getter
@NoArgsConstructor
public class DailyDefenseProblemInfoResponse {

private Long problemNumber;
private Long problemId;
private Long baekjoonProblemId;
private ProblemTier difficulty;
private Long solvedCount;
private Long submitCount;

@JsonInclude(JsonInclude.Include.NON_NULL)
private Boolean isSolved;

@Builder
private DailyDefenseProblemInfoResponse(Long problemNumber, Long problemId, Long baekjoonProblemId, ProblemTier difficulty, Long solvedCount, Long submitCount, Boolean isSolved) {
this.problemNumber = problemNumber;
this.problemId = problemId;
this.baekjoonProblemId = baekjoonProblemId;
this.difficulty = difficulty;
this.solvedCount = solvedCount;
this.submitCount = submitCount;
this.isSolved = isSolved;
}
}


Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package kr.co.morandi.backend.defense_information.application.mapper.dailydefense;

import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse;
import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense;
import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class DailyDefenseInfoMapper {

public static DailyDefenseInfoResponse fromNonAttempted(DailyDefense dailyDefense) {
return DailyDefenseInfoResponse.builder()
.defenseName(dailyDefense.getContentName())
.problemCount(dailyDefense.getProblemCount())
.attemptCount(dailyDefense.getAttemptCount())
.problems(DailyDefenseProblemInfoMapper.ofNonAttempted(dailyDefense.getDailyDefenseProblems()))
.build();
}

public static DailyDefenseInfoResponse ofAttempted(DailyDefense dailyDefense, DailyRecord dailyRecord) {
return DailyDefenseInfoResponse.builder()
.defenseName(dailyDefense.getContentName())
.problemCount(dailyDefense.getProblemCount())
.attemptCount(dailyDefense.getAttemptCount())
.problems(DailyDefenseProblemInfoMapper.ofAttempted(
dailyDefense.getDailyDefenseProblems(),
dailyRecord.getSolvedProblemNumbers())
)
.build();
}
}
Comment on lines +9 to +32
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Mapper 클래스를 따로 분리하는 것보다 DailyDefenseInfoResponse 객체 안에 두 개의 메서드를 만들어서 관리하는 것은 어떻게 생각하실까요?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

SRP 원칙에 따라 하나의 클래스가 하나의 역할을 하도록 Mapper로 구성하는 것도 좋아보여요!

Copy link
Member Author

@miiiinju1 miiiinju1 Apr 30, 2024

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

말씀하신 부분도 제가 정말 많이 고민해봤던 부분 중 하나인데
"시험 시작 시 응답" dto의 경우에는 변환 로직이 복잡했던 관계로 mapper를 도입했었지만

"오늘의 문제 정보 응답" dto는 비교적 간단하여 정적 팩토리 메소드로 구현할까 생각도 했었습니다. 하지만 비즈니스 로직에 따라 응답 로직이 달라지는 점이 존재하여 응답 dto에 여러 변환 로직이 들어가는 것이 적절하지 않을 것으로 생각하여 mapper도입을 결정했습니다.

p.s. 변환 메소드에서 단순히 필드를 get만 하는 비즈니스 로직일 경우 mapper를 도입하더라도 큰 효과를 보지 못할 것으로 생각합니다!

Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package kr.co.morandi.backend.defense_information.application.mapper.dailydefense;

import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseProblemInfoResponse;
import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefenseProblem;
import lombok.AccessLevel;
import lombok.NoArgsConstructor;

import java.util.List;
import java.util.Set;

@NoArgsConstructor(access = AccessLevel.PRIVATE)
public class DailyDefenseProblemInfoMapper {

public static List<DailyDefenseProblemInfoResponse> ofNonAttempted(List<DailyDefenseProblem> dailyDefenseProblems) {
return dailyDefenseProblems.stream().map(problem -> DailyDefenseProblemInfoResponse.builder()
.problemNumber(problem.getProblemNumber())
.problemId(problem.getProblem().getProblemId())
.baekjoonProblemId(problem.getProblem().getBaekjoonProblemId())
.difficulty(problem.getProblem().getProblemTier())
.solvedCount(problem.getSolvedCount())
.submitCount(problem.getSubmitCount())
.build()
).toList();
}
public static List<DailyDefenseProblemInfoResponse> ofAttempted(List<DailyDefenseProblem> dailyDefenseProblems, Set<Long> solvedProblemNumbers) {
return dailyDefenseProblems.stream().map(problem -> DailyDefenseProblemInfoResponse.builder()
.problemNumber(problem.getProblemNumber())
.problemId(problem.getProblem().getProblemId())
.baekjoonProblemId(problem.getProblem().getBaekjoonProblemId())
.difficulty(problem.getProblem().getProblemTier())
.solvedCount(problem.getSolvedCount())
.submitCount(problem.getSubmitCount())
.isSolved(solvedProblemNumbers.contains(problem.getProblemNumber()))
.build()
).toList();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
package kr.co.morandi.backend.defense_information.application.port.in;

import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse;

import java.time.LocalDateTime;

public interface DailyDefenseUseCase {
DailyDefenseInfoResponse getDailyDefenseInfo(Long memberId, LocalDateTime requestDateTime);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
package kr.co.morandi.backend.defense_information.application.service;

import kr.co.morandi.backend.defense_information.application.dto.response.DailyDefenseInfoResponse;
import kr.co.morandi.backend.defense_information.application.mapper.dailydefense.DailyDefenseInfoMapper;
import kr.co.morandi.backend.defense_information.application.port.in.DailyDefenseUseCase;
import kr.co.morandi.backend.defense_information.application.port.out.dailydefense.DailyDefensePort;
import kr.co.morandi.backend.defense_information.domain.model.dailydefense.DailyDefense;
import kr.co.morandi.backend.defense_record.application.port.out.dailyrecord.DailyRecordPort;
import kr.co.morandi.backend.defense_record.domain.model.dailydefense_record.DailyRecord;
import kr.co.morandi.backend.member_management.application.port.out.member.MemberPort;
import kr.co.morandi.backend.member_management.domain.model.member.Member;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;

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

import static kr.co.morandi.backend.defense_information.domain.model.defense.DefenseType.DAILY;

@Service
@Transactional(readOnly = true)
@RequiredArgsConstructor
public class DailyDefenseUseCaseImpl implements DailyDefenseUseCase {

private final MemberPort memberPort;
private final DailyDefensePort dailyDefensePort;
private final DailyRecordPort dailyRecordPort;

@Override
public DailyDefenseInfoResponse getDailyDefenseInfo(Long memberId, LocalDateTime requestDateTime) {
final DailyDefense dailyDefense = dailyDefensePort.findDailyDefense(DAILY, requestDateTime.toLocalDate());
/*
* 비로그인 상태인 경우
* */
if(memberId == null) {
return DailyDefenseInfoMapper.fromNonAttempted(dailyDefense);
}
/*
* 로그인 상태인 경우
* */
final Member member = memberPort.findMemberById(memberId);
Optional<DailyRecord> maybeDailyRecord = dailyRecordPort.findDailyRecord(member, requestDateTime.toLocalDate());

/*
* 시험 기록이 존재하는 경우
* */
if(maybeDailyRecord.isPresent()) {
DailyRecord dailyRecord = maybeDailyRecord.get();
return DailyDefenseInfoMapper.ofAttempted(dailyDefense, dailyRecord);
}
/*
* 시험 응시 기록이 없는 경우
* */
return DailyDefenseInfoMapper.fromNonAttempted(dailyDefense);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,9 @@ public abstract class Defense extends BaseEntity {
@Enumerated(EnumType.STRING)
private DefenseType defenseType;

public void increaseAttemptCount() {
++this.attemptCount;
}
public abstract LocalDateTime getEndTime(LocalDateTime startTime);
//팩토리 메소드 패턴
public Map<Long, Problem> getDefenseProblems(ProblemGenerationService problemGenerationService) {
Expand Down
Loading
Loading