diff --git a/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java new file mode 100644 index 00000000..5cc7a969 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/filters/JwtAuthenticationFilter.java @@ -0,0 +1,96 @@ +package org.runimo.runimo.auth.filters; + +import com.auth0.jwt.exceptions.TokenExpiredException; +import com.fasterxml.jackson.databind.ObjectMapper; +import jakarta.servlet.FilterChain; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.NonNull; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.runimo.runimo.auth.jwt.JwtResolver; +import org.runimo.runimo.common.response.ErrorResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; +import java.util.Collections; + +import static org.runimo.runimo.common.GlobalConsts.WHITE_LIST_ENDPOINTS; + +@Slf4j +@RequiredArgsConstructor +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + private static final String AUTH_HEADER = "Authorization"; + private static final String AUTH_PREFIX = "Bearer "; + private static final int TOKEN_PREFIX_LENGTH = 7; + private final JwtResolver jwtResolver; + private final ObjectMapper objectMapper = new ObjectMapper(); + + @Override + protected void doFilterInternal(@NonNull HttpServletRequest request, @NonNull HttpServletResponse response, @NonNull FilterChain filterChain) + throws IOException { + try { + if (!hasValidAuthorizationHeader(request)) { + setErrorResponse(UserErrorCode.JWT_NOT_FOUND, response); + return; + } + String jwtToken = extractToken(request); + if (!processToken(jwtToken, response)) { + return; + } + filterChain.doFilter(request, response); + } catch (Exception e) { + log.warn("[ERROR]JWT broken : {}", e.getMessage()); + setErrorResponse(UserErrorCode.JWT_BROKEN, response); + } finally { + SecurityContextHolder.clearContext(); + } + } + + private boolean hasValidAuthorizationHeader(HttpServletRequest request) { + String authHeader = request.getHeader(AUTH_HEADER); + return authHeader != null && authHeader.startsWith(AUTH_PREFIX); + } + + private String extractToken(HttpServletRequest request) { + return request.getHeader(AUTH_HEADER).substring(TOKEN_PREFIX_LENGTH); + } + + private boolean processToken(String jwtToken, HttpServletResponse response) throws IOException { + try { + String userId = jwtResolver.getUserIdFromAccessToken(jwtToken); + Authentication authentication = new UsernamePasswordAuthenticationToken( + userId, + null, + Collections.emptyList() + ); + SecurityContextHolder.getContext().setAuthentication(authentication); + return true; + } catch (TokenExpiredException e) { + setErrorResponse(UserErrorCode.JWT_EXPIRED, response); + } catch (Exception e) { // JWT 손상 시 오류 + log.warn("[ERROR]JWT broken : {}", e.getMessage()); + setErrorResponse(UserErrorCode.JWT_BROKEN, response); + } + return false; + } + + private void setErrorResponse(UserErrorCode errorCode, HttpServletResponse response) throws IOException { + response.setStatus(errorCode.getHttpStatusCode().value()); + response.setContentType("application/json"); + response.setCharacterEncoding("UTF-8"); + ErrorResponse errorResponse = ErrorResponse.of(errorCode); + response.getWriter().write(objectMapper.writeValueAsString(errorResponse)); + } + + // JWT 필터는 로그인, 회원가입, 테스트용 API, 웹소켓 연결 요청에 대해서는 필터링을 하지 않는다. + @Override + protected boolean shouldNotFilter(@NonNull HttpServletRequest request) { + return WHITE_LIST_ENDPOINTS.stream().anyMatch(endpoint -> request.getRequestURI().startsWith(endpoint)); + } +} \ No newline at end of file diff --git a/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java b/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java new file mode 100644 index 00000000..5ae557ac --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/filters/UserErrorCode.java @@ -0,0 +1,55 @@ +package org.runimo.runimo.auth.filters; + +import org.runimo.runimo.exceptions.code.CustomResponseCode; +import org.springframework.http.HttpStatus; + +public enum UserErrorCode implements CustomResponseCode { + + // 400 + USER_ALREADY_EXISTS("UEH4001", HttpStatus.BAD_REQUEST, "이미 존재하는 사용자", "이미 존재하는 사용자"), + SIGNUP_FAILED("UEH4002", HttpStatus.BAD_REQUEST, "회원가입 실패", "회원가입 실패"), + + // 401 + LOGIN_FAILED_BY_PROVIDER("UEH4011", HttpStatus.FORBIDDEN, "소셜 로그인 실패", "소셜 로그인 실패"), + JWT_NOT_FOUND("UEH4012", HttpStatus.UNAUTHORIZED, "JWT 토큰이 존재하지 않습니다.", "JWT 토큰이 존재하지 않습니다."), + JWT_EXPIRED("UEH4013", HttpStatus.UNAUTHORIZED, "JWT 토큰이 만료되었습니다.", "JWT 토큰이 만료되었습니다."), + JWT_BROKEN("UEH4014", HttpStatus.UNAUTHORIZED, "JWT 토큰이 손상되었습니다", "JWT 토큰이 손상되었습니다"), + REFRESH_FAILED("UEH4015", HttpStatus.UNAUTHORIZED, "리프레시 토큰이 만료되었습니다.", "리프레시 토큰이 만료되었습니다."), + + // 404 + USER_NOT_FOUND("UEH4041", HttpStatus.NOT_FOUND, "사용자를 찾을 수 없음", "사용자를 찾을 수 없음"), + ; + + private final String code; + private final HttpStatus httpStatusCode; + private final String clientMessage; + private final String logMessage; + + UserErrorCode(String code, HttpStatus httpStatusCode, String clientMessage, String logMessage) { + this.code = code; + this.httpStatusCode = httpStatusCode; + this.clientMessage = clientMessage; + this.logMessage = logMessage; + } + + @Override + public String getCode() { + return this.code; + } + + @Override + public String getClientMessage() { + return this.clientMessage; + } + + @Override + public String getLogMessage() { + return this.logMessage; + } + + @Override + public HttpStatus getHttpStatusCode() { + return this.httpStatusCode; + } +} + diff --git a/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java new file mode 100644 index 00000000..5bdd0679 --- /dev/null +++ b/src/main/java/org/runimo/runimo/auth/jwt/JwtResolver.java @@ -0,0 +1,29 @@ +package org.runimo.runimo.auth.jwt; + +import com.auth0.jwt.JWT; +import com.auth0.jwt.algorithms.Algorithm; +import com.auth0.jwt.exceptions.JWTVerificationException; +import com.auth0.jwt.interfaces.DecodedJWT; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.stereotype.Component; + +@Component +public class JwtResolver { + + private static final String ISSUER = "RunUSAuthService"; + @Value("${jwt.secret}") + private String jwtSecret; + @Value("${jwt.expiration}") + private long jwtExpiration; + @Value("${jwt.refresh.expiration}") + private long jwtRefreshExpiration; + + public DecodedJWT verifyAccessToken(String token) throws JWTVerificationException { + return JWT.require(Algorithm.HMAC256(jwtSecret)).withIssuer(ISSUER).build().verify(token); + } + + public String getUserIdFromAccessToken(String token) throws JWTVerificationException { + DecodedJWT jwt = verifyAccessToken(token); + return jwt.getSubject(); + } +} diff --git a/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java b/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java index e01cd099..ecf4d153 100644 --- a/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java +++ b/src/main/java/org/runimo/runimo/auth/repository/InMemoryOAuthTokenRepository.java @@ -2,8 +2,8 @@ import lombok.RequiredArgsConstructor; import org.runimo.runimo.auth.jwt.TokenStatus; -import org.runimo.runimo.common.CacheEntry; -import org.runimo.runimo.common.InMemoryCache; +import org.runimo.runimo.common.cache.CacheEntry; +import org.runimo.runimo.common.cache.InMemoryCache; import org.runimo.runimo.user.domain.SocialProvider; import org.springframework.stereotype.Component; diff --git a/src/main/java/org/runimo/runimo/common/BaseEntity.java b/src/main/java/org/runimo/runimo/common/BaseEntity.java index 82237b96..84fc971e 100644 --- a/src/main/java/org/runimo/runimo/common/BaseEntity.java +++ b/src/main/java/org/runimo/runimo/common/BaseEntity.java @@ -9,12 +9,17 @@ import org.hibernate.annotations.CreationTimestamp; import org.hibernate.annotations.UpdateTimestamp; +import java.io.Serial; +import java.io.Serializable; import java.time.LocalDateTime; @Getter @NoArgsConstructor @MappedSuperclass -public abstract class BaseEntity { +public abstract class BaseEntity implements Serializable { + + @Serial + private static final long serialVersionUID = 1L; @Id @GeneratedValue(strategy = GenerationType.IDENTITY) protected Long id; diff --git a/src/main/java/org/runimo/runimo/common/GlobalConsts.java b/src/main/java/org/runimo/runimo/common/GlobalConsts.java new file mode 100644 index 00000000..fe72b427 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/GlobalConsts.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.common; + +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.time.ZoneId; +import java.util.Set; + +@NoArgsConstructor(access = AccessLevel.PRIVATE) +public final class GlobalConsts { + + public static final String TIME_ZONE_ID = "Asia/Seoul"; + public static final ZoneId ZONE_ID = ZoneId.of(TIME_ZONE_ID); + public static final String DEFAULT_IMG_URL = "default_img_url"; + public static final String SESSION_ATTRIBUTE_USER = "user-info"; + public static final Set WHITE_LIST_ENDPOINTS = Set.of( + "/test/auth", + "/auth", + "/swagger-ui", + "/v3/api-docs" + ); + +} diff --git a/src/main/java/org/runimo/runimo/common/CacheEntry.java b/src/main/java/org/runimo/runimo/common/cache/CacheEntry.java similarity index 92% rename from src/main/java/org/runimo/runimo/common/CacheEntry.java rename to src/main/java/org/runimo/runimo/common/cache/CacheEntry.java index 42e35050..a284d306 100644 --- a/src/main/java/org/runimo/runimo/common/CacheEntry.java +++ b/src/main/java/org/runimo/runimo/common/cache/CacheEntry.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.common; +package org.runimo.runimo.common.cache; import org.springframework.lang.Nullable; diff --git a/src/main/java/org/runimo/runimo/common/InMemoryCache.java b/src/main/java/org/runimo/runimo/common/cache/InMemoryCache.java similarity index 90% rename from src/main/java/org/runimo/runimo/common/InMemoryCache.java rename to src/main/java/org/runimo/runimo/common/cache/InMemoryCache.java index 783eaaaa..42bc9ef3 100644 --- a/src/main/java/org/runimo/runimo/common/InMemoryCache.java +++ b/src/main/java/org/runimo/runimo/common/cache/InMemoryCache.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.common; +package org.runimo.runimo.common.cache; import java.time.Duration; import java.util.Optional; diff --git a/src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java b/src/main/java/org/runimo/runimo/common/cache/SpringInMemoryCache.java similarity index 98% rename from src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java rename to src/main/java/org/runimo/runimo/common/cache/SpringInMemoryCache.java index 94123d7e..02a141b4 100644 --- a/src/main/java/org/runimo/runimo/common/SpringInMemoryCache.java +++ b/src/main/java/org/runimo/runimo/common/cache/SpringInMemoryCache.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.common; +package org.runimo.runimo.common.cache; import org.springframework.beans.factory.DisposableBean; import org.springframework.beans.factory.InitializingBean; diff --git a/src/main/java/org/runimo/runimo/common/response/ErrorResponse.java b/src/main/java/org/runimo/runimo/common/response/ErrorResponse.java new file mode 100644 index 00000000..7ea21d84 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/response/ErrorResponse.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.common.response; + +import org.runimo.runimo.exceptions.code.CustomResponseCode; + +public class ErrorResponse extends Response { + + private ErrorResponse(final String errorMessage, final String errorCode) { + super(false, errorMessage, errorCode); + } + + private ErrorResponse(final CustomResponseCode errorCode) { + super(false, errorCode); + } + + public static ErrorResponse of(final String errorMessage, final String errorCode) { + return new ErrorResponse(errorMessage, errorCode); + } + + public static ErrorResponse of(final CustomResponseCode errorCode) { + return new ErrorResponse(errorCode); + } +} + diff --git a/src/main/java/org/runimo/runimo/common/Response.java b/src/main/java/org/runimo/runimo/common/response/Response.java similarity index 92% rename from src/main/java/org/runimo/runimo/common/Response.java rename to src/main/java/org/runimo/runimo/common/response/Response.java index cd3b93b5..a3d99387 100644 --- a/src/main/java/org/runimo/runimo/common/Response.java +++ b/src/main/java/org/runimo/runimo/common/response/Response.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.common; +package org.runimo.runimo.common.response; import lombok.Getter; import org.runimo.runimo.exceptions.code.CustomResponseCode; diff --git a/src/main/java/org/runimo/runimo/common/SuccessResponse.java b/src/main/java/org/runimo/runimo/common/response/SuccessResponse.java similarity index 93% rename from src/main/java/org/runimo/runimo/common/SuccessResponse.java rename to src/main/java/org/runimo/runimo/common/response/SuccessResponse.java index a0c16f4f..a33575d0 100644 --- a/src/main/java/org/runimo/runimo/common/SuccessResponse.java +++ b/src/main/java/org/runimo/runimo/common/response/SuccessResponse.java @@ -1,4 +1,4 @@ -package org.runimo.runimo.common; +package org.runimo.runimo.common.response; import lombok.Getter; import org.runimo.runimo.exceptions.code.CustomResponseCode; diff --git a/src/main/java/org/runimo/runimo/common/scale/Distance.java b/src/main/java/org/runimo/runimo/common/scale/Distance.java new file mode 100644 index 00000000..dc87e496 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/scale/Distance.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.common.scale; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Distance implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long amount; + + public Distance(Long amount) { + this.amount = amount; + } +} diff --git a/src/main/java/org/runimo/runimo/common/scale/Pace.java b/src/main/java/org/runimo/runimo/common/scale/Pace.java new file mode 100644 index 00000000..4f8d6732 --- /dev/null +++ b/src/main/java/org/runimo/runimo/common/scale/Pace.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.common.scale; + +import jakarta.persistence.Embeddable; +import lombok.AccessLevel; +import lombok.NoArgsConstructor; + +import java.io.Serial; +import java.io.Serializable; + +@Embeddable +@NoArgsConstructor(access = AccessLevel.PROTECTED) +public class Pace implements Serializable { + @Serial + private static final long serialVersionUID = 1L; + private Long paceInMilliSeconds; + + public Pace(Long paceInMilliSeconds) { + this.paceInMilliSeconds = paceInMilliSeconds; + } +} diff --git a/src/main/java/org/runimo/runimo/config/CacheConfig.java b/src/main/java/org/runimo/runimo/config/CacheConfig.java index 3da7dc5d..db17e8be 100644 --- a/src/main/java/org/runimo/runimo/config/CacheConfig.java +++ b/src/main/java/org/runimo/runimo/config/CacheConfig.java @@ -2,8 +2,8 @@ import com.sun.security.auth.UserPrincipal; import org.runimo.runimo.auth.jwt.TokenStatus; -import org.runimo.runimo.common.InMemoryCache; -import org.runimo.runimo.common.SpringInMemoryCache; +import org.runimo.runimo.common.cache.InMemoryCache; +import org.runimo.runimo.common.cache.SpringInMemoryCache; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; diff --git a/src/main/java/org/runimo/runimo/config/SecurityConfig.java b/src/main/java/org/runimo/runimo/config/SecurityConfig.java index 9e71717e..de740b9c 100644 --- a/src/main/java/org/runimo/runimo/config/SecurityConfig.java +++ b/src/main/java/org/runimo/runimo/config/SecurityConfig.java @@ -1,34 +1,55 @@ package org.runimo.runimo.config; import lombok.RequiredArgsConstructor; +import org.runimo.runimo.auth.filters.JwtAuthenticationFilter; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; +import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; @Configuration @EnableWebSecurity @RequiredArgsConstructor public class SecurityConfig { + private final JwtAuthenticationFilter jwtAuthenticationFilter; + @Bean + @Profile({"prod", "test"}) public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { http - .csrf(csrf -> csrf.disable()) // API 기반 서비스의 경우 CSRF 비활성화 (필요에 따라 조정) - .authorizeHttpRequests(auth -> auth - .requestMatchers("/", "/login**", "/error**", "/auth/**", "/oauth2/**").permitAll() - .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**", "/v3/api-docs.yaml", "/v3/api-docs.html").permitAll() + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) + ) + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/auth/**").permitAll() .anyRequest().authenticated() ) - .oauth2Login(oauth2 -> oauth2 - .loginPage("/login") + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + @Profile("dev") + public SecurityFilterChain devSecurityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(AbstractHttpConfigurer::disable) + .sessionManagement(session -> session + .sessionCreationPolicy(SessionCreationPolicy.STATELESS) ) - .formLogin(form -> form - .loginPage("/login") - .permitAll() + .authorizeHttpRequests(authorize -> authorize + .requestMatchers("/auth/**").permitAll() + .requestMatchers("/swagger-ui/**", "/swagger-ui.html", "/v3/api-docs/**").permitAll() + .anyRequest().authenticated() ); + return http.build(); } - } diff --git a/src/main/java/org/runimo/runimo/config/SwaggerConfig.java b/src/main/java/org/runimo/runimo/config/SwaggerConfig.java index 387ed06c..43e3bc62 100644 --- a/src/main/java/org/runimo/runimo/config/SwaggerConfig.java +++ b/src/main/java/org/runimo/runimo/config/SwaggerConfig.java @@ -4,6 +4,8 @@ import io.swagger.v3.oas.models.info.Contact; import io.swagger.v3.oas.models.info.Info; import io.swagger.v3.oas.models.info.License; +import io.swagger.v3.oas.models.security.SecurityRequirement; +import io.swagger.v3.oas.models.security.SecurityScheme; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; @@ -23,7 +25,14 @@ public OpenAPI customOpenAPI() { .license(new License() .name("Apache 2.0") .url("https://www.apache.org/licenses/LICENSE-2.0")) - ); + ).addSecurityItem(new SecurityRequirement().addList("JWT")) + .components(new io.swagger.v3.oas.models.Components() + .addSecuritySchemes("JWT", + new SecurityScheme() + .name("JWT") + .type(SecurityScheme.Type.HTTP) + .scheme("bearer") + .bearerFormat("JWT"))); } } diff --git a/src/main/java/org/runimo/runimo/records/controller/RecordController.java b/src/main/java/org/runimo/runimo/records/controller/RecordController.java new file mode 100644 index 00000000..d15dff12 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/controller/RecordController.java @@ -0,0 +1,80 @@ +package org.runimo.runimo.records.controller; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.Schema; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.controller.model.RecordSaveRequest; +import org.runimo.runimo.records.controller.model.RecordUpdateRequest; +import org.runimo.runimo.records.service.usecases.RecordCreateUsecase; +import org.runimo.runimo.records.service.usecases.RecordQueryUsecase; +import org.runimo.runimo.records.service.usecases.RecordUpdateUsecase; +import org.runimo.runimo.records.service.usecases.model.RecordCreateCommand; +import org.runimo.runimo.records.service.usecases.model.RecordDetailViewResponse; +import org.runimo.runimo.records.service.usecases.model.RecordSaveResponse; +import org.runimo.runimo.user.controller.UserId; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.*; + +import java.net.URI; + +@Tag(name = "RECORD", description = "기록 관련 API") +@RequestMapping("/api/v1/records") +@RestController +@RequiredArgsConstructor +public class RecordController { + + private final RecordCreateUsecase recordCreateUsecase; + private final RecordUpdateUsecase recordUpdateUsecase; + private final RecordQueryUsecase recordQueryUsecase; + + @Operation(summary = "기록 저장", description = "기록을 저장합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "201", description = "기록 저장 성공", + content = @Content(schema = @Schema(implementation = RecordSaveResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @PostMapping + public ResponseEntity saveRecord( + @RequestBody RecordSaveRequest request + ) { + RecordSaveResponse response = recordCreateUsecase.execute(RecordCreateCommand.from(request)); + return ResponseEntity.created(URI.create("/api/v1/records/" + response.savedId())).body(response); + } + + @Operation(summary = "기록 조회", description = "기록을 조회합니다.") + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "기록 조회 성공", + content = @Content(schema = @Schema(implementation = RecordDetailViewResponse.class))), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + }) + @GetMapping("/{recordId}") + public ResponseEntity viewRecord( + @PathVariable String recordId + ) { + RecordDetailViewResponse response = recordQueryUsecase.getRecordDetailView(recordId); + return ResponseEntity.ok(response); + } + + @Operation(summary = "기록 수정", description = "기록을 수정합니다.") + @ApiResponses( + value = { + @ApiResponse(responseCode = "200", description = "기록 수정 성공"), + @ApiResponse(responseCode = "400", description = "잘못된 요청 데이터"), + @ApiResponse(responseCode = "401", description = "인증 실패") + } + ) + @PatchMapping("/{recordId}") + public ResponseEntity updateRecord( + @RequestBody RecordUpdateRequest request, + @UserId Long userId + ) { + recordUpdateUsecase.updateRecord(RecordUpdateRequest.toCommand(userId, request)); + return ResponseEntity.ok().build(); + } +} diff --git a/src/main/java/org/runimo/runimo/records/controller/model/RecordSaveRequest.java b/src/main/java/org/runimo/runimo/records/controller/model/RecordSaveRequest.java new file mode 100644 index 00000000..0f92074a --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/controller/model/RecordSaveRequest.java @@ -0,0 +1,20 @@ +package org.runimo.runimo.records.controller.model; + +import io.swagger.v3.oas.annotations.media.Schema; + +import java.time.LocalDateTime; + +@Schema(description = "사용자 달리기 기록 저장 요청 DTO") +public record RecordSaveRequest( + @Schema(description = "사용자 고유 식별자", example = "c7b3b3b3-7b3b-4b3b-8b3b-3b3b3b3b3b3b") + String userPublicId, + @Schema(description = "달리기 제목", example = "오늘의 달리기") + LocalDateTime startedAt, + @Schema(description = "달리기 시작 시각", example = "2021-10-10T10:10:10") + LocalDateTime endAt, + @Schema(description = "달린 거리 (미터)", example = "10000") + Long totalDistanceInMeters, + @Schema(description = "평균 페이스 (밀리초)", example = "300000") + Long averagePaceInMilliSeconds +) { +} diff --git a/src/main/java/org/runimo/runimo/records/controller/model/RecordUpdateRequest.java b/src/main/java/org/runimo/runimo/records/controller/model/RecordUpdateRequest.java new file mode 100644 index 00000000..2b17845a --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/controller/model/RecordUpdateRequest.java @@ -0,0 +1,32 @@ +package org.runimo.runimo.records.controller.model; + + +import io.swagger.v3.oas.annotations.media.Schema; +import org.runimo.runimo.records.service.usecases.model.RecordUpdateCommand; + +import java.time.LocalDateTime; + +@Schema(description = "사용자 달리기 기록 수정 요청 DTO") +public record RecordUpdateRequest( + @Schema(description = "달리기 제목", example = "오늘의 달리기") + String title, + @Schema(description = "달리기 시작 시각", example = "2021-10-10T10:10:10") + LocalDateTime startedAt, + @Schema(description = "달리기 종료 시각", example = "2021-10-10T10:20:10") + LocalDateTime endAt, + @Schema(description = "달린 거리 (미터)", example = "10000") + Long totalDistanceInMeters, + @Schema(description = "평균 페이스 (밀리초)", example = "300000") + Long averagePaceInMilliSeconds +) { + public static RecordUpdateCommand toCommand(Long userId, RecordUpdateRequest request) { + return RecordUpdateCommand.builder() + .editorId(userId) + .title(request.title) + .startedAt(request.startedAt) + .endAt(request.endAt) + .totalDistanceInMeters(request.totalDistanceInMeters) + .averagePaceInMilliSeconds(request.averagePaceInMilliSeconds) + .build(); + } +} diff --git a/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java new file mode 100644 index 00000000..a8a4af01 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/domain/RunningRecord.java @@ -0,0 +1,69 @@ +package org.runimo.runimo.records.domain; + + +import jakarta.persistence.Embedded; +import jakarta.persistence.Entity; +import lombok.AccessLevel; +import lombok.Builder; +import lombok.Getter; +import lombok.NoArgsConstructor; +import org.runimo.runimo.common.BaseEntity; +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.common.scale.Pace; + +import java.time.LocalDateTime; +import java.util.Objects; +import java.util.UUID; + +@Entity +@NoArgsConstructor(access = AccessLevel.PROTECTED) +@Getter +public class RunningRecord extends BaseEntity { + private String recordPublicId; + private Long userId; + private String title; + private LocalDateTime startedAt; + private LocalDateTime endAt; + @Embedded + private Distance totalDistance; + @Embedded + private Pace averagePace; + + @Builder + public RunningRecord(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace) { + this.userId = userId; + this.title = title; + this.recordPublicId = UUID.randomUUID().toString(); + this.startedAt = startedAt; + this.endAt = endAt; + this.totalDistance = totalDistance; + this.averagePace = averagePace; + } + + public static RunningRecord withoutId(Long userId, String title, LocalDateTime startedAt, LocalDateTime endAt, Distance totalDistance, Pace averagePace) { + return RunningRecord.builder() + .userId(userId) + .title(title) + .startedAt(startedAt) + .endAt(endAt) + .totalDistance(totalDistance) + .averagePace(averagePace) + .build(); + } + + public void update(RunningRecord updatedEntity) { + validateEditor(updatedEntity.userId); + this.title = updatedEntity.getTitle(); + this.startedAt = updatedEntity.getStartedAt(); + this.endAt = updatedEntity.getEndAt(); + this.averagePace = updatedEntity.averagePace; + this.recordPublicId = updatedEntity.recordPublicId; + } + + + private void validateEditor(Long editorId) { + if (editorId == null || !Objects.equals(this.userId, editorId)) { + throw new IllegalArgumentException("Invalid editor id"); + } + } +} diff --git a/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java new file mode 100644 index 00000000..e47d67fb --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/repository/RecordRepository.java @@ -0,0 +1,12 @@ +package org.runimo.runimo.records.repository; + +import org.runimo.runimo.records.domain.RunningRecord; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.stereotype.Repository; + +import java.util.Optional; + +@Repository +public interface RecordRepository extends JpaRepository { + Optional findByRecordPublicId(String id); +} diff --git a/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java new file mode 100644 index 00000000..85a53e74 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/RecordCommandService.java @@ -0,0 +1,57 @@ +package org.runimo.runimo.records.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.common.scale.Pace; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.repository.RecordRepository; +import org.runimo.runimo.records.service.usecases.model.RecordCreateCommand; +import org.runimo.runimo.records.service.usecases.model.RecordSaveResponse; +import org.runimo.runimo.records.service.usecases.model.RecordUpdateCommand; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.NoSuchElementException; + +@Component +@RequiredArgsConstructor +public class RecordCommandService { + + private final RecordRepository recordRepository; + + @Transactional + public RecordSaveResponse saveRecord(Long userId, RecordCreateCommand command) { + RunningRecord runningRecord = mapToRunningRecord(userId, command); + recordRepository.save(runningRecord); + return new RecordSaveResponse(runningRecord.getId()); + } + + @Transactional + public void updateRecord(RecordUpdateCommand command) { + RunningRecord runningRecord = recordRepository.findByRecordPublicId(command.recordPublicId()) + .orElseThrow(NoSuchElementException::new); + runningRecord.update(mapToUpdateRecord(command)); + recordRepository.save(runningRecord); + } + + private RunningRecord mapToUpdateRecord(RecordUpdateCommand command) { + return RunningRecord.withoutId( + command.editorId(), + command.title(), + command.startedAt(), + command.endAt(), + new Distance(command.totalDistanceInMeters()), + new Pace(command.averagePaceInMilliSeconds()) + ); + } + + private RunningRecord mapToRunningRecord(Long id, RecordCreateCommand command) { + return RunningRecord.builder() + .userId(id) + .startedAt(command.startedAt()) + .endAt(command.endAt()) + .averagePace(command.averagePace()) + .totalDistance(command.totalDistance()) + .build(); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/RecordFinder.java b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java new file mode 100644 index 00000000..3241fe50 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/RecordFinder.java @@ -0,0 +1,21 @@ +package org.runimo.runimo.records.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.repository.RecordRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class RecordFinder { + + private final RecordRepository recordRepository; + + @Transactional(readOnly = true) + public Optional findByPublicId(String id) { + return recordRepository.findByRecordPublicId(id); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecase.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecase.java new file mode 100644 index 00000000..84bffada --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecase.java @@ -0,0 +1,8 @@ +package org.runimo.runimo.records.service.usecases; + +import org.runimo.runimo.records.service.usecases.model.RecordCreateCommand; +import org.runimo.runimo.records.service.usecases.model.RecordSaveResponse; + +public interface RecordCreateUsecase { + RecordSaveResponse execute(RecordCreateCommand command); +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java new file mode 100644 index 00000000..2ae9048c --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordCreateUsecaseImpl.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.records.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.service.RecordCommandService; +import org.runimo.runimo.records.service.usecases.model.RecordCreateCommand; +import org.runimo.runimo.records.service.usecases.model.RecordSaveResponse; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class RecordCreateUsecaseImpl implements RecordCreateUsecase { + + private final UserFinder userFinder; + private final RecordCommandService commandService; + + @Override + public RecordSaveResponse execute(RecordCreateCommand command) { + User user = userFinder.findUserByPublicId(command.userPublicId()) + .orElseThrow(NoSuchElementException::new); + return commandService.saveRecord(user.getId(), command); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java new file mode 100644 index 00000000..c8201689 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecase.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.records.service.usecases; + +import org.runimo.runimo.records.service.usecases.model.RecordDetailViewResponse; + +public interface RecordQueryUsecase { + RecordDetailViewResponse getRecordDetailView(String publicId); +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java new file mode 100644 index 00000000..a284ab95 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordQueryUsecaseImpl.java @@ -0,0 +1,23 @@ +package org.runimo.runimo.records.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.domain.RunningRecord; +import org.runimo.runimo.records.service.RecordFinder; +import org.runimo.runimo.records.service.usecases.model.RecordDetailViewResponse; +import org.springframework.stereotype.Service; + +import java.util.NoSuchElementException; + +@Service +@RequiredArgsConstructor +public class RecordQueryUsecaseImpl implements RecordQueryUsecase { + + private final RecordFinder recordFinder; + + @Override + public RecordDetailViewResponse getRecordDetailView(String recordPublicId) { + RunningRecord runningRecord = recordFinder.findByPublicId(recordPublicId) + .orElseThrow(NoSuchElementException::new); + return RecordDetailViewResponse.from(runningRecord); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecase.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecase.java new file mode 100644 index 00000000..ef39d77f --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecase.java @@ -0,0 +1,7 @@ +package org.runimo.runimo.records.service.usecases; + +import org.runimo.runimo.records.service.usecases.model.RecordUpdateCommand; + +public interface RecordUpdateUsecase { + void updateRecord(RecordUpdateCommand command); +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecaseImpl.java b/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecaseImpl.java new file mode 100644 index 00000000..dfe7476a --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/RecordUpdateUsecaseImpl.java @@ -0,0 +1,21 @@ +package org.runimo.runimo.records.service.usecases; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.records.service.RecordCommandService; +import org.runimo.runimo.records.service.usecases.model.RecordUpdateCommand; +import org.runimo.runimo.user.service.UserFinder; +import org.springframework.stereotype.Service; + + +@Service +@RequiredArgsConstructor +public class RecordUpdateUsecaseImpl implements RecordUpdateUsecase { + + private final RecordCommandService recordCommandService; + private final UserFinder userFinder; + + @Override + public void updateRecord(RecordUpdateCommand command) { + recordCommandService.updateRecord(command); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordCreateCommand.java b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordCreateCommand.java new file mode 100644 index 00000000..99e2319d --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordCreateCommand.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.records.service.usecases.model; + +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.common.scale.Pace; +import org.runimo.runimo.records.controller.model.RecordSaveRequest; + +import java.time.LocalDateTime; + +public record RecordCreateCommand( + String userPublicId, + LocalDateTime startedAt, + LocalDateTime endAt, + Pace averagePace, + Distance totalDistance +) { + + public static RecordCreateCommand from(RecordSaveRequest request) { + return new RecordCreateCommand( + request.userPublicId(), + request.startedAt(), + request.endAt(), + new Pace(request.averagePaceInMilliSeconds()), + new Distance(request.totalDistanceInMeters()) + ); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordDetailViewResponse.java b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordDetailViewResponse.java new file mode 100644 index 00000000..1eced3a2 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordDetailViewResponse.java @@ -0,0 +1,28 @@ +package org.runimo.runimo.records.service.usecases.model; + +import lombok.Builder; +import org.runimo.runimo.common.scale.Distance; +import org.runimo.runimo.common.scale.Pace; +import org.runimo.runimo.records.domain.RunningRecord; + +import java.time.LocalDateTime; + +@Builder +public record RecordDetailViewResponse( + String title, + LocalDateTime startedAt, + LocalDateTime endAt, + Pace averagePace, + Distance totalDistance, + String imgUrl +) { + public static RecordDetailViewResponse from(RunningRecord runningRecord) { + return RecordDetailViewResponse.builder() + .title(runningRecord.getTitle()) + .startedAt(runningRecord.getStartedAt()) + .endAt(runningRecord.getEndAt()) + .averagePace(runningRecord.getAveragePace()) + .totalDistance(runningRecord.getTotalDistance()) + .build(); + } +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordSaveResponse.java b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordSaveResponse.java new file mode 100644 index 00000000..d5d0e39b --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordSaveResponse.java @@ -0,0 +1,5 @@ +package org.runimo.runimo.records.service.usecases.model; + +public record RecordSaveResponse(Long savedId +) { +} diff --git a/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordUpdateCommand.java b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordUpdateCommand.java new file mode 100644 index 00000000..2cdb0fc7 --- /dev/null +++ b/src/main/java/org/runimo/runimo/records/service/usecases/model/RecordUpdateCommand.java @@ -0,0 +1,17 @@ +package org.runimo.runimo.records.service.usecases.model; + +import lombok.Builder; + +import java.time.LocalDateTime; + +@Builder +public record RecordUpdateCommand( + Long editorId, + String recordPublicId, + String title, + LocalDateTime startedAt, + LocalDateTime endAt, + Long totalDistanceInMeters, + Long averagePaceInMilliSeconds +) { +} diff --git a/src/main/java/org/runimo/runimo/user/controller/UserController.java b/src/main/java/org/runimo/runimo/user/controller/UserController.java index b1f9a921..066d69e0 100644 --- a/src/main/java/org/runimo/runimo/user/controller/UserController.java +++ b/src/main/java/org/runimo/runimo/user/controller/UserController.java @@ -5,9 +5,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import io.swagger.v3.oas.annotations.responses.ApiResponse; import io.swagger.v3.oas.annotations.responses.ApiResponses; +import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; -import org.runimo.runimo.common.SuccessResponse; +import org.runimo.runimo.common.response.SuccessResponse; import org.runimo.runimo.user.controller.request.AuthLoginRequest; import org.runimo.runimo.user.controller.request.AuthSignupRequest; import org.runimo.runimo.user.domain.SocialProvider; @@ -24,6 +25,7 @@ import java.net.URI; +@Tag(name = "USER", description = "사용자 관련 API") @RestController @RequestMapping("/api/v1/user") @RequiredArgsConstructor diff --git a/src/main/java/org/runimo/runimo/user/controller/UserId.java b/src/main/java/org/runimo/runimo/user/controller/UserId.java new file mode 100644 index 00000000..a1de34d0 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/controller/UserId.java @@ -0,0 +1,11 @@ +package org.runimo.runimo.user.controller; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +@Retention(RetentionPolicy.RUNTIME) +@Target(ElementType.PARAMETER) +public @interface UserId { +} diff --git a/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java b/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java index 26763807..15842894 100644 --- a/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java +++ b/src/main/java/org/runimo/runimo/user/controller/request/AuthLoginRequest.java @@ -1,11 +1,15 @@ package org.runimo.runimo.user.controller.request; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import org.runimo.runimo.common.EnumValid; import org.runimo.runimo.user.domain.SocialProvider; +@Schema(description = "사용자 로그인 요청 DTO") public record AuthLoginRequest( + @Schema(description = "OIDC ID 토큰", example = "eyJhbGciOiJIUzI1NiIsInR5cCI...") @NotBlank String oidcToken, + @Schema(description = "소셜 로그인 제공자 (APPLE, KAKAO)", example = "APPLE") @NotBlank @EnumValid(enumClass = SocialProvider.class) String provider ) { } diff --git a/src/main/java/org/runimo/runimo/user/repository/UserRepository.java b/src/main/java/org/runimo/runimo/user/repository/UserRepository.java index 5164272f..8a56a351 100644 --- a/src/main/java/org/runimo/runimo/user/repository/UserRepository.java +++ b/src/main/java/org/runimo/runimo/user/repository/UserRepository.java @@ -4,7 +4,10 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.stereotype.Repository; +import java.util.Optional; + @Repository public interface UserRepository extends JpaRepository { + Optional findByPublicId(final String publicId); } diff --git a/src/main/java/org/runimo/runimo/user/service/UserFinder.java b/src/main/java/org/runimo/runimo/user/service/UserFinder.java new file mode 100644 index 00000000..77011d86 --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserFinder.java @@ -0,0 +1,26 @@ +package org.runimo.runimo.user.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.domain.User; +import org.runimo.runimo.user.repository.UserRepository; +import org.springframework.stereotype.Component; +import org.springframework.transaction.annotation.Transactional; + +import java.util.Optional; + +@Component +@RequiredArgsConstructor +public class UserFinder { + + private final UserRepository userRepository; + + @Transactional(readOnly = true) + public Optional findUserByPublicId(final String publicId) { + return userRepository.findByPublicId(publicId); + } + + @Transactional(readOnly = true) + public Optional findUserById(final Long userId) { + return userRepository.findById(userId); + } +} diff --git a/src/main/java/org/runimo/runimo/user/service/UserIdResolver.java b/src/main/java/org/runimo/runimo/user/service/UserIdResolver.java new file mode 100644 index 00000000..5298e30d --- /dev/null +++ b/src/main/java/org/runimo/runimo/user/service/UserIdResolver.java @@ -0,0 +1,38 @@ +package org.runimo.runimo.user.service; + +import lombok.RequiredArgsConstructor; +import org.runimo.runimo.user.controller.UserId; +import org.runimo.runimo.user.domain.User; +import org.springframework.core.MethodParameter; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; +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 javax.naming.NoPermissionException; + +@Component +@RequiredArgsConstructor +public class UserIdResolver implements HandlerMethodArgumentResolver { + + private final UserFinder userFinder; + + @Override + public boolean supportsParameter(MethodParameter parameter) { + return parameter.hasParameterAnnotation(UserId.class); + } + + @Override + public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer mavContainer, NativeWebRequest webRequest, WebDataBinderFactory binderFactory) throws Exception { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + throw new SecurityException("No authentication found"); + } + User user = userFinder.findUserById(Long.valueOf(authentication.getName())) + .orElseThrow(NoPermissionException::new); + return user.getId(); + } +}