diff --git a/build.gradle b/build.gradle index 8d52aebc6..0a3e8c31a 100644 --- a/build.gradle +++ b/build.gradle @@ -16,6 +16,10 @@ 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' + + compileOnly 'org.projectlombok:lombok' + annotationProcessor 'org.projectlombok:lombok' implementation 'dev.akkinoc.spring.boot:logback-access-spring-boot-starter:4.0.0' diff --git a/src/main/java/roomescape/AuthenticationException.java b/src/main/java/roomescape/AuthenticationException.java deleted file mode 100644 index 0b8c3ac3f..000000000 --- a/src/main/java/roomescape/AuthenticationException.java +++ /dev/null @@ -1,4 +0,0 @@ -package roomescape; - -public class AuthenticationException extends RuntimeException { -} diff --git a/src/main/java/roomescape/ExceptionController.java b/src/main/java/roomescape/ExceptionController.java index 1fca08893..58ef61822 100644 --- a/src/main/java/roomescape/ExceptionController.java +++ b/src/main/java/roomescape/ExceptionController.java @@ -1,24 +1,68 @@ package roomescape; +import lombok.Getter; +import org.springframework.dao.DataIntegrityViolationException; +import org.springframework.http.HttpStatus; import org.springframework.http.ResponseEntity; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import roomescape.exception.AuthenticationException; +import roomescape.exception.AuthorizationException; +import roomescape.exception.ConflictException; +import roomescape.exception.ErrorCode; +import roomescape.exception.NotFoundException; import org.springframework.web.bind.annotation.ControllerAdvice; import org.springframework.web.bind.annotation.ExceptionHandler; -@ControllerAdvice +@ControllerAdvice // 아 컨트롤러에서 발생한 예외를 가로챔 ~ ㅋ public class ExceptionController { + private static final Logger log = LoggerFactory.getLogger(ExceptionController.class); + @ExceptionHandler(AuthenticationException.class) public ResponseEntity handleAuthenticationException(AuthenticationException e) { - return ResponseEntity.status(401) - .body(new ErrorResponse(401, "로그인이 필요합니다.")); + return error(e.getErrorCode()); + } + + @ExceptionHandler(AuthorizationException.class) + public ResponseEntity handleAuthorizationException(AuthorizationException e) { + return error(e.getErrorCode()); + } + + @ExceptionHandler(NotFoundException.class) + public ResponseEntity handleNotFoundException(NotFoundException e) { + log.warn("Resource not found: {}", e.getMessage()); + return error(e.getErrorCode()); + } + + @ExceptionHandler({ConflictException.class, DataIntegrityViolationException.class}) + public ResponseEntity handleConflictException(Exception e) { + if (e instanceof ConflictException conflictException) { + return error(conflictException.getErrorCode()); + } + return error(ErrorCode.CONFLICT_REQUEST); + } + + @ExceptionHandler(IllegalArgumentException.class) + public ResponseEntity handleIllegalArgumentException(IllegalArgumentException e) { + return error(HttpStatus.BAD_REQUEST, e.getMessage()); } @ExceptionHandler(Exception.class) - public ResponseEntity handleRuntimeException(Exception e) { + public ResponseEntity handleException(Exception e) { e.printStackTrace(); - return ResponseEntity.badRequest() - .body(new ErrorResponse(400, "잘못된 요청입니다.")); + return error(ErrorCode.INTERNAL_SERVER_ERROR); + } + + private ResponseEntity error(ErrorCode errorCode) { + return error(errorCode.getStatus(), errorCode.getMessage()); + } + + private ResponseEntity error(HttpStatus status, String message) { + return ResponseEntity.status(status) + .body(new ErrorResponse(status.value(), message)); } + @Getter public static class ErrorResponse { private final int statusCode; private final String message; @@ -27,13 +71,5 @@ public ErrorResponse(int statusCode, String message) { this.statusCode = statusCode; this.message = message; } - - public int getStatusCode() { - return statusCode; - } - - public String getMessage() { - return message; - } } } diff --git a/src/main/java/roomescape/PageController.java b/src/main/java/roomescape/PageController.java index ac8ef9408..7cbb269f4 100644 --- a/src/main/java/roomescape/PageController.java +++ b/src/main/java/roomescape/PageController.java @@ -2,25 +2,30 @@ import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.GetMapping; +import roomescape.auth.AdminOnly; @Controller public class PageController { @GetMapping("/admin") + @AdminOnly public String admin() { return "admin/index"; } @GetMapping("/admin/reservation") + @AdminOnly public String adminReservation() { return "admin/reservation"; } @GetMapping("/admin/theme") + @AdminOnly public String adminTheme() { return "admin/theme"; } @GetMapping("/admin/time") + @AdminOnly public String adminTime() { return "admin/time"; } diff --git a/src/main/java/roomescape/WebConfig.java b/src/main/java/roomescape/WebConfig.java index 108ed4555..ab363560b 100644 --- a/src/main/java/roomescape/WebConfig.java +++ b/src/main/java/roomescape/WebConfig.java @@ -2,22 +2,30 @@ 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.LoginMemberArgumentResolver; +import roomescape.auth.AdminInterceptor; +import roomescape.auth.AuthUserArgumentResolver; import java.util.List; -@Configuration +@Configuration // Spring MVC 설정을 추가함 public class WebConfig implements WebMvcConfigurer { - private final LoginMemberArgumentResolver loginMemberArgumentResolver; + private final AuthUserArgumentResolver authUserArgumentResolver; + private final AdminInterceptor adminInterceptor; - public WebConfig(LoginMemberArgumentResolver loginMemberArgumentResolver) { - this.loginMemberArgumentResolver = loginMemberArgumentResolver; + public WebConfig(AuthUserArgumentResolver authUserArgumentResolver, AdminInterceptor adminInterceptor) { + this.authUserArgumentResolver = authUserArgumentResolver; + this.adminInterceptor = adminInterceptor; } @Override public void addArgumentResolvers(List resolvers) { - resolvers.add(loginMemberArgumentResolver); - // 로그인에 이 resolver를 사용해라 + resolvers.add(authUserArgumentResolver); + } + + @Override + public void addInterceptors(InterceptorRegistry registry) { + registry.addInterceptor(adminInterceptor); } } diff --git a/src/main/java/roomescape/auth/AdminInterceptor.java b/src/main/java/roomescape/auth/AdminInterceptor.java new file mode 100644 index 000000000..522d5716d --- /dev/null +++ b/src/main/java/roomescape/auth/AdminInterceptor.java @@ -0,0 +1,40 @@ +package roomescape.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.stereotype.Component; +import org.springframework.web.method.HandlerMethod; +import org.springframework.web.servlet.HandlerInterceptor; +import roomescape.exception.AuthorizationException; +import roomescape.member.MemberService; +import roomescape.member.MemberRole; + +@Component +public class AdminInterceptor implements HandlerInterceptor { + private final AuthCookieProvider authCookieProvider; + private final MemberService memberService; + + public AdminInterceptor(AuthCookieProvider authCookieProvider, MemberService memberService) { + this.authCookieProvider = authCookieProvider; + this.memberService = memberService; + } + + @Override + public boolean preHandle(HttpServletRequest request, HttpServletResponse response, Object handler) { + if (!(handler instanceof HandlerMethod handlerMethod) || !hasAdminOnly(handlerMethod)) { + return true; + } + + String token = authCookieProvider.extractAccessToken(request); + LoginMemberInfo loginMember = memberService.checkLogin(token); + if (MemberRole.ADMIN == loginMember.role()) { + return true; + } + throw new AuthorizationException(); + } + + private boolean hasAdminOnly(HandlerMethod handlerMethod) { + return handlerMethod.hasMethodAnnotation(AdminOnly.class) + || handlerMethod.getBeanType().isAnnotationPresent(AdminOnly.class); + } +} diff --git a/src/main/java/roomescape/auth/AdminOnly.java b/src/main/java/roomescape/auth/AdminOnly.java new file mode 100644 index 000000000..b70177472 --- /dev/null +++ b/src/main/java/roomescape/auth/AdminOnly.java @@ -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.METHOD, ElementType.TYPE}) +@Retention(RetentionPolicy.RUNTIME) +public @interface AdminOnly { +} diff --git a/src/main/java/roomescape/auth/AuthCookieProvider.java b/src/main/java/roomescape/auth/AuthCookieProvider.java index a69541a7e..948692f68 100644 --- a/src/main/java/roomescape/auth/AuthCookieProvider.java +++ b/src/main/java/roomescape/auth/AuthCookieProvider.java @@ -3,22 +3,48 @@ import jakarta.servlet.http.Cookie; import jakarta.servlet.http.HttpServletRequest; import org.springframework.stereotype.Component; -import roomescape.AuthenticationException; +import roomescape.exception.AuthenticationException; @Component public class AuthCookieProvider { - private static final String TOKEN_COOKIE_NAME = "token"; + private static final String ACCESS_TOKEN_COOKIE_NAME = "accessToken"; + private static final String REFRESH_TOKEN_COOKIE_NAME = "refreshToken"; + private static final String LEGACY_TOKEN_COOKIE_NAME = "token"; private static final String COOKIE_PATH = "/"; public Cookie createLoginCookie(String token) { - Cookie cookie = new Cookie(TOKEN_COOKIE_NAME, token); + return createAccessTokenCookie(token); + } + + public Cookie createAccessTokenCookie(String token) { + return createCookie(ACCESS_TOKEN_COOKIE_NAME, token); + } + + public Cookie createRefreshTokenCookie(String token) { + return createCookie(REFRESH_TOKEN_COOKIE_NAME, token); + } + + private Cookie createCookie(String name, String value) { + Cookie cookie = new Cookie(name, value); cookie.setHttpOnly(true); cookie.setPath(COOKIE_PATH); return cookie; } public Cookie createLogoutCookie() { - Cookie cookie = new Cookie(TOKEN_COOKIE_NAME, ""); + return createLogoutCookie(LEGACY_TOKEN_COOKIE_NAME); + } + + public Cookie createLogoutAccessTokenCookie() { + return createLogoutCookie(ACCESS_TOKEN_COOKIE_NAME); + } + + public Cookie createLogoutRefreshTokenCookie() { + return createLogoutCookie(REFRESH_TOKEN_COOKIE_NAME); + } + + private Cookie createLogoutCookie(String name) { + Cookie cookie = new Cookie(name, ""); cookie.setHttpOnly(true); cookie.setPath(COOKIE_PATH); cookie.setMaxAge(0); @@ -26,16 +52,37 @@ public Cookie createLogoutCookie() { } public String extractToken(HttpServletRequest request) { + return extractAccessToken(request); + } + + public String extractAccessToken(HttpServletRequest request) { + return extractToken(request, ACCESS_TOKEN_COOKIE_NAME, LEGACY_TOKEN_COOKIE_NAME); + } + + public String extractRefreshToken(HttpServletRequest request) { + return extractToken(request, REFRESH_TOKEN_COOKIE_NAME); + } + + private String extractToken(HttpServletRequest request, String... cookieNames) { Cookie[] cookies = request.getCookies(); if (cookies == null) { throw new AuthenticationException(); } for (Cookie cookie : cookies) { - if (TOKEN_COOKIE_NAME.equals(cookie.getName())) { + if (contains(cookieNames, cookie.getName())) { return cookie.getValue(); } } throw new AuthenticationException(); } + + private boolean contains(String[] values, String target) { + for (String value : values) { + if (value.equals(target)) { + return true; + } + } + return false; + } } diff --git a/src/main/java/roomescape/auth/LoginMember.java b/src/main/java/roomescape/auth/AuthUser.java similarity index 88% rename from src/main/java/roomescape/auth/LoginMember.java rename to src/main/java/roomescape/auth/AuthUser.java index 41773cd87..867a40bdf 100644 --- a/src/main/java/roomescape/auth/LoginMember.java +++ b/src/main/java/roomescape/auth/AuthUser.java @@ -7,5 +7,5 @@ @Target(ElementType.PARAMETER) @Retention(RetentionPolicy.RUNTIME) -public @interface LoginMember { +public @interface AuthUser { } diff --git a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java b/src/main/java/roomescape/auth/AuthUserArgumentResolver.java similarity index 85% rename from src/main/java/roomescape/auth/LoginMemberArgumentResolver.java rename to src/main/java/roomescape/auth/AuthUserArgumentResolver.java index 30f1eff91..f42c7222f 100644 --- a/src/main/java/roomescape/auth/LoginMemberArgumentResolver.java +++ b/src/main/java/roomescape/auth/AuthUserArgumentResolver.java @@ -7,7 +7,7 @@ import org.springframework.web.context.request.NativeWebRequest; import org.springframework.web.method.support.HandlerMethodArgumentResolver; import org.springframework.web.method.support.ModelAndViewContainer; -import roomescape.AuthenticationException; +import roomescape.exception.AuthenticationException; import roomescape.member.MemberService; import java.lang.reflect.ParameterizedType; @@ -15,18 +15,18 @@ import java.util.Optional; @Component -public class LoginMemberArgumentResolver implements HandlerMethodArgumentResolver { +public class AuthUserArgumentResolver implements HandlerMethodArgumentResolver { private final MemberService memberService; private final AuthCookieProvider authCookieProvider; - public LoginMemberArgumentResolver(MemberService memberService, AuthCookieProvider authCookieProvider) { + public AuthUserArgumentResolver(MemberService memberService, AuthCookieProvider authCookieProvider) { this.memberService = memberService; this.authCookieProvider = authCookieProvider; } @Override public boolean supportsParameter(MethodParameter parameter) { - return parameter.hasParameterAnnotation(LoginMember.class) + return parameter.hasParameterAnnotation(AuthUser.class) && (LoginMemberInfo.class.isAssignableFrom(parameter.getParameterType()) || isOptionalLoginMemberInfo(parameter)); } @@ -46,13 +46,13 @@ public Object resolveArgument( } try { - String token = authCookieProvider.extractToken(request); + String token = authCookieProvider.extractAccessToken(request); LoginMemberInfo loginMember = memberService.checkLogin(token); if (optional) { return Optional.of(loginMember); } return loginMember; - } catch (RuntimeException e) { + } catch (AuthenticationException e) { return handleAuthenticationFailure(optional); } } diff --git a/src/main/java/roomescape/auth/JwtTokenProvider.java b/src/main/java/roomescape/auth/JwtTokenProvider.java index bddf3569e..51eb57075 100644 --- a/src/main/java/roomescape/auth/JwtTokenProvider.java +++ b/src/main/java/roomescape/auth/JwtTokenProvider.java @@ -1,74 +1,100 @@ package roomescape.auth; -import com.fasterxml.jackson.core.type.TypeReference; -import com.fasterxml.jackson.databind.ObjectMapper; +import io.jsonwebtoken.Claims; +import io.jsonwebtoken.JwtParser; import io.jsonwebtoken.Jwts; import io.jsonwebtoken.SignatureAlgorithm; import io.jsonwebtoken.security.Keys; +import org.springframework.beans.factory.annotation.Value; import org.springframework.stereotype.Component; import roomescape.member.Member; +import roomescape.member.MemberRole; -import javax.crypto.Mac; -import javax.crypto.spec.SecretKeySpec; +import java.security.Key; import java.nio.charset.StandardCharsets; -import java.util.Base64; import java.util.Date; -import java.util.Map; @Component public class JwtTokenProvider { - private static final String SECRET_KEY = "roomescape-secret-key-for-jwt-token"; - private static final long EXPIRATION_TIME = 1000 * 60 * 60; - private static final ObjectMapper OBJECT_MAPPER = new ObjectMapper(); + private static final long ACCESS_TOKEN_EXPIRATION_TIME = 1000 * 60 * 30; + private static final long REFRESH_TOKEN_EXPIRATION_TIME = 1000L * 60 * 60 * 24 * 7; + + private final Key signingKey; + + public JwtTokenProvider(@Value("${roomescape.auth.jwt.secret}") String secretKey) { + this.signingKey = Keys.hmacShaKeyFor(secretKey.getBytes(StandardCharsets.UTF_8)); + } public String createToken(Member member) { + return createAccessToken(member); + } + + public String createAccessToken(Member member) { + return createToken(member, TokenType.ACCESS, ACCESS_TOKEN_EXPIRATION_TIME); + } + + public String createRefreshToken(Member member) { + return createToken(member, TokenType.REFRESH, REFRESH_TOKEN_EXPIRATION_TIME); + } + + private String createToken(Member member, TokenType tokenType, long expirationTime) { Date now = new Date(); - Date expiration = new Date(now.getTime() + EXPIRATION_TIME); + Date expiration = new Date(now.getTime() + expirationTime); return Jwts.builder() - .setSubject(member.getEmail()) - .claim("id", member.getId()) - .claim("name", member.getName()) - .claim("role", member.getRole()) + .setSubject(member.email()) + .claim("id", member.id()) + .claim("name", member.name()) + .claim("role", member.role().name()) + .claim("type", tokenType.name()) .setIssuedAt(now) .setExpiration(expiration) - .signWith(Keys.hmacShaKeyFor(SECRET_KEY.getBytes(StandardCharsets.UTF_8)), SignatureAlgorithm.HS256) + .signWith(signingKey, SignatureAlgorithm.HS256) .compact(); } public LoginMemberInfo parseMember(String token) { + return parseAccessToken(token); + } + + public LoginMemberInfo parseAccessToken(String token) { + return parseMember(token, TokenType.ACCESS); + } + + public LoginMemberInfo parseRefreshToken(String token) { + return parseMember(token, TokenType.REFRESH); + } + + private LoginMemberInfo parseMember(String token, TokenType expectedTokenType) { try { - Map claims = parseClaims(token); - Long id = Long.valueOf(claims.get("id").toString()); + Claims claims = parseClaims(token); + validateTokenType(claims, expectedTokenType); + Long id = claims.get("id", Number.class).longValue(); String name = claims.get("name").toString(); - String email = claims.get("sub").toString(); - return new LoginMemberInfo(id, name, email); + String email = claims.getSubject(); + MemberRole role = MemberRole.from(claims.get("role").toString()); + return new LoginMemberInfo(id, name, email, role); } catch (Exception e) { throw new IllegalArgumentException("Invalid token"); } } - private Map parseClaims(String token) throws Exception { - String[] parts = token.split("\\."); - if (parts.length != 3 || !isValidSignature(parts)) { - throw new IllegalArgumentException("Invalid token"); + private void validateTokenType(Claims claims, TokenType expectedTokenType) { + TokenType tokenType = TokenType.from(claims.get("type").toString()); + if (tokenType != expectedTokenType) { + throw new IllegalArgumentException("Invalid token type"); } + } - String payload = new String(Base64.getUrlDecoder().decode(parts[1]), StandardCharsets.UTF_8); - Map claims = OBJECT_MAPPER.readValue(payload, new TypeReference<>() { - }); - Number expiration = (Number) claims.get("exp"); - if (expiration.longValue() < System.currentTimeMillis() / 1000) { - throw new IllegalArgumentException("Expired token"); - } - return claims; + private Claims parseClaims(String token) { + return jwtParser() + .parseClaimsJws(token) + .getBody(); } - private boolean isValidSignature(String[] parts) throws Exception { - Mac mac = Mac.getInstance("HmacSHA256"); - mac.init(new SecretKeySpec(SECRET_KEY.getBytes(StandardCharsets.UTF_8), "HmacSHA256")); - byte[] signature = mac.doFinal((parts[0] + "." + parts[1]).getBytes(StandardCharsets.UTF_8)); - String encodedSignature = Base64.getUrlEncoder().withoutPadding().encodeToString(signature); - return encodedSignature.equals(parts[2]); + private JwtParser jwtParser() { + return Jwts.parserBuilder() + .setSigningKey(signingKey) + .build(); } } diff --git a/src/main/java/roomescape/auth/LoginMemberInfo.java b/src/main/java/roomescape/auth/LoginMemberInfo.java index fbcc8b8cc..736b8f18a 100644 --- a/src/main/java/roomescape/auth/LoginMemberInfo.java +++ b/src/main/java/roomescape/auth/LoginMemberInfo.java @@ -1,25 +1,6 @@ package roomescape.auth; -public class LoginMemberInfo { - private final Long id; - private final String name; - private final String email; +import roomescape.member.MemberRole; - public LoginMemberInfo(Long id, String name, String email) { - this.id = id; - this.name = name; - this.email = email; - } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public String getEmail() { - return email; - } +public record LoginMemberInfo(Long id, String name, String email, MemberRole role) { } diff --git a/src/main/java/roomescape/auth/LoginTokens.java b/src/main/java/roomescape/auth/LoginTokens.java new file mode 100644 index 000000000..203ddb633 --- /dev/null +++ b/src/main/java/roomescape/auth/LoginTokens.java @@ -0,0 +1,4 @@ +package roomescape.auth; + +public record LoginTokens(String accessToken, String refreshToken) { +} diff --git a/src/main/java/roomescape/auth/TokenType.java b/src/main/java/roomescape/auth/TokenType.java new file mode 100644 index 000000000..80d32ce79 --- /dev/null +++ b/src/main/java/roomescape/auth/TokenType.java @@ -0,0 +1,10 @@ +package roomescape.auth; + +public enum TokenType { + ACCESS, + REFRESH; + + public static TokenType from(String value) { + return TokenType.valueOf(value); + } +} diff --git a/src/main/java/roomescape/exception/AuthenticationException.java b/src/main/java/roomescape/exception/AuthenticationException.java new file mode 100644 index 000000000..4114d2ca6 --- /dev/null +++ b/src/main/java/roomescape/exception/AuthenticationException.java @@ -0,0 +1,18 @@ +package roomescape.exception; + +public class AuthenticationException extends RuntimeException { + private final ErrorCode errorCode; + + public AuthenticationException() { + this(ErrorCode.LOGIN_REQUIRED); + } + + public AuthenticationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/AuthorizationException.java b/src/main/java/roomescape/exception/AuthorizationException.java new file mode 100644 index 000000000..90798f20c --- /dev/null +++ b/src/main/java/roomescape/exception/AuthorizationException.java @@ -0,0 +1,18 @@ +package roomescape.exception; + +public class AuthorizationException extends RuntimeException { + private final ErrorCode errorCode; + + public AuthorizationException() { + this(ErrorCode.ADMIN_AUTHORIZATION_REQUIRED); + } + + public AuthorizationException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/ConflictException.java b/src/main/java/roomescape/exception/ConflictException.java new file mode 100644 index 000000000..70f869a94 --- /dev/null +++ b/src/main/java/roomescape/exception/ConflictException.java @@ -0,0 +1,14 @@ +package roomescape.exception; + +public class ConflictException extends RuntimeException { + private final ErrorCode errorCode; + + public ConflictException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/exception/ErrorCode.java b/src/main/java/roomescape/exception/ErrorCode.java new file mode 100644 index 000000000..5fc60727d --- /dev/null +++ b/src/main/java/roomescape/exception/ErrorCode.java @@ -0,0 +1,31 @@ +package roomescape.exception; + +import org.springframework.http.HttpStatus; + +public enum ErrorCode { + LOGIN_REQUIRED(HttpStatus.UNAUTHORIZED, "로그인이 필요합니다."), + ADMIN_AUTHORIZATION_REQUIRED(HttpStatus.FORBIDDEN, "관리자 권한이 필요합니다."), + THEME_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 테마입니다."), + TIME_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 시간입니다."), + MEMBER_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 회원입니다."), + RESERVATION_NOT_FOUND(HttpStatus.NOT_FOUND, "존재하지 않는 예약입니다."), + IDEMPOTENCY_KEY_CONFLICT(HttpStatus.CONFLICT, "동일한 Idempotency-Key로 다른 요청을 처리할 수 없습니다."), + CONFLICT_REQUEST(HttpStatus.CONFLICT, "이미 존재하거나 현재 상태와 충돌하는 요청입니다."), + INTERNAL_SERVER_ERROR(HttpStatus.INTERNAL_SERVER_ERROR, "서버 내부 오류가 발생했습니다."); + + private final HttpStatus status; + private final String message; + + ErrorCode(HttpStatus status, String message) { + this.status = status; + this.message = message; + } + + public HttpStatus getStatus() { + return status; + } + + public String getMessage() { + return message; + } +} diff --git a/src/main/java/roomescape/exception/NotFoundException.java b/src/main/java/roomescape/exception/NotFoundException.java new file mode 100644 index 000000000..f506611a5 --- /dev/null +++ b/src/main/java/roomescape/exception/NotFoundException.java @@ -0,0 +1,19 @@ +package roomescape.exception; + +public class NotFoundException extends RuntimeException { + private final ErrorCode errorCode; + + public NotFoundException(ErrorCode errorCode) { + super(errorCode.getMessage()); + this.errorCode = errorCode; + } + + public NotFoundException(ErrorCode errorCode, Throwable cause) { + super(errorCode.getMessage(), cause); + this.errorCode = errorCode; + } + + public ErrorCode getErrorCode() { + return errorCode; + } +} diff --git a/src/main/java/roomescape/member/Member.java b/src/main/java/roomescape/member/Member.java index 903aaa9b0..4e4ad17d3 100644 --- a/src/main/java/roomescape/member/Member.java +++ b/src/main/java/roomescape/member/Member.java @@ -1,26 +1,57 @@ 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; private String name; + @Column(nullable = false, unique = true) private String email; private String password; - private String role; + @Enumerated(EnumType.STRING) + private MemberRole role; - public Member(Long id, String name, String email, String role) { - this.id = id; - this.name = name; - this.email = email; - this.role = role; + protected Member() { } - public Member(String name, String email, String password, String role) { + public Member(String name, String email, String password, MemberRole role) { this.name = name; this.email = email; this.password = password; this.role = role; } + public Long id() { + return id; + } + + public String name() { + return name; + } + + public String email() { + return email; + } + + public String password() { + return password; + } + + public MemberRole role() { + return role; + } + public Long getId() { return id; } @@ -37,7 +68,7 @@ public String getPassword() { return password; } - public String getRole() { + public MemberRole getRole() { return role; } } diff --git a/src/main/java/roomescape/member/MemberController.java b/src/main/java/roomescape/member/MemberController.java index cf8f4ee01..afb402160 100644 --- a/src/main/java/roomescape/member/MemberController.java +++ b/src/main/java/roomescape/member/MemberController.java @@ -1,6 +1,6 @@ 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; @@ -8,8 +8,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; import roomescape.auth.AuthCookieProvider; -import roomescape.auth.LoginMember; +import roomescape.auth.AuthUser; import roomescape.auth.LoginMemberInfo; +import roomescape.auth.LoginTokens; import java.net.URI; @@ -24,33 +25,39 @@ public MemberController(MemberService memberService, AuthCookieProvider authCook } @PostMapping("/members") - public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) { + public ResponseEntity createMember(@RequestBody MemberRequest memberRequest) { MemberResponse member = memberService.createMember(memberRequest); return ResponseEntity.created(URI.create("/members/" + member.getId())).body(member); } @PostMapping("/login") // URL 경로 // HTTP 요청형식 - public ResponseEntity login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) { - try { - String token = memberService.login(memberRequest); - Cookie cookie = authCookieProvider.createLoginCookie(token); - response.addCookie(cookie); - return ResponseEntity.ok().build(); - } catch (IllegalArgumentException e) { - return ResponseEntity.status(401).build(); - } + public ResponseEntity login(@RequestBody MemberRequest memberRequest, HttpServletResponse response) { + LoginTokens tokens = memberService.login(memberRequest); + response.addCookie(authCookieProvider.createAccessTokenCookie(tokens.accessToken())); + response.addCookie(authCookieProvider.createRefreshTokenCookie(tokens.refreshToken())); + response.addCookie(authCookieProvider.createLoginCookie(tokens.accessToken())); + return ResponseEntity.ok().build(); + } + + @PostMapping("/token/refresh") + public ResponseEntity refreshToken(HttpServletRequest request, HttpServletResponse response) { + String refreshToken = authCookieProvider.extractRefreshToken(request); + String accessToken = memberService.refreshAccessToken(refreshToken); + response.addCookie(authCookieProvider.createAccessTokenCookie(accessToken)); + return ResponseEntity.ok().build(); } @GetMapping("/login/check") - public ResponseEntity checkLogin(@LoginMember LoginMemberInfo member) { - return ResponseEntity.ok(new MemberResponse(member.getId(), member.getName(), member.getEmail())); + public ResponseEntity checkLogin(@AuthUser LoginMemberInfo member) { + return ResponseEntity.ok(new MemberResponse(member.id(), member.name(), member.email())); } @PostMapping("/logout") - public ResponseEntity logout(HttpServletResponse response) { - Cookie cookie = authCookieProvider.createLogoutCookie(); - response.addCookie(cookie); + public ResponseEntity logout(HttpServletResponse response) { + response.addCookie(authCookieProvider.createLogoutAccessTokenCookie()); + response.addCookie(authCookieProvider.createLogoutRefreshTokenCookie()); + response.addCookie(authCookieProvider.createLogoutCookie()); return ResponseEntity.ok().build(); } diff --git a/src/main/java/roomescape/member/MemberDao.java b/src/main/java/roomescape/member/MemberDao.java index e3203235d..b4e0fa54c 100644 --- a/src/main/java/roomescape/member/MemberDao.java +++ b/src/main/java/roomescape/member/MemberDao.java @@ -1,68 +1,52 @@ package roomescape.member; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public class MemberDao { - private JdbcTemplate jdbcTemplate; - - public MemberDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } + @PersistenceContext + private EntityManager entityManager; 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"); + entityManager.persist(member); + return member; } - 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 Optional findByEmailAndPassword(String email, String password) { + return entityManager.createQuery( + "select m from Member m where m.email = :email and m.password = :password", + Member.class + ) + .setParameter("email", email) + .setParameter("password", password) + .getResultList() + .stream() + .findFirst(); } - 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 - ); + public Optional findByName(String name) { + return entityManager.createQuery( + "select m from Member m where m.name = :name", + Member.class + ) + .setParameter("name", name) + .getResultList() + .stream() + .findFirst(); } - public Member findByEmail(String email) { - return jdbcTemplate.queryForObject( - "SELECT id, name, email, role FROM member WHERE email = ?", - (rs, rowNum) -> new Member( - rs.getLong("id"), - rs.getString("name"), - rs.getString("email"), - rs.getString("role") - ), - email - ); + public Optional findByEmail(String email) { + return entityManager.createQuery( + "select m from Member m where m.email = :email", + Member.class + ) + .setParameter("email", email) + .getResultList() + .stream() + .findFirst(); } } diff --git a/src/main/java/roomescape/member/MemberRequest.java b/src/main/java/roomescape/member/MemberRequest.java index cafb79f14..853d7697b 100644 --- a/src/main/java/roomescape/member/MemberRequest.java +++ b/src/main/java/roomescape/member/MemberRequest.java @@ -1,19 +1,10 @@ package roomescape.member; +import lombok.Getter; + +@Getter public class MemberRequest { private String name; private String email; private String password; - - public String getName() { - return name; - } - - public String getEmail() { - return email; - } - - public String getPassword() { - return password; - } } diff --git a/src/main/java/roomescape/member/MemberResponse.java b/src/main/java/roomescape/member/MemberResponse.java index b9fa3b97a..55bf4b958 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 lombok.Getter; + +@Getter public class MemberResponse { private Long id; private String name; @@ -10,16 +13,4 @@ public MemberResponse(Long id, String name, String email) { this.name = name; this.email = email; } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public String getEmail() { - return email; - } } diff --git a/src/main/java/roomescape/member/MemberRole.java b/src/main/java/roomescape/member/MemberRole.java new file mode 100644 index 000000000..e8b3373aa --- /dev/null +++ b/src/main/java/roomescape/member/MemberRole.java @@ -0,0 +1,10 @@ +package roomescape.member; + +public enum MemberRole { + ADMIN, + USER; + + public static MemberRole from(String role) { + return MemberRole.valueOf(role); + } +} diff --git a/src/main/java/roomescape/member/MemberService.java b/src/main/java/roomescape/member/MemberService.java index abc534944..ee0ecc6b1 100644 --- a/src/main/java/roomescape/member/MemberService.java +++ b/src/main/java/roomescape/member/MemberService.java @@ -1,11 +1,16 @@ package roomescape.member; -import org.springframework.dao.EmptyResultDataAccessException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import roomescape.exception.AuthenticationException; import roomescape.auth.JwtTokenProvider; import roomescape.auth.LoginMemberInfo; +import roomescape.auth.LoginTokens; +import roomescape.exception.ErrorCode; +import roomescape.exception.NotFoundException; @Service +@Transactional(readOnly = true) public class MemberService { private final MemberDao memberDao; private final JwtTokenProvider jwtTokenProvider; @@ -15,21 +20,38 @@ public MemberService(MemberDao memberDao, JwtTokenProvider jwtTokenProvider) { this.jwtTokenProvider = jwtTokenProvider; } + @Transactional public MemberResponse createMember(MemberRequest memberRequest) { - Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), "USER")); - return new MemberResponse(member.getId(), member.getName(), member.getEmail()); + Member member = memberDao.save(new Member(memberRequest.getName(), memberRequest.getEmail(), memberRequest.getPassword(), MemberRole.USER)); + return new MemberResponse(member.id(), member.name(), member.email()); } - public String login(MemberRequest memberRequest) { + public LoginTokens login(MemberRequest memberRequest) { + Member member = memberDao.findByEmailAndPassword(memberRequest.getEmail(), memberRequest.getPassword()) + .orElseThrow(AuthenticationException::new); + + return new LoginTokens( + jwtTokenProvider.createAccessToken(member), + jwtTokenProvider.createRefreshToken(member) + ); + } + + public LoginMemberInfo checkLogin(String token) { try { - Member member = memberDao.findByEmailAndPassword(memberRequest.getEmail(), memberRequest.getPassword()); - return jwtTokenProvider.createToken(member); - } catch (EmptyResultDataAccessException e) { - throw new IllegalArgumentException("Invalid email or password"); + return jwtTokenProvider.parseAccessToken(token); + } catch (IllegalArgumentException e) { + throw new AuthenticationException(); } } - public LoginMemberInfo checkLogin(String token) { - return jwtTokenProvider.parseMember(token); + public String refreshAccessToken(String refreshToken) { + try { + LoginMemberInfo loginMember = jwtTokenProvider.parseRefreshToken(refreshToken); + Member member = memberDao.findByEmail(loginMember.email()) + .orElseThrow(() -> new NotFoundException(ErrorCode.MEMBER_NOT_FOUND)); + return jwtTokenProvider.createAccessToken(member); + } catch (IllegalArgumentException e) { + throw new AuthenticationException(); + } } } diff --git a/src/main/java/roomescape/reservation/Reservation.java b/src/main/java/roomescape/reservation/Reservation.java index 83a7edf1b..8516b9304 100644 --- a/src/main/java/roomescape/reservation/Reservation.java +++ b/src/main/java/roomescape/reservation/Reservation.java @@ -1,46 +1,81 @@ package roomescape.reservation; +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 roomescape.member.Member; import roomescape.theme.Theme; import roomescape.time.Time; +@Entity +@Table(name = "reservation") public class Reservation { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; - private String name; private String date; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "member_id") + private Member member; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "time_id") private Time time; + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "theme_id") private Theme theme; - public Reservation(Long id, String name, String date, Time time, Theme theme) { + protected Reservation() { + } + + public Reservation(Long id, String date, Member member, Time time, Theme theme) { this.id = id; - this.name = name; this.date = date; + this.member = member; this.time = time; this.theme = theme; } - public Reservation(String name, String date, Time time, Theme theme) { - this.name = name; - this.date = date; - this.time = time; - this.theme = theme; + public Reservation(String date, Member member, Time time, Theme theme) { + this(null, date, member, time, theme); } - public Reservation() { + public Long id() { + return id; + } + public String date() { + return date; } - public Long getId() { - return id; + public Member member() { + return member; } - public String getName() { - return name; + public Time time() { + return time; + } + + public Theme theme() { + return theme; + } + + public Long getId() { + return id; } public String getDate() { return date; } + public Member getMember() { + return member; + } + public Time getTime() { return time; } diff --git a/src/main/java/roomescape/reservation/ReservationController.java b/src/main/java/roomescape/reservation/ReservationController.java index e1fabf122..d6f6f4f1c 100644 --- a/src/main/java/roomescape/reservation/ReservationController.java +++ b/src/main/java/roomescape/reservation/ReservationController.java @@ -6,8 +6,10 @@ 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.RequestHeader; import org.springframework.web.bind.annotation.RestController; -import roomescape.auth.LoginMember; +import roomescape.auth.AdminOnly; +import roomescape.auth.AuthUser; import roomescape.auth.LoginMemberInfo; import java.net.URI; @@ -29,26 +31,28 @@ public List list() { } @GetMapping("/reservations-mine") - public List listMine(@LoginMember LoginMemberInfo loginMember) { + public List listMine(@AuthUser LoginMemberInfo loginMember) { return reservationService.findMine(loginMember); } @PostMapping("/reservations") public ResponseEntity create( @RequestBody ReservationRequest reservationRequest, - @LoginMember Optional loginMember + @RequestHeader(value = "Idempotency-Key", required = false) String idempotencyKey, + @AuthUser Optional loginMember ) { if (reservationRequest.getDate() == null || reservationRequest.getTheme() == null || reservationRequest.getTime() == null) { return ResponseEntity.badRequest().build(); } - ReservationResponse reservation = reservationService.save(reservationRequest, loginMember); + ReservationResponse reservation = reservationService.save(reservationRequest, loginMember, Optional.ofNullable(idempotencyKey)); return ResponseEntity.created(URI.create("/reservations/" + reservation.getId())).body(reservation); } @DeleteMapping("/reservations/{id}") + @AdminOnly public ResponseEntity delete(@PathVariable Long id) { reservationService.deleteById(id); return ResponseEntity.noContent().build(); diff --git a/src/main/java/roomescape/reservation/ReservationDao.java b/src/main/java/roomescape/reservation/ReservationDao.java index 880084fef..7a91ebb62 100644 --- a/src/main/java/roomescape/reservation/ReservationDao.java +++ b/src/main/java/roomescape/reservation/ReservationDao.java @@ -1,124 +1,68 @@ package roomescape.reservation; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.core.namedparam.NamedParameterJdbcTemplate; -import org.springframework.jdbc.core.simple.SimpleJdbcInsert; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; +import roomescape.member.Member; import roomescape.theme.Theme; import roomescape.time.Time; -import java.sql.ResultSet; -import java.sql.SQLException; import java.util.List; -import java.util.Map; @Repository public class ReservationDao { + @PersistenceContext + private EntityManager entityManager; - private final JdbcTemplate jdbcTemplate; - private final NamedParameterJdbcTemplate namedParameterJdbcTemplate; - private final SimpleJdbcInsert simpleJdbcInsert; - - public ReservationDao(JdbcTemplate jdbcTemplate, NamedParameterJdbcTemplate namedParameterJdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - this.namedParameterJdbcTemplate = namedParameterJdbcTemplate; - this.simpleJdbcInsert = new SimpleJdbcInsert(jdbcTemplate) - .withTableName("reservation") - .usingGeneratedKeyColumns("id"); - } - - 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", - this::mapReservation); - } - - public Reservation save(ReservationRequest reservationRequest, String name) { - Number id = simpleJdbcInsert.executeAndReturnKey(Map.of( - "date", reservationRequest.getDate(), - "name", name, - "theme_id", reservationRequest.getTheme(), - "time_id", reservationRequest.getTime() - )); - - 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( - id.longValue(), - name, - reservationRequest.getDate(), - time, - theme - ); - } - - public void deleteById(Long id) { - jdbcTemplate.update("DELETE FROM reservation WHERE id = ?", id); + public List findAll() { + return entityManager.createQuery( + "select new roomescape.reservation.ReservationResponse(r.id, m.name, th.name, r.date, t.value) " + + "from Reservation r " + + "join r.member m " + + "join r.theme th " + + "join r.time t", + ReservationResponse.class + ) + .getResultList(); } - public List findByMemberName(String name) { - return namedParameterJdbcTemplate.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.name = :name", - Map.of("name", name), - this::mapReservation); + public Reservation save(String date, Member member, Time time, Theme theme) { + Reservation reservation = new Reservation(date, member, time, theme); + entityManager.persist(reservation); + return reservation; } - public List findReservationsByDateAndTheme(String date, Long themeId) { - return namedParameterJdbcTemplate.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 = :date AND r.theme_id = :themeId", - Map.of("date", date, "themeId", themeId), - this::mapReservation); + public boolean deleteById(Long id) { + Reservation reservation = entityManager.find(Reservation.class, id); + if (reservation == null) { + return false; + } + entityManager.remove(reservation); + return true; } - public List findByDateAndThemeId(String date, Long themeId) { - return namedParameterJdbcTemplate.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 = :date AND r.theme_id = :themeId", - Map.of("date", date, "themeId", themeId), - this::mapReservation); + public List findByMemberId(Long memberId) { + return entityManager.createQuery( + "select new roomescape.reservation.ReservationMineResponse(r.id, th.name, r.date, t.value, '예약') " + + "from Reservation r " + + "join r.theme th " + + "join r.time t " + + "where r.member.id = :memberId", + ReservationMineResponse.class + ) + .setParameter("memberId", memberId) + .getResultList(); } - private Reservation mapReservation(ResultSet rs, int rowNum) throws SQLException { - return 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 findReservedTimeIdsByDateAndThemeId(String date, Long themeId) { + return entityManager.createQuery( + "select t.id from Reservation r " + + "join r.time t " + + "where r.date = :date and r.theme.id = :themeId", + Long.class + ) + .setParameter("date", date) + .setParameter("themeId", themeId) + .getResultList(); } } diff --git a/src/main/java/roomescape/reservation/ReservationMineResponse.java b/src/main/java/roomescape/reservation/ReservationMineResponse.java index 8b5f65d6e..f6e71573c 100644 --- a/src/main/java/roomescape/reservation/ReservationMineResponse.java +++ b/src/main/java/roomescape/reservation/ReservationMineResponse.java @@ -1,5 +1,8 @@ package roomescape.reservation; +import lombok.Getter; + +@Getter public class ReservationMineResponse { private final Long id; private final String theme; @@ -14,24 +17,4 @@ public ReservationMineResponse(Long id, String theme, String date, String time, this.time = time; this.status = status; } - - public Long getId() { - return id; - } - - public String getTheme() { - return theme; - } - - public String getDate() { - return date; - } - - public String getTime() { - return time; - } - - public String getStatus() { - return status; - } } diff --git a/src/main/java/roomescape/reservation/ReservationRequest.java b/src/main/java/roomescape/reservation/ReservationRequest.java index 19f441246..0a48eb49e 100644 --- a/src/main/java/roomescape/reservation/ReservationRequest.java +++ b/src/main/java/roomescape/reservation/ReservationRequest.java @@ -1,24 +1,11 @@ package roomescape.reservation; +import lombok.Getter; + +@Getter public class ReservationRequest { private String name; private String date; private Long theme; private Long time; - - public String getName() { - return name; - } - - public String getDate() { - return date; - } - - public Long getTheme() { - return theme; - } - - public Long getTime() { - return time; - } } diff --git a/src/main/java/roomescape/reservation/ReservationResponse.java b/src/main/java/roomescape/reservation/ReservationResponse.java index 41360a363..a825b34a3 100644 --- a/src/main/java/roomescape/reservation/ReservationResponse.java +++ b/src/main/java/roomescape/reservation/ReservationResponse.java @@ -1,5 +1,8 @@ package roomescape.reservation; +import lombok.Getter; + +@Getter public class ReservationResponse { private Long id; private String name; @@ -14,24 +17,4 @@ public ReservationResponse(Long id, String name, String theme, String date, Stri this.date = date; this.time = time; } - - public Long getId() { - return id; - } - - public String getName() { - return name; - } - - public String getTheme() { - return theme; - } - - public String getDate() { - return date; - } - - public String getTime() { - return time; - } } diff --git a/src/main/java/roomescape/reservation/ReservationService.java b/src/main/java/roomescape/reservation/ReservationService.java index a17150848..1508aa7f1 100644 --- a/src/main/java/roomescape/reservation/ReservationService.java +++ b/src/main/java/roomescape/reservation/ReservationService.java @@ -1,54 +1,160 @@ package roomescape.reservation; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import org.springframework.stereotype.Service; -import roomescape.AuthenticationException; +import org.springframework.transaction.annotation.Transactional; +import roomescape.exception.AuthenticationException; +import roomescape.exception.ConflictException; +import roomescape.exception.ErrorCode; +import roomescape.exception.NotFoundException; import roomescape.auth.LoginMemberInfo; import roomescape.member.Member; import roomescape.member.MemberDao; +import roomescape.theme.Theme; +import roomescape.theme.ThemeDao; +import roomescape.time.Time; +import roomescape.time.TimeDao; import java.util.List; +import java.util.Map; import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; @Service +@Transactional(readOnly = true) public class ReservationService { - private ReservationDao reservationDao; - private MemberDao memberDao; + private static final Logger log = LoggerFactory.getLogger(ReservationService.class); - public ReservationService(ReservationDao reservationDao, MemberDao memberDao) { + private final ReservationDao reservationDao; + private final MemberDao memberDao; + private final TimeDao timeDao; + private final ThemeDao themeDao; + private final Map idempotencyRecords = new ConcurrentHashMap<>(); + + public ReservationService(ReservationDao reservationDao, MemberDao memberDao, TimeDao timeDao, ThemeDao themeDao) { this.reservationDao = reservationDao; this.memberDao = memberDao; + this.timeDao = timeDao; + this.themeDao = themeDao; } + @Transactional public ReservationResponse save(ReservationRequest reservationRequest, Optional loginMember) { + return save(reservationRequest, loginMember, Optional.empty()); + } + + @Transactional + public ReservationResponse save( + ReservationRequest reservationRequest, + Optional loginMember, + Optional idempotencyKey + ) { + Optional key = idempotencyKey.map(String::trim).filter(it -> !it.isBlank()); + if (key.isEmpty()) { + return createReservation(reservationRequest, loginMember); + } + + ReservationFingerprint fingerprint = ReservationFingerprint.from(reservationRequest, loginMember); + synchronized (idempotencyRecords) { + IdempotencyRecord record = idempotencyRecords.get(key.get()); + if (record != null) { + if (!record.fingerprint().equals(fingerprint)) { + throw new ConflictException(ErrorCode.IDEMPOTENCY_KEY_CONFLICT); + } + return record.response(); + } + + ReservationResponse response = createReservation(reservationRequest, loginMember); + idempotencyRecords.put(key.get(), new IdempotencyRecord(fingerprint, response)); + return response; + } + } + + private ReservationResponse createReservation(ReservationRequest reservationRequest, Optional loginMember) { Member member = findReservationMember(reservationRequest, loginMember); - Reservation reservation = reservationDao.save(reservationRequest, member.getName()); + Theme theme = findTheme(reservationRequest.getTheme()); + Time time = findTime(reservationRequest.getTime()); + Reservation reservation = reservationDao.save( + reservationRequest.getDate(), + member, + time, + theme + ); + + return toResponse(reservation); + } + + private Theme findTheme(Long themeId) { + return themeDao.findById(themeId) + .orElseThrow(() -> new NotFoundException(ErrorCode.THEME_NOT_FOUND)); + } - return new ReservationResponse(reservation.getId(), member.getName(), reservation.getTheme().getName(), reservation.getDate(), reservation.getTime().getValue()); + private Time findTime(Long timeId) { + return timeDao.findById(timeId) + .orElseThrow(() -> new NotFoundException(ErrorCode.TIME_NOT_FOUND)); } private Member findReservationMember(ReservationRequest reservationRequest, Optional loginMember) { + Optional member = findMember(reservationRequest, loginMember); + if (member.isEmpty()) { + log.warn( + "Reservation member not found. name={}, loginEmail={}", + reservationRequest.getName(), + loginMember.map(LoginMemberInfo::email).orElse(null) + ); + throw new NotFoundException(ErrorCode.MEMBER_NOT_FOUND); + } + return member.get(); + } + + private Optional findMember(ReservationRequest reservationRequest, Optional loginMember) { if (reservationRequest.getName() != null && !reservationRequest.getName().isBlank()) { return memberDao.findByName(reservationRequest.getName()); } if (loginMember.isEmpty()) { throw new AuthenticationException(); } - return memberDao.findByEmail(loginMember.get().getEmail()); + return memberDao.findByEmail(loginMember.get().email()); } + @Transactional public void deleteById(Long id) { - reservationDao.deleteById(id); + if (!reservationDao.deleteById(id)) { + throw new NotFoundException(ErrorCode.RESERVATION_NOT_FOUND); + } } public List findAll() { - return reservationDao.findAll().stream() - .map(it -> new ReservationResponse(it.getId(), it.getName(), it.getTheme().getName(), it.getDate(), it.getTime().getValue())) - .toList(); + return reservationDao.findAll(); } public List findMine(LoginMemberInfo loginMember) { - return reservationDao.findByMemberName(loginMember.getName()).stream() - .map(it -> new ReservationMineResponse(it.getId(), it.getTheme().getName(), it.getDate(), it.getTime().getValue(), "예약")) - .toList(); + return reservationDao.findByMemberId(loginMember.id()); + } + + private ReservationResponse toResponse(Reservation reservation) { + return new ReservationResponse( + reservation.id(), + reservation.member().name(), + reservation.theme().name(), + reservation.date(), + reservation.time().value() + ); + } + + private record ReservationFingerprint(String name, String loginEmail, String date, Long theme, Long time) { + private static ReservationFingerprint from(ReservationRequest request, Optional loginMember) { + return new ReservationFingerprint( + request.getName(), + loginMember.map(LoginMemberInfo::email).orElse(null), + request.getDate(), + request.getTheme(), + request.getTime() + ); + } + } + + private record IdempotencyRecord(ReservationFingerprint fingerprint, ReservationResponse response) { } } diff --git a/src/main/java/roomescape/theme/Theme.java b/src/main/java/roomescape/theme/Theme.java index 430a6239c..b6d101c6e 100644 --- a/src/main/java/roomescape/theme/Theme.java +++ b/src/main/java/roomescape/theme/Theme.java @@ -1,9 +1,22 @@ package roomescape.theme; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "theme") public class Theme { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String name; private String description; + @JsonIgnore + private boolean deleted; public Theme() { } @@ -12,10 +25,38 @@ public Theme(Long id, String name, String description) { this.id = id; this.name = name; this.description = description; + this.deleted = false; } public Theme(String name, String description) { + this(null, name, description); + } + + public Long id() { + return id; + } + + public String name() { + return name; + } + + public String description() { + return description; + } + + public boolean deleted() { + return deleted; + } + + public void delete() { + this.deleted = true; + } + + public void setName(String name) { this.name = name; + } + + public void setDescription(String description) { this.description = description; } @@ -30,4 +71,9 @@ public String getName() { public String getDescription() { return description; } + + @JsonIgnore + public boolean isDeleted() { + return deleted; + } } diff --git a/src/main/java/roomescape/theme/ThemeController.java b/src/main/java/roomescape/theme/ThemeController.java index 03bca41a6..b4ffd84b5 100644 --- a/src/main/java/roomescape/theme/ThemeController.java +++ b/src/main/java/roomescape/theme/ThemeController.java @@ -7,32 +7,35 @@ import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RestController; +import roomescape.auth.AdminOnly; import java.net.URI; import java.util.List; @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") + @AdminOnly public ResponseEntity createTheme(@RequestBody Theme theme) { - Theme newTheme = themeDao.save(theme); - return ResponseEntity.created(URI.create("/themes/" + newTheme.getId())).body(newTheme); + Theme newTheme = themeService.save(theme); + return ResponseEntity.created(URI.create("/themes/" + newTheme.id())).body(newTheme); } @GetMapping("/themes") public ResponseEntity> list() { - return ResponseEntity.ok(themeDao.findAll()); + return ResponseEntity.ok(themeService.findAll()); } @DeleteMapping("/themes/{id}") + @AdminOnly 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 index 945341d8d..728b1b480 100644 --- a/src/main/java/roomescape/theme/ThemeDao.java +++ b/src/main/java/roomescape/theme/ThemeDao.java @@ -1,41 +1,36 @@ package roomescape.theme; -import org.springframework.jdbc.core.JdbcTemplate; -import org.springframework.jdbc.support.GeneratedKeyHolder; -import org.springframework.jdbc.support.KeyHolder; +import jakarta.persistence.EntityManager; +import jakarta.persistence.PersistenceContext; import org.springframework.stereotype.Repository; import java.util.List; +import java.util.Optional; @Repository public class ThemeDao { - private JdbcTemplate jdbcTemplate; - - public ThemeDao(JdbcTemplate jdbcTemplate) { - this.jdbcTemplate = jdbcTemplate; - } + @PersistenceContext + private EntityManager entityManager; 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") - )); + return entityManager.createQuery( + "select t from Theme t where t.deleted = false", + Theme.class + ) + .getResultList(); } 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()); + entityManager.persist(theme); + return theme; } - public void deleteById(Long id) { - jdbcTemplate.update("UPDATE theme SET deleted = true WHERE id = ?", id); + public Optional findById(Long id) { + Theme theme = entityManager.find(Theme.class, id); + if (theme == null || theme.deleted()) { + return Optional.empty(); + } + return Optional.of(theme); } + } diff --git a/src/main/java/roomescape/theme/ThemeService.java b/src/main/java/roomescape/theme/ThemeService.java new file mode 100644 index 000000000..4e2942494 --- /dev/null +++ b/src/main/java/roomescape/theme/ThemeService.java @@ -0,0 +1,34 @@ +package roomescape.theme; + +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; +import roomescape.exception.ErrorCode; +import roomescape.exception.NotFoundException; + +import java.util.List; + +@Service +@Transactional(readOnly = true) +public class ThemeService { + private final ThemeDao themeDao; + + public ThemeService(ThemeDao themeDao) { + this.themeDao = themeDao; + } + + public List findAll() { + return themeDao.findAll(); + } + + @Transactional + public Theme save(Theme theme) { + return themeDao.save(theme); + } + + @Transactional + public void deleteById(Long id) { + Theme theme = themeDao.findById(id) + .orElseThrow(() -> new NotFoundException(ErrorCode.THEME_NOT_FOUND)); + theme.delete(); + } +} diff --git a/src/main/java/roomescape/time/AvailableTime.java b/src/main/java/roomescape/time/AvailableTime.java index 33acef7e3..c73510595 100644 --- a/src/main/java/roomescape/time/AvailableTime.java +++ b/src/main/java/roomescape/time/AvailableTime.java @@ -1,5 +1,8 @@ package roomescape.time; +import lombok.Getter; + +@Getter public class AvailableTime { private Long timeId; private String time; @@ -10,16 +13,4 @@ public AvailableTime(Long timeId, String time, boolean booked) { this.time = time; 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..6a190eb02 100644 --- a/src/main/java/roomescape/time/Time.java +++ b/src/main/java/roomescape/time/Time.java @@ -1,20 +1,51 @@ package roomescape.time; +import com.fasterxml.jackson.annotation.JsonIgnore; +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.Table; + +@Entity +@Table(name = "time") public class Time { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; + @Column(name = "time_value") private String value; + @JsonIgnore + private boolean deleted; + + public Time() { + } public Time(Long id, String value) { this.id = id; this.value = value; + this.deleted = false; } - public Time(String value) { - this.value = value; + public Long id() { + return id; } - public Time() { + public String value() { + return value; + } + + public boolean deleted() { + return deleted; + } + + public void delete() { + this.deleted = true; + } + public void setValue(String value) { + this.value = value; } public Long getId() { @@ -24,4 +55,9 @@ public Long getId() { public String getValue() { return value; } + + @JsonIgnore + public boolean isDeleted() { + return deleted; + } } diff --git a/src/main/java/roomescape/time/TimeController.java b/src/main/java/roomescape/time/TimeController.java index 2343114d1..c8f9ad382 100644 --- a/src/main/java/roomescape/time/TimeController.java +++ b/src/main/java/roomescape/time/TimeController.java @@ -8,6 +8,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; +import roomescape.auth.AdminOnly; import java.net.URI; import java.util.List; @@ -26,16 +27,18 @@ public List