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

[1 - 2단계 방탈출 예약 대기] 리브(김민주) 미션 제출합니다. #13

Merged
merged 42 commits into from
May 20, 2024
Merged
Changes from all commits
Commits
Show all changes
42 commits
Select commit Hold shift + click to select a range
3f4a417
migrate first mission code
Minjoo522 May 14, 2024
a938996
chore: 의존성 추가
Minjoo522 May 14, 2024
84f3c95
chore: yml을 properties로 변경
Minjoo522 May 14, 2024
20e8418
refactor: 토큰 만료기한 설정
Minjoo522 May 14, 2024
00b100a
feat: Member 엔티티에 JPA 도입
Minjoo522 May 14, 2024
4244018
feat: Reservation, ReservationTime, Theme 엔티티에 JPA 도입
Minjoo522 May 14, 2024
aec9a5a
feat: [2단계] 내 예약 목록 조회 기능 클라이언트 코드
woowabrie May 11, 2024
5c12370
feat: 유저 예약 조회 페이지 렌더링
Minjoo522 May 15, 2024
c420dcd
feat: 유저 예약 조회 api 구현
Minjoo522 May 15, 2024
4f9e9a3
style: 코드 포멧팅
Minjoo522 May 15, 2024
c224b2b
feat: Member equals, hashCode 재정의
Minjoo522 May 15, 2024
7f9ef62
chore: 불필요한 의존성 제거
Minjoo522 May 15, 2024
0e2303e
style: 불필요한 개행 제거 및 개행 추가
Minjoo522 May 15, 2024
2420b10
refactor: SpringBootTest random port 사용 및 추상 클래스로 중복 제거
Minjoo522 May 16, 2024
a067267
refactor: 변수명 명확하게 변경
Minjoo522 May 16, 2024
f038f01
refactor: Member 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
d1a8b3c
refactor: Reservation 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
9baf5b7
refactor: user Reservation 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
a4ed374
refactor: ReservationTime 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
b18ba89
refactor: ReservationStatus 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
476c202
refactor: Theme 정보 List가 아닌 Object 형식으로 응답
Minjoo522 May 16, 2024
ecd0398
refactor: List<ReservationStatus>를 담는 일급 객체 사용하도록 변경
Minjoo522 May 16, 2024
799af57
refactor: 예약 생성 관련 코드 하나로 통합
Minjoo522 May 16, 2024
dddb6f4
refactor: 예약된 테마 시간 JPQL 사용하지 않도록 변경
Minjoo522 May 16, 2024
59be832
refactor: 예약 관련 관리자 API 분리
Minjoo522 May 16, 2024
9f2d268
refactor: 회원 조회 관련 관리자만 접근 가능한 API로 변경
Minjoo522 May 16, 2024
7b94d74
test: 불필요한 테스트 삭제
Minjoo522 May 16, 2024
edf9e2f
refactor: 테스트 속도를 위해 @DirtiesContext 제거
Minjoo522 May 16, 2024
3706585
test: 불필요한 테스트 제거
Minjoo522 May 16, 2024
20351f7
style: 개행 추가
Minjoo522 May 16, 2024
69c9929
refactor: createReservation -> create 메서드 명 변경
Minjoo522 May 16, 2024
a06f5af
refactor: Member의 name, email, password 포장
Minjoo522 May 17, 2024
9a5323a
refactor: Member, Role 패키지 이동
Minjoo522 May 17, 2024
bfcac76
refactor: vo equals & hashCode 재정의
Minjoo522 May 17, 2024
a59e0a4
refactor: 예외 응답 객체 사용 및 커스텀 애플리케이션 예외 추상 클래스 생성
Minjoo522 May 18, 2024
92b5148
refactor: IllegalArgumentException -> 커스텀 예외로 변경
Minjoo522 May 18, 2024
5b3d5d1
refactor: 예외 객체가 다양한 자료형을 받을 수 있도록 변경
Minjoo522 May 18, 2024
92fbca0
refactor: id 유효성 검사 커스텀 어노테이션 생성 및 사용
Minjoo522 May 18, 2024
c6c744e
refactor: 유효성 검사 접근 제한자 private으로 변경
Minjoo522 May 18, 2024
29eee7f
refactor: 예약 유효성 검사 및 생성 메서드 이름 변경
Minjoo522 May 18, 2024
230f5e3
refactor: @Transactional 사용
Minjoo522 May 19, 2024
cd71c48
refactor: 메서드 이름 역할에 맞게 변경
Minjoo522 May 19, 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
40 changes: 40 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
## 요구사항 명세

- [x] localhost:8080/admin 요청 시 index.html 페이지가 응답한다.
- [x] /admin/reservation 요청 시 reservation-legacy.html 응답한다.
- [x] /reservations 요청 시 예약 목록을 응답한다.
- [x] 예약 페이지 요청 시 예약 목록을 조회하여 보여준다.
- [x] 예약을 추가한다.
- [x] 예약을 삭제한다.


- [x] 시간 생성 시 시작 시간에 유효하지 않은 값이 입력되었을 때
- [x] 시작 시간은 빈칸일 수 없다.
- [x] HH:mm 형식이어야 한다.

- [x] 예약 생성 시 예약자명, 날짜, 시간에 유효하지 않은 값이 입력 되었을 때
- [x] 예약자명은 빈칸일 수 없다.
- [x] 날짜는 빈칸일 수 없다.
- [x] 날짜는 YYYY-MM-dd 형식이어야 한다.
- [x] 시간에 대한 아이디는 정수여야 한다.

- [x] 특정 시간에 대한 예약이 존재할 때, 그 시간을 삭제할 수 없다.
- [x] 존재하는 시간에 대해서만 예약할 수 있다.

- [x] 지나간 날짜와 시간에 대한 예약 생성은 불가능하다.
- [x] 중복 예약은 불가능하다. (ex. 이미 4월 1일 10시에 예약이 되어있다면, 4월 1일 10시에 대한 예약을 생성할 수 없다)
- [x] 시간은 중복될 수 없다.

- [x] 모든 테마는 시작 시간이 동일하다.
- [x] 테마 이름은 빈칸일 수 없다.
- [x] 관리자가 테마를 관리할 수 있다.
- [x] 이미 예약 중인 테마는 삭제할 수 없다.
- [x] 존재하는 테마에 대해서만 예약할 수 있다.
- [x] 관리자가 방탈출 예약 시, 테마 정보를 포함할 수 있다.

- [x] 사용자가 날짜를 선택하면 테마를 조회할 수 있다.
- [x] 사용자가 테마를 선택하면 예약 가능한 시간을 조회할 수 있다.
- [x] 사용자는 예약자명을 입력할 수 있다.
- [x] 사용자가 예약할 수 있다.
- [x] 인기 테마 조회 기능을 추가합니다.
- [x] 최근 일주일을 기준으로 해당 기간 내에 방문하는 예약이 많은 테마 10개를 조회한다.
11 changes: 5 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
@@ -16,12 +16,11 @@ dependencies {
implementation 'org.springframework.boot:spring-boot-starter'
implementation 'org.springframework.boot:spring-boot-starter-web'
implementation 'org.springframework.boot:spring-boot-starter-thymeleaf'
implementation 'org.springframework.boot:spring-boot-starter-jdbc'

implementation 'io.jsonwebtoken:jjwt-api:0.11.2'
implementation 'io.jsonwebtoken:jjwt-impl:0.11.2'
implementation 'io.jsonwebtoken:jjwt-gson:0.11.2'

implementation 'org.springframework.boot:spring-boot-starter-validation'
implementation 'org.springframework.boot:spring-boot-starter-data-jpa'
implementation 'io.jsonwebtoken:jjwt-api:0.12.5'
implementation 'io.jsonwebtoken:jjwt-jackson:0.12.5'
runtimeOnly 'io.jsonwebtoken:jjwt-impl:0.12.5'
runtimeOnly 'com.h2database:h2'

testImplementation 'org.springframework.boot:spring-boot-starter-test'
37 changes: 37 additions & 0 deletions src/main/java/roomescape/auth/AdminAuthHandlerInterceptor.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
package roomescape.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.stereotype.Component;
import org.springframework.web.servlet.HandlerInterceptor;
import roomescape.domain.member.Member;
import roomescape.domain.member.Role;
import roomescape.exception.AuthenticationException;
import roomescape.service.auth.AuthService;
import roomescape.service.auth.TokenProvider;

@Component
public class AdminAuthHandlerInterceptor implements HandlerInterceptor {

private final AuthService authService;
private final TokenProvider tokenProvider;

public AdminAuthHandlerInterceptor(AuthService authService, TokenProvider tokenProvider) {
this.authService = authService;
this.tokenProvider = tokenProvider;
}

@Override
public boolean preHandle(HttpServletRequest request,
HttpServletResponse response,
Object handler) throws Exception {
Cookie[] cookies = request.getCookies();
String token = tokenProvider.extractTokenFromCookie(cookies);
Member member = authService.findMemberByToken(token);
if (member == null || !member.getRole().equals(Role.ADMIN)) {
throw new AuthenticationException("권한이 없습니다.");
}
return true;
}
}
11 changes: 11 additions & 0 deletions src/main/java/roomescape/auth/AuthenticatedMember.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package roomescape.auth;

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 AuthenticatedMember {
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
package roomescape.auth;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletRequest;
import org.springframework.core.MethodParameter;
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 roomescape.domain.member.Member;
import roomescape.exception.AuthenticationException;
import roomescape.service.auth.AuthService;
import roomescape.service.auth.TokenProvider;

@Component
public class AuthenticatedMemberArgumentResolver implements HandlerMethodArgumentResolver {

private final AuthService authService;
private final TokenProvider tokenProvider;

public AuthenticatedMemberArgumentResolver(AuthService authService, TokenProvider tokenProvider) {
this.authService = authService;
this.tokenProvider = tokenProvider;
}

@Override
public boolean supportsParameter(MethodParameter parameter) {
return parameter.hasParameterAnnotation(AuthenticatedMember.class)
&& Member.class.isAssignableFrom(parameter.getParameterType());
}

@Override
public Member resolveArgument(MethodParameter parameter,
ModelAndViewContainer mavContainer,
NativeWebRequest webRequest,
WebDataBinderFactory binderFactory) throws Exception {
HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest();
Cookie[] cookies = request.getCookies();
String token = tokenProvider.extractTokenFromCookie(cookies);

if ("".equals(token)) {
throw new AuthenticationException("로그인해 주세요");
}
return authService.findMemberByToken(token);
}
}
32 changes: 32 additions & 0 deletions src/main/java/roomescape/config/AuthWebConfig.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package roomescape.config;

import java.util.List;
import org.springframework.context.annotation.Configuration;
import org.springframework.web.method.support.HandlerMethodArgumentResolver;
import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
import roomescape.auth.AdminAuthHandlerInterceptor;
import roomescape.auth.AuthenticatedMemberArgumentResolver;

@Configuration
public class AuthWebConfig implements WebMvcConfigurer {

private final AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver;
private final AdminAuthHandlerInterceptor adminAuthHandlerInterceptor;

public AuthWebConfig(AuthenticatedMemberArgumentResolver authenticatedMemberArgumentResolver,
AdminAuthHandlerInterceptor adminAuthHandlerInterceptor) {
this.authenticatedMemberArgumentResolver = authenticatedMemberArgumentResolver;
this.adminAuthHandlerInterceptor = adminAuthHandlerInterceptor;
}

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

@Override
public void addInterceptors(InterceptorRegistry registry) {
registry.addInterceptor(adminAuthHandlerInterceptor).addPathPatterns("/admin/**", "/members");
}
}
49 changes: 49 additions & 0 deletions src/main/java/roomescape/controller/api/AuthApiController.java
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
package roomescape.controller.api;

import jakarta.servlet.http.Cookie;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import roomescape.auth.AuthenticatedMember;
import roomescape.domain.member.Member;
import roomescape.service.auth.AuthService;
import roomescape.service.dto.request.LoginRequest;
import roomescape.service.dto.response.member.MemberIdAndNameResponse;

@RestController
public class AuthApiController {

private final AuthService authService;

public AuthApiController(AuthService authService) {
this.authService = authService;
}

@GetMapping("/login/check")
public ResponseEntity<MemberIdAndNameResponse> getMemberLoginInfo(@AuthenticatedMember Member member) {
return ResponseEntity.ok(new MemberIdAndNameResponse(member.getId(), member.getName().getValue()));
}

@PostMapping("/login")
public ResponseEntity<Void> login(@RequestBody @Valid LoginRequest request, HttpServletResponse response) {
String accessToken = authService.login(request);
Cookie cookie = new Cookie("token", accessToken);
cookie.setHttpOnly(true);
cookie.setPath("/");
response.addCookie(cookie);
return ResponseEntity.ok().build();
}

@PostMapping("/logout")
public ResponseEntity<Void> logout(HttpServletResponse response) {
Cookie cookie = new Cookie("token", null);
cookie.setPath("/");
cookie.setMaxAge(0);
response.addCookie(cookie);
return ResponseEntity.ok().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
package roomescape.controller.api;

import jakarta.validation.Valid;
import java.net.URI;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RestController;
import roomescape.auth.AuthenticatedMember;
import roomescape.controller.api.validator.IdPositive;
import roomescape.domain.Reservation;
import roomescape.domain.member.Member;
import roomescape.service.dto.request.ReservationSaveRequest;
import roomescape.service.dto.response.reservation.ReservationResponse;
import roomescape.service.dto.response.reservation.UserReservationResponses;
import roomescape.service.reservation.ReservationCreateService;
import roomescape.service.reservation.ReservationDeleteService;
import roomescape.service.reservation.ReservationFindService;

@Validated
@RestController
public class ReservationApiController {

private final ReservationFindService reservationFindService;
private final ReservationCreateService reservationCreateService;
private final ReservationDeleteService reservationDeleteService;

public ReservationApiController(ReservationFindService reservationFindService,
ReservationCreateService reservationCreateService,
ReservationDeleteService reservationDeleteService) {
this.reservationFindService = reservationFindService;
this.reservationCreateService = reservationCreateService;
this.reservationDeleteService = reservationDeleteService;
}

@GetMapping("/reservations-mine")
public ResponseEntity<UserReservationResponses> getUserReservations(@AuthenticatedMember Member member) {
List<Reservation> userReservations = reservationFindService.findUserReservations(member.getId());
return ResponseEntity.ok(UserReservationResponses.from(userReservations));
}

@PostMapping("/reservations")
public ResponseEntity<ReservationResponse> addReservation(@RequestBody @Valid ReservationSaveRequest request,
@AuthenticatedMember Member member) {
Reservation newReservation = reservationCreateService.create(request, member);
return ResponseEntity.created(URI.create("/reservations/" + newReservation.getId()))
.body(new ReservationResponse(newReservation));
}

@DeleteMapping("/reservations/{reservationId}")
public ResponseEntity<Void> deleteReservation(@PathVariable
@IdPositive long reservationId) {
reservationDeleteService.deleteReservation(reservationId);
return ResponseEntity.noContent().build();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
package roomescape.controller.api;

import jakarta.validation.Valid;
import jakarta.validation.constraints.Positive;
import java.net.URI;
import java.time.LocalDate;
import java.util.List;
import org.springframework.http.ResponseEntity;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import roomescape.controller.api.validator.IdPositive;
import roomescape.domain.ReservationStatuses;
import roomescape.domain.ReservationTime;
import roomescape.service.dto.request.ReservationTimeSaveRequest;
import roomescape.service.dto.response.reservationTime.ReservationStatusResponses;
import roomescape.service.dto.response.reservationTime.ReservationTimeResponse;
import roomescape.service.dto.response.reservationTime.ReservationTimeResponses;
import roomescape.service.reservationtime.ReservationTimeCreateService;
import roomescape.service.reservationtime.ReservationTimeDeleteService;
import roomescape.service.reservationtime.ReservationTimeFindService;

@Validated
@RestController
public class ReservationTimeApiController {

private final ReservationTimeCreateService reservationTimeCreateService;
private final ReservationTimeFindService reservationTimeFindService;
private final ReservationTimeDeleteService reservationTimeDeleteService;

public ReservationTimeApiController(ReservationTimeCreateService reservationTimeCreateService,
ReservationTimeFindService reservationTimeFindService,
ReservationTimeDeleteService reservationTimeDeleteService) {
this.reservationTimeCreateService = reservationTimeCreateService;
this.reservationTimeFindService = reservationTimeFindService;
this.reservationTimeDeleteService = reservationTimeDeleteService;
}

@GetMapping("/times")
public ResponseEntity<ReservationTimeResponses> getReservationTimes() {
List<ReservationTime> reservationTimes = reservationTimeFindService.findReservationTimes();
return ResponseEntity.ok(ReservationTimeResponses.from(reservationTimes));
}

@GetMapping("/times/available")
public ResponseEntity<ReservationStatusResponses> getReservationTimesIsBooked(
@RequestParam LocalDate date,
@RequestParam @Positive(message = "1 이상의 값만 입력해주세요.") long themeId) {
ReservationStatuses reservationStatus = reservationTimeFindService.findReservationStatuses(date, themeId);
return ResponseEntity.ok(ReservationStatusResponses.from(reservationStatus));
}

@PostMapping("/times")
public ResponseEntity<ReservationTimeResponse> addReservationTime(
@RequestBody @Valid ReservationTimeSaveRequest request) {
ReservationTime reservationTime = reservationTimeCreateService.createReservationTime(request);
return ResponseEntity.created(URI.create("times/" + reservationTime.getId()))
.body(new ReservationTimeResponse(reservationTime));
}

@DeleteMapping("/times/{timeId}")
public ResponseEntity<Void> deleteReservationTime(@PathVariable
@IdPositive long timeId) {
reservationTimeDeleteService.deleteReservationTime(timeId);
return ResponseEntity.noContent().build();
}
}
Loading