diff --git a/.gitignore b/.gitignore index c2065bc26..7f180798d 100644 --- a/.gitignore +++ b/.gitignore @@ -35,3 +35,5 @@ out/ ### VS Code ### .vscode/ + +**/application-key.properties diff --git a/build.gradle b/build.gradle index 8d52aebc6..3551ce1ca 100644 --- a/build.gradle +++ b/build.gradle @@ -15,16 +15,20 @@ repositories { dependencies { 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 'org.springframework.boot:spring-boot-starter-data-jpa' implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0' - implementation 'io.jsonwebtoken:jjwt-api:0.11.2' implementation 'io.jsonwebtoken:jjwt-impl:0.11.2' implementation 'io.jsonwebtoken:jjwt-gson:0.11.2' - testImplementation 'org.springframework.boot:spring-boot-starter-test' + testImplementation 'org.springframework.boot:spring-boot-starter-data-jpa' testImplementation 'io.rest-assured:rest-assured:5.3.1' + testImplementation 'com.h2database:h2' + testImplementation 'org.springframework.boot:spring-boot-starter-test' + + + runtimeOnly 'com.h2database:h2' } diff --git a/deploy.sh b/deploy.sh new file mode 100644 index 000000000..9b3a6533d --- /dev/null +++ b/deploy.sh @@ -0,0 +1,42 @@ +#!/bin/bash + +echo "==========================================" +echo "배포 시작" +echo "==========================================" + +echo ">>> Git Pull" +git pull origin main + +if [ $? -ne 0 ]; then + echo "Git Pull 실패" + exit 1 +fi + +echo ">>> 프로젝트 빌드 시작" +./gradlew clean build + +if [ $? -ne 0 ]; then + echo "빌드 실패" + exit 1 +fi + +echo ">>> 실행 중인 애플리케이션 확인" +CURRENT_PID=$(pgrep -f roomescape) + +if [ -z "$CURRENT_PID" ]; then + echo ">>> 실행 중인 애플리케이션이 없습니다." +else + echo ">>> 애플리케이션 종료 (PID: $CURRENT_PID)" + kill -15 $CURRENT_PID + sleep 5 +fi + +echo ">>> 새 애플리케이션 실행" +nohup java -jar build/libs/roomescape-0.0.1-SNAPSHOT.jar > application.log 2>&1 & + +sleep 3 + +NEW_PID=$(pgrep -f roomescape) +echo ">>> 배포 완료 (PID: $NEW_PID)" + +echo "==========================================" diff --git a/src/main/java/auth/JwtTokenProvider.java b/src/main/java/auth/JwtTokenProvider.java new file mode 100644 index 000000000..6ba363c78 --- /dev/null +++ b/src/main/java/auth/JwtTokenProvider.java @@ -0,0 +1,50 @@ +package auth; + +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtException; +import io.jsonwebtoken.Jwts; +import io.jsonwebtoken.SignatureAlgorithm; + +import java.util.Date; + + +public class JwtTokenProvider { + private final String secretKey; + private final long validityInMilliseconds; + + public JwtTokenProvider(String secretKey, long validityInMilliseconds) { + this.secretKey = secretKey; + this.validityInMilliseconds = validityInMilliseconds; + } + + public String createToken(String payload) { + Claims claims = Jwts.claims().setSubject(payload); + Date now = new Date(); + Date validity = new Date(now.getTime() + validityInMilliseconds); + + return Jwts.builder() + .setClaims(claims) + .setIssuedAt(now) + .setExpiration(validity) + .signWith(SignatureAlgorithm.HS256, secretKey) + .compact(); + } + + public String getPayload(String token) { + return Jwts.parser().setSigningKey(secretKey).parseClaimsJws(token).getBody().getSubject(); + } + + public boolean validateToken(String token) { + try { + Jwts.parser() + .setSigningKey(secretKey) + .parseClaimsJws(token); + return true; + + } catch (JwtException | IllegalArgumentException e) { + + return false; + } + } +} + diff --git a/src/main/java/roomescape/ExceptionController.java b/src/main/java/roomescape/ExceptionController.java deleted file mode 100644 index 4e2450f9e..000000000 --- a/src/main/java/roomescape/ExceptionController.java +++ /dev/null @@ -1,14 +0,0 @@ -package roomescape; - -import org.springframework.http.ResponseEntity; -import org.springframework.web.bind.annotation.ControllerAdvice; -import org.springframework.web.bind.annotation.ExceptionHandler; - -@ControllerAdvice -public class ExceptionController { - @ExceptionHandler(Exception.class) - public ResponseEntity handleRuntimeException(Exception e) { - e.printStackTrace(); - return ResponseEntity.badRequest().build(); - } -} diff --git a/src/main/java/roomescape/Loader/ProductionDataLoader.java b/src/main/java/roomescape/Loader/ProductionDataLoader.java new file mode 100644 index 000000000..ea0933aca --- /dev/null +++ b/src/main/java/roomescape/Loader/ProductionDataLoader.java @@ -0,0 +1,75 @@ +package roomescape.Loader; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import roomescape.member.Member; +import roomescape.member.MemberRepository; +import roomescape.member.Role; +import roomescape.reservation.ReservationRepository; +import roomescape.theme.Theme; +import roomescape.theme.ThemeRepository; +import roomescape.time.Time; +import roomescape.time.TimeRepository; + +@Component +public class ProductionDataLoader implements CommandLineRunner { + + private final MemberRepository memberRepository; + private final ThemeRepository themeRepository; + private final TimeRepository timeRepository; + private final ReservationRepository reservationRepository; + + public ProductionDataLoader( + MemberRepository memberRepository, + ThemeRepository themeRepository, + TimeRepository timeRepository, + ReservationRepository reservationRepository + ) { + this.memberRepository = memberRepository; + this.themeRepository = themeRepository; + this.timeRepository = timeRepository; + this.reservationRepository = reservationRepository; + } + + @Override + @Transactional + public void run(String... args) throws Exception { + // 1. 관리자 계정 생성 + if (memberRepository.findByEmail("admin").isEmpty()) { + Member admin = new Member("admin", "admin", "admin", Role.ADMIN); + memberRepository.save(admin); + System.out.println("관리자 계정이 생성되었습니다."); + } + + // 2. 테마 데이터 생성 + if (themeRepository.count() == 0) { + Theme theme1 = new Theme("테마1", "테마1입니다."); + Theme theme2 = new Theme("테마2", "테마2입니다."); + Theme theme3 = new Theme("테마3", "테마3입니다."); + + themeRepository.save(theme1); + themeRepository.save(theme2); + themeRepository.save(theme3); + System.out.println("테마 데이터가 생성되었습니다."); + } + + // 3. 시간 데이터 생성 + if (timeRepository.count() == 0) { + Time time1 = new Time("10:00"); + Time time2 = new Time("12:00"); + Time time3 = new Time("14:00"); + Time time4 = new Time("16:00"); + Time time5 = new Time("18:00"); + Time time6 = new Time("20:00"); + + timeRepository.save(time1); + timeRepository.save(time2); + timeRepository.save(time3); + timeRepository.save(time4); + timeRepository.save(time5); + timeRepository.save(time6); + System.out.println("시간 데이터가 생성되었습니다."); + } + } +} diff --git a/src/main/java/roomescape/Loader/TestDataLoader.java b/src/main/java/roomescape/Loader/TestDataLoader.java new file mode 100644 index 000000000..b4f6a34f7 --- /dev/null +++ b/src/main/java/roomescape/Loader/TestDataLoader.java @@ -0,0 +1,108 @@ +package roomescape.Loader; + +import org.springframework.boot.CommandLineRunner; +import org.springframework.context.annotation.Profile; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; +import roomescape.member.Member; +import roomescape.member.MemberRepository; +import roomescape.member.Role; +import roomescape.reservation.Reservation; +import roomescape.reservation.ReservationRepository; +import roomescape.theme.Theme; +import roomescape.theme.ThemeRepository; +import roomescape.time.Time; +import roomescape.time.TimeRepository; + +@Profile("test") +@Component +public class TestDataLoader implements CommandLineRunner { + + private final MemberRepository memberRepository; + private final ThemeRepository themeRepository; + private final TimeRepository timeRepository; + private final ReservationRepository reservationRepository; + + public TestDataLoader( + MemberRepository memberRepository, + ThemeRepository themeRepository, + TimeRepository timeRepository, + ReservationRepository reservationRepository + ) { + this.memberRepository = memberRepository; + this.themeRepository = themeRepository; + this.timeRepository = timeRepository; + this.reservationRepository = reservationRepository; + } + + @Override + @Transactional + public void run(String... args) throws Exception { + // 1. 관리자 계정 생성 + if (memberRepository.findByEmail("admin").isEmpty()) { + Member admin = new Member("admin", "admin", "admin", Role.ADMIN); + memberRepository.save(admin); + System.out.println("관리자 계정이 생성되었습니다."); + } + + // 2. 테마 데이터 생성 + if (themeRepository.count() == 0) { + Theme theme1 = new Theme("테마1", "테마1입니다."); + Theme theme2 = new Theme("테마2", "테마2입니다."); + Theme theme3 = new Theme("테마3", "테마3입니다."); + + themeRepository.save(theme1); + themeRepository.save(theme2); + themeRepository.save(theme3); + System.out.println("테마 데이터가 생성되었습니다."); + } + + // 3. 시간 데이터 생성 + if (timeRepository.count() == 0) { + Time time1 = new Time("10:00"); + Time time2 = new Time("12:00"); + Time time3 = new Time("14:00"); + Time time4 = new Time("16:00"); + Time time5 = new Time("18:00"); + Time time6 = new Time("20:00"); + + timeRepository.save(time1); + timeRepository.save(time2); + timeRepository.save(time3); + timeRepository.save(time4); + timeRepository.save(time5); + timeRepository.save(time6); + System.out.println("시간 데이터가 생성되었습니다."); + } + + if (reservationRepository.count() == 0) { + Member admin = memberRepository.findByEmail("admin") + .orElseThrow(() -> new RuntimeException("Admin not found")); + + Time time1 = timeRepository.findById(1L).orElseThrow(); + Time time2 = timeRepository.findById(2L).orElseThrow(); + Time time3 = timeRepository.findById(3L).orElseThrow(); + + Theme theme1 = themeRepository.findById(1L).orElseThrow(); + Theme theme2 = themeRepository.findById(2L).orElseThrow(); + Theme theme3 = themeRepository.findById(3L).orElseThrow(); + + Reservation reservation1 = new Reservation("", "2024-03-01", time1, theme1, admin); + Reservation reservation2 = new Reservation("", "2024-03-01", time2, theme2, admin); + Reservation reservation3 = new Reservation("", "2024-03-01", time3, theme3, admin); + + reservationRepository.save(reservation1); + reservationRepository.save(reservation2); + reservationRepository.save(reservation3); + + Reservation reservation4 = new Reservation("브라운", "2024-03-01", time1, theme2); + + reservationRepository.save(reservation4); + System.out.println("예약 데이터가 생성되었습니다."); + } + + System.out.println("초기 데이터 로딩이 완료되었습니다."); + } + + +} diff --git a/src/main/java/roomescape/exception/AuthenticationException.java b/src/main/java/roomescape/exception/AuthenticationException.java new file mode 100644 index 000000000..d7cc72c17 --- /dev/null +++ b/src/main/java/roomescape/exception/AuthenticationException.java @@ -0,0 +1,8 @@ +package roomescape.exception; + +public class AuthenticationException extends RuntimeException { + + public AuthenticationException(String message) { + super(message); + } +} diff --git a/src/main/java/roomescape/exception/ErrorResponse.java b/src/main/java/roomescape/exception/ErrorResponse.java new file mode 100644 index 000000000..ebe97cc4a --- /dev/null +++ b/src/main/java/roomescape/exception/ErrorResponse.java @@ -0,0 +1,4 @@ +package roomescape.exception; + +public record ErrorResponse(String message) { +} diff --git a/src/main/java/roomescape/exception/GlobalExceptionHandler.java b/src/main/java/roomescape/exception/GlobalExceptionHandler.java new file mode 100644 index 000000000..0abe88223 --- /dev/null +++ b/src/main/java/roomescape/exception/GlobalExceptionHandler.java @@ -0,0 +1,36 @@ +package roomescape.exception; + +import io.jsonwebtoken.ExpiredJwtException; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.ControllerAdvice; +import org.springframework.web.bind.annotation.ExceptionHandler; + +@ControllerAdvice +public class GlobalExceptionHandler { + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return ResponseEntity.badRequest() + .body(new ErrorResponse(e.getMessage())); + } + + @ExceptionHandler(AuthenticationException.class) + public ResponseEntity handleAuthenticationException(AuthenticationException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(e.getMessage()); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity handleException(Exception e) { + return ResponseEntity.internalServerError() + .body(new ErrorResponse("오류가 발생했습니다.")); + } + + @ExceptionHandler(ExpiredJwtException.class) + public ResponseEntity handleExpiredJwtException(ExpiredJwtException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse("토큰 만료 시간 초과")); + } +} + diff --git a/src/main/java/roomescape/infrastructure/AuthMember.java b/src/main/java/roomescape/infrastructure/AuthMember.java new file mode 100644 index 000000000..0ff90e6d5 --- /dev/null +++ b/src/main/java/roomescape/infrastructure/AuthMember.java @@ -0,0 +1,11 @@ +package roomescape.infrastructure; + +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 AuthMember { +} diff --git a/src/main/java/roomescape/infrastructure/AuthService.java b/src/main/java/roomescape/infrastructure/AuthService.java new file mode 100644 index 000000000..29a4c9869 --- /dev/null +++ b/src/main/java/roomescape/infrastructure/AuthService.java @@ -0,0 +1,44 @@ +package roomescape.infrastructure; + +import auth.JwtTokenProvider; +import jakarta.servlet.http.Cookie; +import jakarta.servlet.http.HttpServletRequest; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import roomescape.exception.AuthenticationException; +import roomescape.member.Member; +import roomescape.member.MemberService; + +@Service +@Transactional(readOnly = true) +public class AuthService { + private final JwtTokenProvider jwtTokenProvider; + private final MemberService memberService; + + public AuthService(JwtTokenProvider jwtTokenProvider, MemberService memberService) { + this.jwtTokenProvider = jwtTokenProvider; + this.memberService = memberService; + } + + public Member extractMember(HttpServletRequest request) { + String token = extractTokenFromCookie(request); + + if (!jwtTokenProvider.validateToken(token)) { + throw new AuthenticationException("인증되지 않은 사용자입니다."); + } + + return memberService.findByToken(token); + } + + private String extractTokenFromCookie(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies != null) { + for (Cookie cookie : cookies) { + if ("token".equals(cookie.getName())) { + return cookie.getValue(); + } + } + } + return null; + } +} diff --git a/src/main/java/roomescape/infrastructure/JwtConfig.java b/src/main/java/roomescape/infrastructure/JwtConfig.java new file mode 100644 index 000000000..9895242e9 --- /dev/null +++ b/src/main/java/roomescape/infrastructure/JwtConfig.java @@ -0,0 +1,21 @@ +package roomescape.infrastructure; + + +import auth.JwtTokenProvider; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; + +@Configuration +public class JwtConfig { + @Value("${security.jwt.token.secret-key}") + private String secretKey; + @Value("${security.jwt.token.expire-length:3600000}") + private long validityInMilliseconds; + + @Bean + public JwtTokenProvider jwtTokenProvider() { + return new JwtTokenProvider(secretKey, validityInMilliseconds); + } + +} diff --git a/src/main/java/roomescape/member/Member.java b/src/main/java/roomescape/member/Member.java index 903aaa9b0..03949758a 100644 --- a/src/main/java/roomescape/member/Member.java +++ b/src/main/java/roomescape/member/Member.java @@ -1,20 +1,48 @@ package roomescape.member; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.EnumType; +import jakarta.persistence.Enumerated; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "member") public class Member { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @Column(nullable = false) private String name; + + @Column(nullable = false, unique = true) private String email; + + @Column(nullable = false) private String password; - private String role; - public Member(Long id, String name, String email, String role) { + @Enumerated(EnumType.STRING) // DB에 "USER" 문자열 그대로 저장되도록 설정 + @Column(nullable = false) + private Role role; + + @Column(name = "deleted") + private boolean deleted = false; + + protected Member() { + } + + public Member(Long id, String name, String email, Role role) { this.id = id; this.name = name; this.email = email; this.role = role; } - public Member(String name, String email, String password, String role) { + public Member(String name, String email, String password, Role role) { this.name = name; this.email = email; this.password = password; @@ -33,11 +61,7 @@ public String getEmail() { return email; } - public String getPassword() { - return password; - } - public String getRole() { - return role; + return role.name(); } } diff --git a/src/main/java/roomescape/member/MemberController.java b/src/main/java/roomescape/member/MemberController.java index 881ae5e0d..851c177ae 100644 --- a/src/main/java/roomescape/member/MemberController.java +++ b/src/main/java/roomescape/member/MemberController.java @@ -1,24 +1,52 @@ package roomescape.member; import jakarta.servlet.http.Cookie; -import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; 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.infrastructure.AuthMember; import java.net.URI; @RestController public class MemberController { - private MemberService memberService; + private final MemberService memberService; public MemberController(MemberService memberService) { this.memberService = memberService; } + @PostMapping("/login") + public ResponseEntity login( + @RequestBody MemberRequest memberRequest, + HttpServletResponse response) { + + String tokenValue = memberService.login(memberRequest); + + Cookie cookie = new Cookie("token", tokenValue); + cookie.setHttpOnly(true); + cookie.setPath("/"); + cookie.setMaxAge(3600); + response.addCookie(cookie); + + return ResponseEntity.ok() + .header("Keep-Alive", "timeout=60") + .build(); + } + + @GetMapping("/login/check") + public ResponseEntity check( + @AuthMember Member member + ) { + return ResponseEntity.ok() + .header("Connection", "keep-alive") + .header("Keep-Alive", "timeout=60") + .body(new MemberResponse(null, member.getName(), null)); + } + @PostMapping("/members") public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) { MemberResponse member = memberService.createMember(memberRequest); diff --git a/src/main/java/roomescape/member/MemberDao.java b/src/main/java/roomescape/member/MemberDao.java deleted file mode 100644 index 81f77f4cd..000000000 --- a/src/main/java/roomescape/member/MemberDao.java +++ /dev/null @@ -1,55 +0,0 @@ -package roomescape.member; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; - -@Repository -public class MemberDao { - private JdbcTemplate jdbcTemplate; - - public MemberDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - public Member save(Member member) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - var ps = connection.prepareStatement("INSERT INTO member(name, email, password, role) VALUES (?, ?, ?, ?)", new String[]{"id"}); - ps.setString(1, member.getName()); - ps.setString(2, member.getEmail()); - ps.setString(3, member.getPassword()); - ps.setString(4, member.getRole()); - return ps; - }, keyHolder); - - return new Member(keyHolder.getKey().longValue(), member.getName(), member.getEmail(), "USER"); - } - - public Member findByEmailAndPassword(String email, String password) { - return jdbcTemplate.queryForObject( - "SELECT id, name, email, role FROM member WHERE email = ? AND password = ?", - (rs, rowNum) -> new Member( - rs.getLong("id"), - rs.getString("name"), - rs.getString("email"), - rs.getString("role") - ), - email, password - ); - } - - public Member findByName(String name) { - return jdbcTemplate.queryForObject( - "SELECT id, name, email, role FROM member WHERE name = ?", - (rs, rowNum) -> new Member( - rs.getLong("id"), - rs.getString("name"), - rs.getString("email"), - rs.getString("role") - ), - name - ); - } -} diff --git a/src/main/java/roomescape/member/MemberRepository.java b/src/main/java/roomescape/member/MemberRepository.java new file mode 100644 index 000000000..6aa1ca949 --- /dev/null +++ b/src/main/java/roomescape/member/MemberRepository.java @@ -0,0 +1,14 @@ +package roomescape.member; + +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.Optional; + + +public interface MemberRepository extends JpaRepository { + + Optional findByEmailAndPassword(String email, String password); + + Optional findByEmail(String email); + +} diff --git a/src/main/java/roomescape/member/MemberRequest.java b/src/main/java/roomescape/member/MemberRequest.java index cafb79f14..5549b763f 100644 --- a/src/main/java/roomescape/member/MemberRequest.java +++ b/src/main/java/roomescape/member/MemberRequest.java @@ -16,4 +16,4 @@ public String getEmail() { public String getPassword() { return password; } -} +} \ No newline at end of file diff --git a/src/main/java/roomescape/member/MemberResponse.java b/src/main/java/roomescape/member/MemberResponse.java index b9fa3b97a..35112e5dc 100644 --- a/src/main/java/roomescape/member/MemberResponse.java +++ b/src/main/java/roomescape/member/MemberResponse.java @@ -1,5 +1,8 @@ package roomescape.member; +import com.fasterxml.jackson.annotation.JsonInclude; + +@JsonInclude(JsonInclude.Include.NON_NULL) public class MemberResponse { private Long id; private String name; @@ -18,8 +21,4 @@ public Long getId() { public String getName() { return name; } - - public String getEmail() { - return email; - } } diff --git a/src/main/java/roomescape/member/MemberService.java b/src/main/java/roomescape/member/MemberService.java index ccaa8cba5..35fbf4326 100644 --- a/src/main/java/roomescape/member/MemberService.java +++ b/src/main/java/roomescape/member/MemberService.java @@ -1,17 +1,47 @@ package roomescape.member; +import auth.JwtTokenProvider; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; @Service +@Transactional(readOnly = true) public class MemberService { - private MemberDao memberDao; + private final MemberRepository memberRepository; + private final JwtTokenProvider jwtTokenProvider; - public MemberService(MemberDao memberDao) { - this.memberDao = memberDao; + public MemberService(MemberRepository memberRepository, JwtTokenProvider jwtTokenProvider) { + this.memberRepository = memberRepository; + this.jwtTokenProvider = jwtTokenProvider; } + @Transactional public MemberResponse createMember(MemberRequest memberRequest) { - Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER")); + Member member = memberRepository.save( + new Member( + memberRequest.getName(), + memberRequest.getEmail(), + memberRequest.getPassword(), + Role.USER + ) + ); return new MemberResponse(member.getId(), member.getName(), member.getEmail()); } + + public Member findByToken(String token) { + String email = jwtTokenProvider.getPayload(token); + + return memberRepository.findByEmail(email) + .orElseThrow(() -> new RuntimeException("존재하지 않는 회원입니다.")); + } + + public String login(MemberRequest memberRequest) { + Member member = memberRepository.findByEmailAndPassword( + memberRequest.getEmail(), + memberRequest.getPassword() + ) + .orElseThrow(() -> new RuntimeException("이메일 또는 비밀번호가 일치하지 않습니다.")); + + return jwtTokenProvider.createToken(member.getEmail()); + } } diff --git a/src/main/java/roomescape/member/Role.java b/src/main/java/roomescape/member/Role.java new file mode 100644 index 000000000..9fd877ecf --- /dev/null +++ b/src/main/java/roomescape/member/Role.java @@ -0,0 +1,5 @@ +package roomescape.member; + +public enum Role { + USER, ADMIN; +} diff --git a/src/main/java/roomescape/presentation/AdminInterceptor.java b/src/main/java/roomescape/presentation/AdminInterceptor.java new file mode 100644 index 000000000..dee4258ed --- /dev/null +++ b/src/main/java/roomescape/presentation/AdminInterceptor.java @@ -0,0 +1,53 @@ +package roomescape.presentation; + +import auth.JwtTokenProvider; +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.member.Member; +import roomescape.member.MemberRepository; + +import java.util.Arrays; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + private final JwtTokenProvider jwtTokenProvider; + private final MemberRepository memberRepository; + + public AdminInterceptor(JwtTokenProvider jwtTokenProvider, MemberRepository memberRepository) { + this.jwtTokenProvider = jwtTokenProvider; + this.memberRepository = memberRepository; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) throws Exception { + String token = extractToken(request); + + if (token == null || !jwtTokenProvider.validateToken(token)) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + String email = jwtTokenProvider.getPayload(token); + Member member = memberRepository.findByEmail(email).orElse(null); + + if (member == null || !"ADMIN".equals(member.getRole())) { + response.setStatus(HttpServletResponse.SC_UNAUTHORIZED); + return false; + } + + return true; + } + + private String extractToken(HttpServletRequest request) { + Cookie[] cookies = request.getCookies(); + if (cookies == null) return null; + return Arrays.stream(cookies) + .filter(c -> "token".equals(c.getName())) + .map(Cookie::getValue) + .findFirst() + .orElse(null); + } +} diff --git a/src/main/java/roomescape/presentation/AuthenticationPrincipalArgumentResolver.java b/src/main/java/roomescape/presentation/AuthenticationPrincipalArgumentResolver.java new file mode 100644 index 000000000..d5561bf67 --- /dev/null +++ b/src/main/java/roomescape/presentation/AuthenticationPrincipalArgumentResolver.java @@ -0,0 +1,38 @@ +package roomescape.presentation; + +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.infrastructure.AuthMember; +import roomescape.infrastructure.AuthService; +import roomescape.member.Member; + +@Component +public class AuthenticationPrincipalArgumentResolver implements HandlerMethodArgumentResolver { + private final AuthService authService; + + public AuthenticationPrincipalArgumentResolver(AuthService authService) { + + this.authService = authService; + } + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(AuthMember.class) + && Member.class.isAssignableFrom(parameter.getParameterType()); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, + NativeWebRequest webRequest, WebDataBinderFactory binderFactory) { + HttpServletRequest request = (HttpServletRequest) webRequest.getNativeRequest(); + + Member member = authService.extractMember(request); + + return member; + } +} diff --git a/src/main/java/roomescape/presentation/WebMvcConfig.java b/src/main/java/roomescape/presentation/WebMvcConfig.java new file mode 100644 index 000000000..fc1c15718 --- /dev/null +++ b/src/main/java/roomescape/presentation/WebMvcConfig.java @@ -0,0 +1,31 @@ +package roomescape.presentation; + +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 java.util.List; + + +@Configuration +public class WebMvcConfig implements WebMvcConfigurer { + private final AuthenticationPrincipalArgumentResolver authResolver; + private final AdminInterceptor adminInterceptor; + + public WebMvcConfig(AuthenticationPrincipalArgumentResolver authResolver, AdminInterceptor adminInterceptor) { + this.authResolver = authResolver; + this.adminInterceptor = adminInterceptor; + } + + @Override + public void addArgumentResolvers(List resolvers) { + resolvers.add(authResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor) + .addPathPatterns("/admin/**"); + } +} \ No newline at end of file diff --git a/src/main/java/roomescape/reservation/Reservation.java b/src/main/java/roomescape/reservation/Reservation.java index 83a7edf1b..b1823926a 100644 --- a/src/main/java/roomescape/reservation/Reservation.java +++ b/src/main/java/roomescape/reservation/Reservation.java @@ -1,21 +1,59 @@ package roomescape.reservation; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; +import roomescape.member.Member; import roomescape.theme.Theme; import roomescape.time.Time; +@Entity +@Table(name = "Reservation") +@SQLDelete(sql = "UPDATE reservation SET deleted = true WHERE id = ?") +@Where(clause = "deleted = false") public class Reservation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @Column(nullable = false) private String name; + + @Column(nullable = false) private String date; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id", nullable = false) private Time time; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id", nullable = false) private Theme theme; - public Reservation(Long id, String name, String date, Time time, Theme theme) { - this.id = id; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @Column(name = "deleted") + private boolean deleted = false; + + protected Reservation() { + } + + public Reservation(String name, String date, Time time, Theme theme, Member member) { this.name = name; this.date = date; this.time = time; this.theme = theme; + this.member = member; } public Reservation(String name, String date, Time time, Theme theme) { @@ -25,10 +63,6 @@ public Reservation(String name, String date, Time time, Theme theme) { this.theme = theme; } - public Reservation() { - - } - public Long getId() { return id; } @@ -48,4 +82,8 @@ public Time getTime() { public Theme getTheme() { return theme; } + + public Member getMember() { + return member; + } } diff --git a/src/main/java/roomescape/reservation/ReservationController.java b/src/main/java/roomescape/reservation/ReservationController.java index b3bef3990..7b135f8d5 100644 --- a/src/main/java/roomescape/reservation/ReservationController.java +++ b/src/main/java/roomescape/reservation/ReservationController.java @@ -7,6 +7,11 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import roomescape.infrastructure.AuthMember; +import roomescape.member.Member; +import roomescape.reservation.dto.MyReservationResponse; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.reservation.dto.ReservationResponse; import java.net.URI; import java.util.List; @@ -26,16 +31,14 @@ public List list() { } @PostMapping("/reservations") - public ResponseEntity create(@RequestBody ReservationRequest reservationRequest) { - if (reservationRequest.getName() == null - || reservationRequest.getDate() == null - || reservationRequest.getTheme() == null - || reservationRequest.getTime() == null) { - return ResponseEntity.badRequest().build(); + public ResponseEntity create(@AuthMember Member member, @RequestBody ReservationRequest request) { + ReservationResponse response; + if (member != null) { + response = reservationService.saveByMember(request, member); + } else { + response = reservationService.saveByAdmin(request); } - ReservationResponse reservation = reservationService.save(reservationRequest); - - return ResponseEntity.created(URI.create("/reservations/" + reservation.getId())).body(reservation); + return ResponseEntity.created(URI.create("/reservations/" + response.getId())).body(response); } @DeleteMapping("/reservations/{id}") @@ -43,4 +46,9 @@ public ResponseEntity delete(@PathVariable Long id) { reservationService.deleteById(id); return ResponseEntity.noContent().build(); } + + @GetMapping("/reservations-mine") + public List list(@AuthMember Member member) { + return reservationService.findByMember(member); + } } diff --git a/src/main/java/roomescape/reservation/ReservationDao.java b/src/main/java/roomescape/reservation/ReservationDao.java deleted file mode 100644 index a4972430c..000000000 --- a/src/main/java/roomescape/reservation/ReservationDao.java +++ /dev/null @@ -1,127 +0,0 @@ -package roomescape.reservation; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; -import roomescape.theme.Theme; -import roomescape.time.Time; - -import java.sql.PreparedStatement; -import java.util.List; - -@Repository -public class ReservationDao { - - private final JdbcTemplate jdbcTemplate; - - public ReservationDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - public List findAll() { - return jdbcTemplate.query( - "SELECT r.id AS reservation_id, r.name as reservation_name, r.date as reservation_date, " + - "t.id AS theme_id, t.name AS theme_name, t.description AS theme_description, " + - "ti.id AS time_id, ti.time_value AS time_value " + - "FROM reservation r " + - "JOIN theme t ON r.theme_id = t.id " + - "JOIN time ti ON r.time_id = ti.id", - - (rs, rowNum) -> new Reservation( - rs.getLong("reservation_id"), - rs.getString("reservation_name"), - rs.getString("reservation_date"), - new Time( - rs.getLong("time_id"), - rs.getString("time_value") - ), - new Theme( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_description") - ))); - } - - public Reservation save(ReservationRequest reservationRequest) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - PreparedStatement ps = connection.prepareStatement("INSERT INTO reservation(date, name, theme_id, time_id) VALUES (?, ?, ?, ?)", new String[]{"id"}); - ps.setString(1, reservationRequest.getDate()); - ps.setString(2, reservationRequest.getName()); - ps.setLong(3, reservationRequest.getTheme()); - ps.setLong(4, reservationRequest.getTime()); - return ps; - }, keyHolder); - - Time time = jdbcTemplate.queryForObject("SELECT * FROM time WHERE id = ?", - (rs, rowNum) -> new Time(rs.getLong("id"), rs.getString("time_value")), - reservationRequest.getTime()); - - Theme theme = jdbcTemplate.queryForObject("SELECT * FROM theme WHERE id = ?", - (rs, rowNum) -> new Theme(rs.getLong("id"), rs.getString("name"), rs.getString("description")), - reservationRequest.getTheme()); - - return new Reservation( - keyHolder.getKey().longValue(), - reservationRequest.getName(), - reservationRequest.getDate(), - time, - theme - ); - } - - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM reservation WHERE id = ?", id); - } - - public List findReservationsByDateAndTheme(String date, Long themeId) { - return jdbcTemplate.query( - "SELECT r.id AS reservation_id, r.name as reservation_name, r.date as reservation_date, " + - "t.id AS theme_id, t.name AS theme_name, t.description AS theme_description, " + - "ti.id AS time_id, ti.time_value AS time_value " + - "FROM reservation r " + - "JOIN theme t ON r.theme_id = t.id " + - "JOIN time ti ON r.time_id = ti.id" + - "WHERE r.date = ? AND r.theme_id = ?", - new Object[]{date, themeId}, - (rs, rowNum) -> new Reservation( - rs.getLong("reservation_id"), - rs.getString("reservation_name"), - rs.getString("reservation_date"), - new Time( - rs.getLong("time_id"), - rs.getString("time_value") - ), - new Theme( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_description") - ))); - } - - public List findByDateAndThemeId(String date, Long themeId) { - return jdbcTemplate.query( - "SELECT r.id AS reservation_id, r.name as reservation_name, r.date as reservation_date, " + - "t.id AS theme_id, t.name AS theme_name, t.description AS theme_description, " + - "ti.id AS time_id, ti.time_value AS time_value " + - "FROM reservation r " + - "JOIN theme t ON r.theme_id = t.id " + - "JOIN time ti ON r.time_id = ti.id " + - "WHERE r.date = ? AND r.theme_id = ?", - new Object[]{date, themeId}, - (rs, rowNum) -> new Reservation( - rs.getLong("reservation_id"), - rs.getString("reservation_name"), - rs.getString("reservation_date"), - new Time( - rs.getLong("time_id"), - rs.getString("time_value") - ), - new Theme( - rs.getLong("theme_id"), - rs.getString("theme_name"), - rs.getString("theme_description") - ))); - } -} diff --git a/src/main/java/roomescape/reservation/ReservationRepository.java b/src/main/java/roomescape/reservation/ReservationRepository.java new file mode 100644 index 000000000..2d1797738 --- /dev/null +++ b/src/main/java/roomescape/reservation/ReservationRepository.java @@ -0,0 +1,15 @@ +package roomescape.reservation; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface ReservationRepository extends JpaRepository { + + List findByDateAndThemeId(String date, Long themeId); + + @Query("SELECT r FROM Reservation r JOIN FETCH r.time JOIN FETCH r.theme WHERE r.member.id = :memberId") + List findByMemberId(@Param("memberId") Long memberId); +} diff --git a/src/main/java/roomescape/reservation/ReservationService.java b/src/main/java/roomescape/reservation/ReservationService.java index bd3313328..fbc5a7c0f 100644 --- a/src/main/java/roomescape/reservation/ReservationService.java +++ b/src/main/java/roomescape/reservation/ReservationService.java @@ -1,30 +1,113 @@ package roomescape.reservation; +import jakarta.transaction.Transactional; import org.springframework.stereotype.Service; +import roomescape.member.Member; +import roomescape.reservation.dto.MyReservationResponse; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.reservation.dto.ReservationResponse; +import roomescape.reservation.waiting.Waiting; +import roomescape.reservation.waiting.WaitingRepository; +import roomescape.reservation.waiting.WaitingWithRank; +import roomescape.theme.Theme; +import roomescape.theme.ThemeRepository; +import roomescape.time.Time; +import roomescape.time.TimeRepository; import java.util.List; @Service +@Transactional public class ReservationService { - private ReservationDao reservationDao; + private final ReservationRepository reservationRepository; + private final TimeRepository timeRepository; + private final ThemeRepository themeRepository; + private final WaitingRepository waitingRepository; - public ReservationService(ReservationDao reservationDao) { - this.reservationDao = reservationDao; + public ReservationService(ReservationRepository reservationRepository, + TimeRepository timeRepository, + ThemeRepository themeRepository, + WaitingRepository waitingRepository) { + this.reservationRepository = reservationRepository; + this.timeRepository = timeRepository; + this.themeRepository = themeRepository; + this.waitingRepository = waitingRepository; } - public ReservationResponse save(ReservationRequest reservationRequest) { - Reservation reservation = reservationDao.save(reservationRequest); + @Transactional + public ReservationResponse saveByMember(ReservationRequest request, Member member) { + Theme theme = findTheme(request.getTheme()); + Time time = findTime(request.getTime()); - return new ReservationResponse(reservation.getId(), reservationRequest.getName(), reservation.getTheme().getName(), reservation.getDate(), reservation.getTime().getValue()); + if (waitingRepository.existsByMemberIdAndDateAndThemeIdAndTimeId(member.getId(), request.getDate(), theme.getId(), time.getId())) { + throw new IllegalArgumentException("이미 대기 신청을 한 타임입니다."); + } + + List existing = reservationRepository.findByDateAndThemeId(request.getDate(), theme.getId()); + + if (existing.isEmpty()) { + Reservation reservation = new Reservation(member.getName(), request.getDate(), time, theme, member); + reservationRepository.save(reservation); + return ReservationResponse.from(reservation); + } + + Waiting waiting = new Waiting(member, theme, time, request.getDate()); + waitingRepository.save(waiting); + + return null; } + public ReservationResponse saveByAdmin(ReservationRequest request) { + Theme theme = findTheme(request.getTheme()); + Time time = findTime(request.getTime()); + + Reservation reservation = new Reservation(request.getName(), request.getDate(), time, theme); + reservationRepository.save(reservation); + return ReservationResponse.from(reservation); + } + + @Transactional public void deleteById(Long id) { - reservationDao.deleteById(id); + reservationRepository.deleteById(id); + } + + @Transactional + public List findByMember(Member member) { + List responses = new java.util.ArrayList<>( + reservationRepository.findByMemberId(member.getId()).stream() + .map(MyReservationResponse::from) + .toList() + ); + + List waitingsWithRank = waitingRepository.findWaitingsWithRankByMemberId(member.getId()); + + for (WaitingWithRank wr : waitingsWithRank) { + Waiting w = wr.getWaiting(); + responses.add(new MyReservationResponse( + w.getId(), + w.getTheme().getName(), + w.getDate(), + w.getTime().getValue(), + wr.getRank() + "번째 예약대기" + )); + } + + return responses; + } + + + private Theme findTheme(Long id) { + return themeRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("테마 없음")); + } + + private Time findTime(Long id) { + return timeRepository.findById(id).orElseThrow(() -> new IllegalArgumentException("시간 없음")); } public List findAll() { - return reservationDao.findAll().stream() - .map(it -> new ReservationResponse(it.getId(), it.getName(), it.getTheme().getName(), it.getDate(), it.getTime().getValue())) + return reservationRepository.findAll().stream() + .map(it -> new ReservationResponse(it.getId(), it.getName(), + it.getTheme().getName(), it.getDate(), it.getTime().getValue())) .toList(); } } diff --git a/src/main/java/roomescape/reservation/dto/MyReservationResponse.java b/src/main/java/roomescape/reservation/dto/MyReservationResponse.java new file mode 100644 index 000000000..42a095e1a --- /dev/null +++ b/src/main/java/roomescape/reservation/dto/MyReservationResponse.java @@ -0,0 +1,21 @@ +package roomescape.reservation.dto; + +import roomescape.reservation.Reservation; + +public record MyReservationResponse( + Long reservationId, + String theme, + String date, + String time, + String status +) { + public static MyReservationResponse from(Reservation reservation) { + return new MyReservationResponse( + reservation.getId(), + reservation.getTheme().getName(), + reservation.getDate(), + reservation.getTime().getValue(), + "예약" + ); + } +} diff --git a/src/main/java/roomescape/reservation/ReservationRequest.java b/src/main/java/roomescape/reservation/dto/ReservationRequest.java similarity index 90% rename from src/main/java/roomescape/reservation/ReservationRequest.java rename to src/main/java/roomescape/reservation/dto/ReservationRequest.java index 19f441246..aca06b48b 100644 --- a/src/main/java/roomescape/reservation/ReservationRequest.java +++ b/src/main/java/roomescape/reservation/dto/ReservationRequest.java @@ -1,4 +1,4 @@ -package roomescape.reservation; +package roomescape.reservation.dto; public class ReservationRequest { private String name; @@ -21,4 +21,4 @@ public Long getTheme() { public Long getTime() { return time; } -} +} \ No newline at end of file diff --git a/src/main/java/roomescape/reservation/ReservationResponse.java b/src/main/java/roomescape/reservation/dto/ReservationResponse.java similarity index 56% rename from src/main/java/roomescape/reservation/ReservationResponse.java rename to src/main/java/roomescape/reservation/dto/ReservationResponse.java index 41360a363..99002a161 100644 --- a/src/main/java/roomescape/reservation/ReservationResponse.java +++ b/src/main/java/roomescape/reservation/dto/ReservationResponse.java @@ -1,4 +1,6 @@ -package roomescape.reservation; +package roomescape.reservation.dto; + +import roomescape.reservation.Reservation; public class ReservationResponse { private Long id; @@ -15,6 +17,16 @@ public ReservationResponse(Long id, String name, String theme, String date, Stri this.time = time; } + public static ReservationResponse from(Reservation reservation) { + return new ReservationResponse( + reservation.getId(), + reservation.getName(), + reservation.getTheme().getName(), // Theme 객체에서 이름을 가져옴 + reservation.getDate(), + reservation.getTime().getValue() // Time 객체에서 시간 값을 가져옴 + ); + } + public Long getId() { return id; } diff --git a/src/main/java/roomescape/reservation/waiting/Waiting.java b/src/main/java/roomescape/reservation/waiting/Waiting.java new file mode 100644 index 000000000..b0f1eff5b --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/Waiting.java @@ -0,0 +1,63 @@ +package roomescape.reservation.waiting; + +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import roomescape.member.Member; +import roomescape.theme.Theme; +import roomescape.time.Time; + +@Entity +public class Waiting { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") + private Theme theme; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id") + private Time time; + + private String date; + + public Waiting() { + } + + public Waiting(Member member, Theme theme, Time time, String date) { + this.member = member; + this.theme = theme; + this.time = time; + this.date = date; + } + + public Long getId() { + return id; + } + + public Member getMember() { + return member; + } + + public Theme getTheme() { + return theme; + } + + public Time getTime() { + return time; + } + + public String getDate() { + return date; + } +} diff --git a/src/main/java/roomescape/reservation/waiting/WaitingController.java b/src/main/java/roomescape/reservation/waiting/WaitingController.java new file mode 100644 index 000000000..6ab2eb1f3 --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/WaitingController.java @@ -0,0 +1,28 @@ +package roomescape.reservation.waiting; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RestController; +import roomescape.infrastructure.AuthMember; +import roomescape.member.Member; +import roomescape.reservation.dto.ReservationRequest; + +import java.net.URI; + +@RestController +public class WaitingController { + private final WaitingService waitingService; + + public WaitingController(WaitingService waitingService) { + this.waitingService = waitingService; + } + + @PostMapping("/waitings") + public ResponseEntity create(@AuthMember Member member, + @RequestBody ReservationRequest request) { + WaitingResponse response = waitingService.save(request, member); + return ResponseEntity.created(URI.create("/waitings/" + response.id())) + .body(response); + } +} diff --git a/src/main/java/roomescape/reservation/waiting/WaitingRepository.java b/src/main/java/roomescape/reservation/waiting/WaitingRepository.java new file mode 100644 index 000000000..219ff3830 --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/WaitingRepository.java @@ -0,0 +1,25 @@ +package roomescape.reservation.waiting; + +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; + +import java.util.List; + +public interface WaitingRepository extends JpaRepository { + + boolean existsByMemberIdAndDateAndThemeIdAndTimeId(Long memberId, String date, Long themeId, Long timeId); + + @Query(""" + SELECT new roomescape.reservation.waiting.WaitingWithRank( + w, + (SELECT COUNT(w2) FROM Waiting w2 + WHERE w2.theme = w.theme + AND w2.date = w.date + AND w2.time = w.time + AND w2.id < w.id)) + FROM Waiting w + WHERE w.member.id = :memberId + """) + List findWaitingsWithRankByMemberId(@Param("memberId") Long memberId); +} diff --git a/src/main/java/roomescape/reservation/waiting/WaitingResponse.java b/src/main/java/roomescape/reservation/waiting/WaitingResponse.java new file mode 100644 index 000000000..e04a5b3a2 --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/WaitingResponse.java @@ -0,0 +1,17 @@ +package roomescape.reservation.waiting; + +public record WaitingResponse( + Long id, + String theme, + String date, + String time +) { + public static WaitingResponse from(Waiting waiting) { + return new WaitingResponse( + waiting.getId(), + waiting.getTheme().getName(), + waiting.getDate(), + waiting.getTime().getValue() + ); + } +} diff --git a/src/main/java/roomescape/reservation/waiting/WaitingService.java b/src/main/java/roomescape/reservation/waiting/WaitingService.java new file mode 100644 index 000000000..2d1656c3c --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/WaitingService.java @@ -0,0 +1,57 @@ +package roomescape.reservation.waiting; + +import jakarta.transaction.Transactional; +import org.springframework.stereotype.Service; +import roomescape.member.Member; +import roomescape.reservation.Reservation; +import roomescape.reservation.ReservationRepository; +import roomescape.reservation.dto.ReservationRequest; +import roomescape.theme.Theme; +import roomescape.theme.ThemeRepository; +import roomescape.time.Time; +import roomescape.time.TimeRepository; + +import java.util.List; + +@Service +@Transactional +public class WaitingService { + private final WaitingRepository waitingRepository; + private final ReservationRepository reservationRepository; + private final ThemeRepository themeRepository; + private final TimeRepository timeRepository; + + public WaitingService(WaitingRepository waitingRepository, + ReservationRepository reservationRepository, + ThemeRepository themeRepository, + TimeRepository timeRepository) { + this.waitingRepository = waitingRepository; + this.reservationRepository = reservationRepository; + this.themeRepository = themeRepository; + this.timeRepository = timeRepository; + } + + public WaitingResponse save(ReservationRequest request, Member member) { + Theme theme = themeRepository.findById(request.getTheme()) + .orElseThrow(() -> new IllegalArgumentException("테마 없음")); + Time time = timeRepository.findById(request.getTime()) + .orElseThrow(() -> new IllegalArgumentException("시간 없음")); + + if (waitingRepository.existsByMemberIdAndDateAndThemeIdAndTimeId(member.getId(), request.getDate(), theme.getId(), time.getId())) { + throw new IllegalArgumentException("이미 해당 시간에 대기/예약이 존재합니다."); + } + + List existing = reservationRepository.findByDateAndThemeId(request.getDate(), theme.getId()); + boolean hasReservationAtTime = existing.stream() + .anyMatch(r -> r.getTime().getId().equals(time.getId())); + + if (!hasReservationAtTime) { + throw new IllegalArgumentException("예약자가 없는 시간에는 대기를 걸 수 없습니다. 바로 예약해 주세요."); + } + + Waiting waiting = new Waiting(member, theme, time, request.getDate()); + waitingRepository.save(waiting); + + return WaitingResponse.from(waiting); + } +} diff --git a/src/main/java/roomescape/reservation/waiting/WaitingWithRank.java b/src/main/java/roomescape/reservation/waiting/WaitingWithRank.java new file mode 100644 index 000000000..c6b9a863d --- /dev/null +++ b/src/main/java/roomescape/reservation/waiting/WaitingWithRank.java @@ -0,0 +1,19 @@ +package roomescape.reservation.waiting; + +public class WaitingWithRank { + private Waiting waiting; + private Long rank; + + public WaitingWithRank(Waiting waiting, Long rank) { + this.waiting = waiting; + this.rank = rank + 1; + } + + public Waiting getWaiting() { + return waiting; + } + + public Long getRank() { + return rank; + } +} diff --git a/src/main/java/roomescape/theme/Theme.java b/src/main/java/roomescape/theme/Theme.java index 430a6239c..b2cf35501 100644 --- a/src/main/java/roomescape/theme/Theme.java +++ b/src/main/java/roomescape/theme/Theme.java @@ -1,17 +1,26 @@ package roomescape.theme; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +@Entity +@SQLDelete(sql = "UPDATE theme SET deleted = true WHERE id = ?") +@Where(clause = "deleted = false") public class Theme { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + private String name; private String description; - public Theme() { - } + private boolean deleted = false; - public Theme(Long id, String name, String description) { - this.id = id; - this.name = name; - this.description = description; + protected Theme() { } public Theme(String name, String description) { @@ -26,8 +35,4 @@ public Long getId() { public String getName() { return name; } - - public String getDescription() { - return description; - } } diff --git a/src/main/java/roomescape/theme/ThemeController.java b/src/main/java/roomescape/theme/ThemeController.java index 03bca41a6..a1d7a9b42 100644 --- a/src/main/java/roomescape/theme/ThemeController.java +++ b/src/main/java/roomescape/theme/ThemeController.java @@ -13,26 +13,26 @@ @RestController public class ThemeController { - private ThemeDao themeDao; + private final ThemeService themeService; - public ThemeController(ThemeDao themeDao) { - this.themeDao = themeDao; + public ThemeController(ThemeService themeService) { + this.themeService = themeService; } @PostMapping("/themes") public ResponseEntity createTheme(@RequestBody Theme theme) { - Theme newTheme = themeDao.save(theme); + Theme newTheme = themeService.save(theme); return ResponseEntity.created(URI.create("/themes/" + newTheme.getId())).body(newTheme); } @GetMapping("/themes") public ResponseEntity> list() { - return ResponseEntity.ok(themeDao.findAll()); + return ResponseEntity.ok(themeService.findAll()); } @DeleteMapping("/themes/{id}") public ResponseEntity deleteTheme(@PathVariable Long id) { - themeDao.deleteById(id); + themeService.deleteById(id); return ResponseEntity.noContent().build(); } } diff --git a/src/main/java/roomescape/theme/ThemeDao.java b/src/main/java/roomescape/theme/ThemeDao.java deleted file mode 100644 index 945341d8d..000000000 --- a/src/main/java/roomescape/theme/ThemeDao.java +++ /dev/null @@ -1,41 +0,0 @@ -package roomescape.theme; - -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; -import org.springframework.stereotype.Repository; - -import java.util.List; - -@Repository -public class ThemeDao { - private JdbcTemplate jdbcTemplate; - - public ThemeDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } - - public List findAll() { - return jdbcTemplate.query("SELECT * FROM theme where deleted = false", (rs, rowNum) -> new Theme( - rs.getLong("id"), - rs.getString("name"), - rs.getString("description") - )); - } - - public Theme save(Theme theme) { - KeyHolder keyHolder = new GeneratedKeyHolder(); - jdbcTemplate.update(connection -> { - var ps = connection.prepareStatement("INSERT INTO theme(name, description) VALUES (?, ?)", new String[]{"id"}); - ps.setString(1, theme.getName()); - ps.setString(2, theme.getDescription()); - return ps; - }, keyHolder); - - return new Theme(keyHolder.getKey().longValue(), theme.getName(), theme.getDescription()); - } - - public void deleteById(Long id) { - jdbcTemplate.update("UPDATE theme SET deleted = true WHERE id = ?", id); - } -} diff --git a/src/main/java/roomescape/theme/ThemeRepository.java b/src/main/java/roomescape/theme/ThemeRepository.java new file mode 100644 index 000000000..cbdb21a3d --- /dev/null +++ b/src/main/java/roomescape/theme/ThemeRepository.java @@ -0,0 +1,6 @@ +package roomescape.theme; + +import org.springframework.data.jpa.repository.JpaRepository; + +public interface ThemeRepository extends JpaRepository { +} diff --git a/src/main/java/roomescape/theme/ThemeService.java b/src/main/java/roomescape/theme/ThemeService.java new file mode 100644 index 000000000..5be623eee --- /dev/null +++ b/src/main/java/roomescape/theme/ThemeService.java @@ -0,0 +1,30 @@ +package roomescape.theme; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; + +@Service +@Transactional +public class ThemeService { + + private final ThemeRepository themeRepository; + + public ThemeService(ThemeRepository themeRepository) { + this.themeRepository = themeRepository; + } + + public Theme save(Theme theme) { + return themeRepository.save(theme); + } + + @Transactional(readOnly = true) + public List findAll() { + return themeRepository.findAll(); + } + + public void deleteById(Long id) { + themeRepository.deleteById(id); + } +} diff --git a/src/main/java/roomescape/time/AvailableTime.java b/src/main/java/roomescape/time/AvailableTime.java index 33acef7e3..83b0fa6e7 100644 --- a/src/main/java/roomescape/time/AvailableTime.java +++ b/src/main/java/roomescape/time/AvailableTime.java @@ -11,15 +11,7 @@ public AvailableTime(Long timeId, String time, boolean booked) { this.booked = booked; } - public Long getTimeId() { - return timeId; - } - public String getTime() { return time; } - - public boolean isBooked() { - return booked; - } } diff --git a/src/main/java/roomescape/time/Time.java b/src/main/java/roomescape/time/Time.java index 008ed93cf..d470d94d7 100644 --- a/src/main/java/roomescape/time/Time.java +++ b/src/main/java/roomescape/time/Time.java @@ -1,9 +1,26 @@ package roomescape.time; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import org.hibernate.annotations.SQLDelete; +import org.hibernate.annotations.Where; + +@Entity +@SQLDelete(sql = "UPDATE time SET deleted = true WHERE id = ?") +@Where(clause = "deleted = false") public class Time { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + + @Column(name = "time_value") private String value; + private boolean deleted = false; + public Time(Long id, String value) { this.id = id; this.value = value; @@ -14,7 +31,6 @@ public Time(String value) { } public Time() { - } public Long getId() { diff --git a/src/main/java/roomescape/time/TimeController.java b/src/main/java/roomescape/time/TimeController.java index 2343114d1..d4ba92a3f 100644 --- a/src/main/java/roomescape/time/TimeController.java +++ b/src/main/java/roomescape/time/TimeController.java @@ -14,7 +14,7 @@ @RestController public class TimeController { - private TimeService timeService; + private final TimeService timeService; public TimeController(TimeService timeService) { this.timeService = timeService; @@ -30,7 +30,6 @@ public ResponseEntity