diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java b/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java index 53c440a..cd01533 100644 --- a/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java +++ b/src/main/java/com/dnd/dndtravel/auth/controller/AuthController.java @@ -2,6 +2,7 @@ import com.dnd.dndtravel.auth.controller.request.AppleWithdrawRequest; import com.dnd.dndtravel.auth.controller.request.ReIssueTokenRequest; +import com.dnd.dndtravel.auth.controller.swagger.AuthControllerSwagger; import com.dnd.dndtravel.auth.service.dto.response.AppleIdTokenPayload; import com.dnd.dndtravel.auth.service.AppleOAuthService; import com.dnd.dndtravel.auth.service.JwtTokenService; @@ -20,19 +21,18 @@ @RequiredArgsConstructor @RestController -public class AuthController { +public class AuthController implements AuthControllerSwagger { private final AppleOAuthService appleOAuthService; private final JwtTokenService jwtTokenService; private final MemberService memberService; - //todo 클라이언트에서 실제 인증코드 보내주면 테스트 진행 필요 @PostMapping("/login/oauth2/apple") public ResponseEntity appleOAuthLogin(@RequestBody AppleLoginRequest appleLoginRequest) { // 클라이언트에서 준 code 값으로 apple의 IdToken Payload를 얻어온다 AppleIdTokenPayload tokenPayload = appleOAuthService.get(appleLoginRequest.appleToken()); // apple에서 가져온 유저정보를 DB에 저장 - Member member = memberService.saveMember(tokenPayload.name(), tokenPayload.email(), appleLoginRequest.selectedColor()); + Member member = memberService.saveMember(tokenPayload.email(), appleLoginRequest.selectedColor()); // 클라이언트와 주고받을 user token(access , refresh) 생성 TokenResponse tokenResponse = jwtTokenService.generateTokens(member.getId()); diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java b/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java index 4f76346..cc9bdd5 100644 --- a/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java +++ b/src/main/java/com/dnd/dndtravel/auth/controller/request/AppleLoginRequest.java @@ -1,9 +1,22 @@ package com.dnd.dndtravel.auth.controller.request; -//todo 필드값들 유효성 체크 -// todo 입력 색상 예시는 클라에서 String 타입들. RED, ORANGE, YELLOW, MELON, BLUE, PURPLE +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + +import com.dnd.dndtravel.auth.controller.request.validation.ColorValidation; +import com.dnd.dndtravel.member.domain.SelectedColor; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; + public record AppleLoginRequest( + @Schema(description = "authorization code", requiredMode = REQUIRED) + @NotBlank(message = "authorization code는 필수 입니다.") + @Size(max = 300, message = "authorization code 형식이 아닙니다") String appleToken, + + @Schema(description = "유저가 선택한 색상", requiredMode = REQUIRED) + @ColorValidation(enumClass = SelectedColor.class) String selectedColor ){ } \ No newline at end of file diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidation.java b/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidation.java new file mode 100644 index 0000000..b8c5961 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidation.java @@ -0,0 +1,16 @@ +package com.dnd.dndtravel.auth.controller.request.validation; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import jakarta.validation.Constraint; + +@Constraint(validatedBy = {ColorValidator.class}) +@Target(ElementType.FIELD) +@Retention(RetentionPolicy.RUNTIME) +public @interface ColorValidation { + String message() default "invalid region"; + Class> enumClass(); +} diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidator.java b/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidator.java new file mode 100644 index 0000000..b9b7571 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/controller/request/validation/ColorValidator.java @@ -0,0 +1,37 @@ +package com.dnd.dndtravel.auth.controller.request.validation; + +import java.util.Arrays; + +import com.dnd.dndtravel.map.controller.request.validation.RegionCondition; +import com.dnd.dndtravel.map.controller.request.validation.RegionEnum; +import com.dnd.dndtravel.member.domain.SelectedColor; + +import jakarta.validation.ConstraintValidator; +import jakarta.validation.ConstraintValidatorContext; + +public class ColorValidator implements ConstraintValidator { + + private ColorValidation annotation; + + + @Override + public void initialize(ColorValidation constraintAnnotation) { + this.annotation = constraintAnnotation; + } + + @Override + public boolean isValid(String color, ConstraintValidatorContext context) { + // 색상 없으면 예외 + if (color == null || color.isBlank()) { + return false; + } + + Object[] enumValues = this.annotation.enumClass().getEnumConstants(); + if (enumValues == null) { + return false; + } + + // 색상 Enum중 하나라도 해당되면 true + return Arrays.stream(enumValues).anyMatch(enumValue -> SelectedColor.isMatch(color)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/controller/swagger/AuthControllerSwagger.java b/src/main/java/com/dnd/dndtravel/auth/controller/swagger/AuthControllerSwagger.java new file mode 100644 index 0000000..db30608 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/controller/swagger/AuthControllerSwagger.java @@ -0,0 +1,67 @@ +package com.dnd.dndtravel.auth.controller.swagger; + +import org.springframework.http.MediaType; +import org.springframework.http.ResponseEntity; + +import com.dnd.dndtravel.auth.controller.request.AppleLoginRequest; +import com.dnd.dndtravel.auth.controller.request.ReIssueTokenRequest; +import com.dnd.dndtravel.auth.service.dto.response.ReissueTokenResponse; +import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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; + +@Tag(name = "auth", description = "인증 API") +public interface AuthControllerSwagger { + + String STATUS_CODE_400_BODY_MESSAGE = "{\"message\":\"잘못된 요청입니다\"}"; + + @Operation( + summary = "애플 OAuth login API" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 로그인", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse(responseCode = "204", description = "refresh token 재발급 필요시", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE)), + @ApiResponse( + responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = "{\"message\":\"Internal Server Error\"}") + ) + ) + }) + ResponseEntity appleOAuthLogin( + @Parameter(description = "로그인 요청 정보(authorization code, color)", required = true) + AppleLoginRequest appleLoginRequest + ); + + @Operation( + summary = "Refresh Token 재발급 API" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 발급", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = ReissueTokenResponse.class))), + @ApiResponse(responseCode = "400", description = "유효하지 않은 RefreshToken 요청시", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + @ApiResponse( + responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = "{\"message\":\"Internal Server Error\"}") + ) + ) + }) + ReissueTokenResponse reissueToken( + @Parameter(description = "refreshToken", required = true) + ReIssueTokenRequest reissueTokenRequest + ); +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java b/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java new file mode 100644 index 0000000..935bc0c --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/AppleTokenDecodingException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.auth.exception; + +public class AppleTokenDecodingException extends RuntimeException { + private static final String MESSAGE = "Apple 토큰 payload 해독실패"; + + public AppleTokenDecodingException(Exception e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java new file mode 100644 index 0000000..b3ecc54 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenDecodingException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.auth.exception; + +import io.jsonwebtoken.JwtException; + +public class JwtTokenDecodingException extends RuntimeException { + private static final String MESSAGE = "Jwt 토큰 해독 실패"; + + public JwtTokenDecodingException(JwtException e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java new file mode 100644 index 0000000..6f3af49 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/JwtTokenExpiredException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.auth.exception; + +import io.jsonwebtoken.ExpiredJwtException; + +public class JwtTokenExpiredException extends RuntimeException { + private static final String MESSAGE = "Jwt 토큰이 만료되었음"; + + public JwtTokenExpiredException(ExpiredJwtException e) { + super(MESSAGE, e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java b/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java new file mode 100644 index 0000000..6f18232 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/exception/RefreshTokenInvalidException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.auth.exception; + +public class RefreshTokenInvalidException extends RuntimeException { + private static final String MESSAGE = "유효하지 않은 RefreshToken 토큰 [refreshToken=%s]"; + + public RefreshTokenInvalidException(String refreshToken) { + super(String.format(MESSAGE, refreshToken)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java b/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java index 1e9fbe9..0589eca 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/AppleOAuthService.java @@ -43,7 +43,6 @@ @RequiredArgsConstructor @Component -@Slf4j public class AppleOAuthService { private final AppleClient appleClient; private final AppleProperties appleProperties; diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java index abe324d..327a8a5 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtProvider.java @@ -11,6 +11,9 @@ import javax.crypto.SecretKey; +import com.dnd.dndtravel.auth.exception.JwtTokenExpiredException; +import com.dnd.dndtravel.auth.exception.JwtTokenDecodingException; + @Component public class JwtProvider { private static final String CLAIM_CONTENT = "memberId"; @@ -49,14 +52,14 @@ public Claims parseClaims(String splitHeader) { Jws claims; try { claims = Jwts.parser() - .verifyWith(Keys.hmacShaKeyFor(Base64.getDecoder().decode(String.valueOf(this.secretKey)))) + .verifyWith(secretKey) .build() .parseSignedClaims(splitHeader); } catch (ExpiredJwtException e) { - throw new RuntimeException("message", e); // 유효하지 않은 토큰 + throw new JwtTokenExpiredException(e); } catch (JwtException e) { - throw new RuntimeException("message", e); // 토큰 해독 실패 + throw new JwtTokenDecodingException(e); } return claims.getPayload(); diff --git a/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java index 79b70cb..14f18bb 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/JwtTokenService.java @@ -4,6 +4,7 @@ import org.springframework.transaction.annotation.Transactional; import com.dnd.dndtravel.auth.domain.RefreshToken; +import com.dnd.dndtravel.auth.exception.RefreshTokenInvalidException; import com.dnd.dndtravel.auth.repository.RefreshTokenRepository; import com.dnd.dndtravel.auth.service.dto.response.TokenResponse; import com.dnd.dndtravel.auth.service.dto.response.ReissueTokenResponse; @@ -16,26 +17,33 @@ public class JwtTokenService { private final JwtProvider jwtProvider; private final RefreshTokenRepository refreshTokenRepository; - // todo 현재 refreshtoken하나로 계속해서 발급해주는 모델인데, 이는 리프레쉬 탈취시 보안위협있음. 추후 개선 필요 @Transactional public TokenResponse generateTokens(Long memberId) { RefreshToken refreshToken = refreshTokenRepository.findByMemberId(memberId); + // 리프레시 토큰이 없는경우 if (refreshToken == null) { String newRefreshToken = jwtProvider.refreshToken(); refreshTokenRepository.save(RefreshToken.of(memberId, newRefreshToken)); // refreshToken은 DB에 저장 return new TokenResponse(jwtProvider.accessToken(memberId), newRefreshToken); - } else if (refreshToken.isExpire()) { + } + + // 리프레시 토큰이 만료됐으면 재발급 받으라고 멘트줌 + if (refreshToken.isExpire()) { return null; } - - return new TokenResponse(jwtProvider.accessToken(memberId), null); + + // 리프레시 토큰이 DB에 존재하고 유효한경우 + refreshTokenRepository.delete(refreshToken); + String newRefreshToken = jwtProvider.refreshToken(); + refreshTokenRepository.save(RefreshToken.of(refreshToken.getMemberId(), newRefreshToken)); + return new TokenResponse(jwtProvider.accessToken(memberId), newRefreshToken); } @Transactional public ReissueTokenResponse reIssue(String token) { //validation - RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(token).orElseThrow(() -> new RuntimeException("유효하지 않은 토큰")); + RefreshToken refreshToken = refreshTokenRepository.findByRefreshToken(token).orElseThrow(() -> new RefreshTokenInvalidException(token)); //RTR refreshTokenRepository.delete(refreshToken); diff --git a/src/main/java/com/dnd/dndtravel/auth/service/MemberNameGenerator.java b/src/main/java/com/dnd/dndtravel/auth/service/MemberNameGenerator.java new file mode 100644 index 0000000..5abc14a --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/auth/service/MemberNameGenerator.java @@ -0,0 +1,33 @@ +package com.dnd.dndtravel.auth.service; + +import java.util.Random; + +import org.springframework.stereotype.Component; + +@Component +public class MemberNameGenerator { + private static final String[] ADJECTIVES = { + "가냘픈", "가엾은", "거센", "거친", "건조한", + "게으른", "고달픈", "귀여운", "그리운", "깨끗한", + "누런", "느린", "더러운", "덜된", "동그란", "뛰어난", + "무서운", "미친", "보람찬", "뽀얀", "비싼", "서툰","섣부른", + "성가신","수줍은","쏜살같은","못난", "못생긴", "무거운", "빠른", + "용감한", "행복한", "슬픈", "탐스러운", "한결같은","희망찬" + }; + + private static final String[] ANIMALS = { + "사자", "호랑이", "곰", "여우", "토끼", + "거북이", "고양이", "늑대", "강아지", "팬더", + "병아리", "망아지", "코끼리", "오징어", "바퀴벌레", + "거머리", "개미핥기", "돌고래", "순록", "올빼미", "박쥐", + "펭귄", "나무늘보", "오소리", "하이에나", "햄스터" + }; + + private final Random random = new Random(); + + public String generateRandomName() { + String adjective = ADJECTIVES[this.random.nextInt(ADJECTIVES.length)]; + String animal = ANIMALS[this.random.nextInt(ANIMALS.length)]; + return adjective + animal; + } +} diff --git a/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java index a789b2f..21c3f9f 100644 --- a/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java +++ b/src/main/java/com/dnd/dndtravel/auth/service/TokenDecoder.java @@ -2,6 +2,7 @@ import java.util.Base64; +import com.dnd.dndtravel.auth.exception.AppleTokenDecodingException; import com.fasterxml.jackson.databind.DeserializationFeature; import com.fasterxml.jackson.databind.ObjectMapper; @@ -25,7 +26,7 @@ public static T decodePayload(String token, Class targetClass) { try { return objectMapper.readValue(payload, targetClass); } catch (Exception e) { - throw new RuntimeException("Error decoding token payload", e); + throw new AppleTokenDecodingException(e); } } } diff --git a/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java b/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java new file mode 100644 index 0000000..ca037c8 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/common/CommonExceptionHandler.java @@ -0,0 +1,131 @@ +package com.dnd.dndtravel.common; + +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.MethodArgumentNotValidException; +import org.springframework.web.bind.annotation.ExceptionHandler; +import org.springframework.web.bind.annotation.RestControllerAdvice; + +import com.dnd.dndtravel.auth.exception.AppleTokenDecodingException; +import com.dnd.dndtravel.auth.exception.JwtTokenDecodingException; +import com.dnd.dndtravel.auth.exception.JwtTokenExpiredException; +import com.dnd.dndtravel.auth.exception.RefreshTokenInvalidException; +import com.dnd.dndtravel.map.exception.MemberAttractionNotFoundException; +import com.dnd.dndtravel.map.exception.MemberNotFoundException; +import com.dnd.dndtravel.map.exception.PhotoDeleteFailException; +import com.dnd.dndtravel.map.exception.PhotoEmptyException; +import com.dnd.dndtravel.map.exception.PhotoInvalidException; +import com.dnd.dndtravel.map.exception.PhotoUploadFailException; +import com.dnd.dndtravel.map.exception.RegionNotFoundException; + +import lombok.extern.slf4j.Slf4j; + +//todo 예외클래스가 많아지면 해당클래스가 길어질것으로 예상, 개선필요해보이고 보안 때문에 상태코드별로 애매하게 동일한 메시지를 전달해주고, 스웨거 문서로 상세 오류를 전달해주는데 이 구조가 적절한건지 고민해봐야한다. +@Slf4j +@RestControllerAdvice +public class CommonExceptionHandler { + + private static final String INTERNAL_SERVER_ERROR_MESSAGE = "Internal Server Error"; + private static final String BAD_REQUEST_MESSAGE = "잘못된 요청입니다"; + + @ExceptionHandler(MethodArgumentNotValidException.class) + public ResponseEntity runtimeException() { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(MemberNotFoundException.class) + public ResponseEntity runtimeException(MemberNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(MemberAttractionNotFoundException.class) + public ResponseEntity runtimeException(MemberAttractionNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(RegionNotFoundException.class) + public ResponseEntity runtimeException(RegionNotFoundException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(AppleTokenDecodingException.class) + public ResponseEntity runtimeException(AppleTokenDecodingException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(JwtTokenExpiredException.class) + public ResponseEntity runtimeException(JwtTokenExpiredException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(JwtTokenDecodingException.class) + public ResponseEntity runtimeException(JwtTokenDecodingException e) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body("토큰 인증에 실패했습니다"); + } + + @ExceptionHandler(RefreshTokenInvalidException.class) + public ResponseEntity runtimeException(RefreshTokenInvalidException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(PhotoEmptyException.class) + public ResponseEntity runtimeException(PhotoEmptyException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + @ExceptionHandler(PhotoDeleteFailException.class) + public ResponseEntity runtimeException(PhotoDeleteFailException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(PhotoUploadFailException.class) + public ResponseEntity runtimeException(PhotoUploadFailException e) { + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(PhotoInvalidException.class) + public ResponseEntity runtimeException(PhotoInvalidException e) { + return ResponseEntity + .status(HttpStatus.BAD_REQUEST) + .body(BAD_REQUEST_MESSAGE); + } + + + @ExceptionHandler(RuntimeException.class) + public ResponseEntity runtimeException(RuntimeException e) { + log.error("runtimeException = {}", e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } + + @ExceptionHandler(Exception.class) + public ResponseEntity runtimeException(Exception e) { + log.error("exception = {}", e); + return ResponseEntity + .status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(INTERNAL_SERVER_ERROR_MESSAGE); + } +} diff --git a/src/main/java/com/dnd/dndtravel/config/AuthResolver.java b/src/main/java/com/dnd/dndtravel/config/AuthResolver.java index 422ed8e..d4cb8f3 100644 --- a/src/main/java/com/dnd/dndtravel/config/AuthResolver.java +++ b/src/main/java/com/dnd/dndtravel/config/AuthResolver.java @@ -48,7 +48,8 @@ public Object resolveArgument(MethodParameter parameter, ModelAndViewContainer m Claims accessClaim = jwtProvider.parseClaims(splitHeaders[1]); //토큰에 심었던 user 식별자값 유효성 확인 - Member member = memberRepository.findById((Long)accessClaim.get(MEMBER_ID_CLAIM)) + Integer memberId = (Integer) accessClaim.get(MEMBER_ID_CLAIM); + Member member = memberRepository.findById(memberId.longValue()) .orElseThrow(() -> new RuntimeException("유효하지 않은 토큰 값")); // 컨트롤러 파라미터로 반환 diff --git a/src/main/java/com/dnd/dndtravel/config/SwaggerConfig.java b/src/main/java/com/dnd/dndtravel/config/SwaggerConfig.java index d7ff76f..2429906 100644 --- a/src/main/java/com/dnd/dndtravel/config/SwaggerConfig.java +++ b/src/main/java/com/dnd/dndtravel/config/SwaggerConfig.java @@ -7,6 +7,7 @@ @Configuration public class SwaggerConfig { + @Bean public OpenAPI openAPI() { return new OpenAPI() @@ -14,6 +15,5 @@ public OpenAPI openAPI() { .title("MAPDDANG API") .description("맵땅 앱 관련 API") .version("1.0.0")); - } } diff --git a/src/main/java/com/dnd/dndtravel/map/controller/MapController.java b/src/main/java/com/dnd/dndtravel/map/controller/MapController.java index 34208e6..3478588 100644 --- a/src/main/java/com/dnd/dndtravel/map/controller/MapController.java +++ b/src/main/java/com/dnd/dndtravel/map/controller/MapController.java @@ -1,5 +1,6 @@ package com.dnd.dndtravel.map.controller; +import org.springframework.http.MediaType; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.DeleteMapping; import org.springframework.web.bind.annotation.GetMapping; @@ -13,38 +14,35 @@ import org.springframework.web.bind.annotation.RequestPart; import org.springframework.web.multipart.MultipartFile; +import com.dnd.dndtravel.config.AuthenticationMember; import com.dnd.dndtravel.map.controller.request.RecordRequest; import com.dnd.dndtravel.map.controller.request.UpdateRecordRequest; import com.dnd.dndtravel.map.controller.request.validation.PhotoValidation; -import com.dnd.dndtravel.config.AuthenticationMember; +import com.dnd.dndtravel.map.controller.swagger.MapControllerSwagger; import com.dnd.dndtravel.map.service.MapService; import com.dnd.dndtravel.map.service.dto.response.AttractionRecordDetailViewResponse; import com.dnd.dndtravel.map.service.dto.response.AttractionRecordResponse; import com.dnd.dndtravel.map.service.dto.response.RegionResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; @Validated @RestController @RequiredArgsConstructor -public class MapController { +public class MapController implements MapControllerSwagger { private final MapService mapService; - @Tag(name = "MAP", description = "지도 API") - @Operation(summary = "전체 지역 조회", description = "전체 지역 방문 횟수를 조회합니다.") @GetMapping("/maps") public RegionResponse map(AuthenticationMember authenticationMember) { return mapService.allRegions(authenticationMember.id()); } - @PostMapping("/maps/record") + @PostMapping(value = "/maps/record", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public void memo( - @PhotoValidation @RequestPart("photos") List photos, - @RequestPart("recordRequest") RecordRequest recordRequest, - AuthenticationMember authenticationMember + AuthenticationMember authenticationMember, + @PhotoValidation @RequestPart(value = "photos", required = false) List photos, + @RequestPart("recordRequest") RecordRequest recordRequest ) { mapService.recordAttraction(recordRequest.toDto(photos), authenticationMember.id()); } @@ -53,9 +51,9 @@ public void memo( //서버에서 클라에 마지막 게시글의 ID를 줘야함 @GetMapping("/maps/history") public List findRecords( - @RequestParam long cursorNo, - @RequestParam(defaultValue = "10") int displayPerPage, - AuthenticationMember authenticationMember + AuthenticationMember authenticationMember, + @RequestParam(defaultValue = "0") long cursorNo, + @RequestParam(defaultValue = "10") int displayPerPage ) { return mapService.allRecords(authenticationMember.id(), cursorNo, displayPerPage); } @@ -63,28 +61,28 @@ public List findRecords( // 기록 단건 조회 @GetMapping("/maps/history/{recordId}") public AttractionRecordDetailViewResponse findRecord( + AuthenticationMember authenticationMember, @PathVariable long recordId ) { - long memberId = 1L; - return mapService.findOneVisitRecord(memberId, recordId); + return mapService.findOneVisitRecord(authenticationMember.id(), recordId); } - @PutMapping("/maps/history/{recordId}") + @PutMapping(value = "/maps/history/{recordId}", consumes = MediaType.MULTIPART_FORM_DATA_VALUE) public void updateRecord( + AuthenticationMember authenticationMember, @PathVariable long recordId, - @PhotoValidation @RequestPart("photos") List photos, + @PhotoValidation @RequestPart(value = "photos", required = false) List photos, @RequestPart("updateRecordRequest") UpdateRecordRequest updateRecordRequest ) { - long memberId = 1L; - mapService.updateVisitRecord(updateRecordRequest.toDto(photos), memberId, recordId); + mapService.updateVisitRecord(updateRecordRequest.toDto(photos), authenticationMember.id(), recordId); } // 기록 삭제 @DeleteMapping("/maps/history/{recordId}") public void deleteRecord( + AuthenticationMember authenticationMember, @PathVariable long recordId ) { - long memberId = 1L; - mapService.deleteRecord(memberId, recordId); + mapService.deleteRecord(authenticationMember.id(), recordId); } } diff --git a/src/main/java/com/dnd/dndtravel/map/controller/request/RecordRequest.java b/src/main/java/com/dnd/dndtravel/map/controller/request/RecordRequest.java index e0eb988..f0c8448 100644 --- a/src/main/java/com/dnd/dndtravel/map/controller/request/RecordRequest.java +++ b/src/main/java/com/dnd/dndtravel/map/controller/request/RecordRequest.java @@ -1,5 +1,8 @@ package com.dnd.dndtravel.map.controller.request; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import java.time.LocalDate; import java.util.List; @@ -9,26 +12,49 @@ import com.dnd.dndtravel.map.controller.request.validation.RegionEnum; import com.dnd.dndtravel.map.dto.RecordDto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; -//todo 회의 후 제약조건 변경 필요 -public record RecordRequest( +import lombok.Getter; +import lombok.Setter; + +/** + * record타입은 multipart로 받을때 Caused by: com.fasterxml.jackson.databind.exc.MismatchedInputException: Cannot construct instance of + * `*.request.RecordRequest` (although at least one Creator exists): + * no String-argument constructor/factory method to deserialize from String value 문제가 있었음 + * + */ +@Getter +@Setter +public class RecordRequest { + @Schema(description = "지역 이름", requiredMode = REQUIRED) @RegionEnum(enumClass = RegionCondition.class) - String region, + String region; - @NotBlank(message = "명소 이름은 필수 입력 사항입니다.") - @Pattern(regexp = "^[가-힣]+$", message = "명소 이름은 한글만 입력 가능합니다.") - @Size(max = 50, message = "명소 이름은 50자 이내여야 합니다.") - String attractionName, + @Schema(description = "명소명", requiredMode = REQUIRED) + @NotBlank(message = "명소명은 필수 입니다.") + @Size(max = 10, message = "명소 이름은 10자 이내여야 합니다.") + String attractionName; + @Schema(description = "메모", requiredMode = NOT_REQUIRED) @Size(max = 25, message = "메모는 25자 이내여야 합니다.") - String memo, + String memo; + @Schema(description = "방문날짜, ISO Date(yyyy-MM-dd) 형식으로 입력", requiredMode = NOT_REQUIRED) @NotNull(message = "날짜는 필수 입력 사항입니다.") - LocalDate localDate -) { + LocalDate localDate; + + public RecordRequest() { + } + + public RecordRequest(String region, String attractionName, String memo, LocalDate localDate) { + this.region = region; + this.attractionName = attractionName; + this.memo = memo; + this.localDate = localDate; + } + public RecordDto toDto(List photos) { return RecordDto.builder() .region(this.region) diff --git a/src/main/java/com/dnd/dndtravel/map/controller/request/UpdateRecordRequest.java b/src/main/java/com/dnd/dndtravel/map/controller/request/UpdateRecordRequest.java index 4b4a3f9..6000940 100644 --- a/src/main/java/com/dnd/dndtravel/map/controller/request/UpdateRecordRequest.java +++ b/src/main/java/com/dnd/dndtravel/map/controller/request/UpdateRecordRequest.java @@ -1,5 +1,8 @@ package com.dnd.dndtravel.map.controller.request; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import java.time.LocalDate; import java.util.List; @@ -9,23 +12,26 @@ import com.dnd.dndtravel.map.controller.request.validation.RegionEnum; import com.dnd.dndtravel.map.dto.RecordDto; +import io.swagger.v3.oas.annotations.media.Schema; import jakarta.validation.constraints.NotBlank; import jakarta.validation.constraints.NotNull; -import jakarta.validation.constraints.Pattern; import jakarta.validation.constraints.Size; public record UpdateRecordRequest( + @Schema(description = "지역 이름", requiredMode = REQUIRED) @RegionEnum(enumClass = RegionCondition.class) String region, - @NotBlank(message = "명소 이름은 필수 입력 사항입니다.") - @Pattern(regexp = "^[가-힣]+$", message = "명소 이름은 한글만 입력 가능합니다.") - @Size(max = 50, message = "명소 이름은 50자 이내여야 합니다.") + @Schema(description = "명소명", requiredMode = REQUIRED) + @NotBlank(message = "명소명은 필수 입니다.") + @Size(max = 10, message = "명소 이름은 10자 이내여야 합니다.") String attractionName, + @Schema(description = "메모", requiredMode = NOT_REQUIRED) @Size(max = 25, message = "메모는 25자 이내여야 합니다.") String memo, + @Schema(description = "방문날짜, ISO Date(yyyy-MM-dd) 형식으로 입력", requiredMode = NOT_REQUIRED) @NotNull(message = "날짜는 필수 입력 사항입니다.") LocalDate localDate ) { diff --git a/src/main/java/com/dnd/dndtravel/map/controller/swagger/AuthenticationCommonResponse.java b/src/main/java/com/dnd/dndtravel/map/controller/swagger/AuthenticationCommonResponse.java new file mode 100644 index 0000000..d6c2c60 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/controller/swagger/AuthenticationCommonResponse.java @@ -0,0 +1,50 @@ +package com.dnd.dndtravel.map.controller.swagger; + +import static io.swagger.v3.oas.annotations.enums.ParameterIn.HEADER; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +import org.springframework.http.MediaType; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +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; + +@Target(ElementType.METHOD) +@Retention(RetentionPolicy.RUNTIME) +@Operation( + parameters = { + @Parameter( + description = "Bearer JWT 토큰", + required = true, + in = HEADER + ) + } +) +@ApiResponses(value = { + @ApiResponse(responseCode = "400", description = "토큰 헤더가 비어있거나, Bearer Token 형식이 아닌경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = "{\"message\":\"잘못된 요청입니다\"}") + ) + ), + @ApiResponse( + responseCode = "401", description = "토큰의 유저정보가 유효하지 않거나 토큰 해독 실패", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = "{\"message\": \"유효하지 않은 토큰값입니다.\"}") + ) + ), + @ApiResponse( + responseCode = "500", description = "서버 오류", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = "{\"message\":\"Internal Server Error\"}") + ) + ) +}) +public @interface AuthenticationCommonResponse { +} diff --git a/src/main/java/com/dnd/dndtravel/map/controller/swagger/MapControllerSwagger.java b/src/main/java/com/dnd/dndtravel/map/controller/swagger/MapControllerSwagger.java new file mode 100644 index 0000000..9c2786a --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/controller/swagger/MapControllerSwagger.java @@ -0,0 +1,157 @@ +package com.dnd.dndtravel.map.controller.swagger; + +import java.util.List; + +import org.springframework.http.MediaType; +import org.springframework.web.multipart.MultipartFile; + +import com.dnd.dndtravel.config.AuthenticationMember; +import com.dnd.dndtravel.map.controller.request.RecordRequest; +import com.dnd.dndtravel.map.controller.request.UpdateRecordRequest; +import com.dnd.dndtravel.map.service.dto.response.AttractionRecordDetailViewResponse; +import com.dnd.dndtravel.map.service.dto.response.AttractionRecordResponse; +import com.dnd.dndtravel.map.service.dto.response.RegionResponse; + +import io.swagger.v3.oas.annotations.Operation; +import io.swagger.v3.oas.annotations.Parameter; +import io.swagger.v3.oas.annotations.media.ArraySchema; +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; + +@Tag(name = "map", description = "지도 API") +public interface MapControllerSwagger { + + String STATUS_CODE_400_BODY_MESSAGE = "{\"message\":\"잘못된 요청입니다\"}"; + + @Operation( + summary = "인증된 사용자의 모든 지역 정보 조회" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 조회", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = RegionResponse.class) + ) + ), + @ApiResponse(responseCode = "400", description = "유저가 존재하지 않는경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + RegionResponse map( + @Parameter(hidden = true) + AuthenticationMember authenticationMember + ); + + + @Operation( + summary = "인증된 사용자의 방문 기록 저장" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 저장"), + @ApiResponse(responseCode = "400", description = "잘못된 방문기록 값 입력, 지역이나 유저정보가 유효하지 않는경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + void memo( + @Parameter(hidden = true) + AuthenticationMember authenticationMember, + @Parameter(description = "사진") + List photos, + @Parameter(description = "기록 요청 정보", required = true) + RecordRequest recordRequest + ); + + @Operation( + summary = "인증된 사용자의 방문 기록 조회" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 조회", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + array = @ArraySchema(schema = @Schema(implementation = AttractionRecordResponse.class)))), + @ApiResponse(responseCode = "400", description = "잘못된 방문기록 값 입력, 유저정보가 유효하지 않은경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + List findRecords( + @Parameter(hidden = true) + AuthenticationMember authenticationMember, + @Parameter(description = "게시글의 ID값, 0 혹은 미입력시 가장최신 페이지 조회", example = "이전요청의 마지막 게시글ID가 7인경우 7로 요청시 다음 페이지 게시글 조회") + long cursorNo, + @Parameter(description = "페이지당 조회할 게시글 개수, 미입력시 10으로 지정") + int displayPerPage + ); + + @Operation( + summary = "인증된 사용자의 방문 기록 단건 조회" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상조회, 단일 JSON객체 반환", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(implementation = AttractionRecordDetailViewResponse.class))), + @ApiResponse(responseCode = "400", description = "유저정보나 방문기록이 유효하지 않은경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + AttractionRecordDetailViewResponse findRecord( + @Parameter(hidden = true) + AuthenticationMember authenticationMember, + @Parameter(description = "방문기록 id값", required = true) + long recordId + ); + + @Operation( + summary = "인증된 사용자의 방문 기록 수정" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 수정"), + @ApiResponse(responseCode = "400", description = "유저정보나 방문기록, 명소정보가 유효하지 않은경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + void updateRecord( + @Parameter(hidden = true) + AuthenticationMember authenticationMember, + @Parameter(description = "방문기록 id값", required = true) + long recordId, + @Parameter(description = "수정요청한 사진", required = false) + List photos, + @Parameter(description = "수정요청 값", required = true) + UpdateRecordRequest updateRecordRequest + ); + + @Operation( + summary = "인증된 사용자의 방문 기록 삭제" + ) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "정상 삭제"), + @ApiResponse(responseCode = "400", description = "삭제하려는 방문기록이 유효하지 않은경우", + content = @Content(mediaType = MediaType.APPLICATION_JSON_VALUE, + schema = @Schema(example = STATUS_CODE_400_BODY_MESSAGE) + ) + ), + }) + @AuthenticationCommonResponse + void deleteRecord( + @Parameter(hidden = true) + AuthenticationMember authenticationMember, + @Parameter(description = "방문기록 id값", required = true) + long recordId + ); +} diff --git a/src/main/java/com/dnd/dndtravel/map/domain/Region.java b/src/main/java/com/dnd/dndtravel/map/domain/Region.java index 5af1273..d7ce37d 100644 --- a/src/main/java/com/dnd/dndtravel/map/domain/Region.java +++ b/src/main/java/com/dnd/dndtravel/map/domain/Region.java @@ -1,8 +1,6 @@ package com.dnd.dndtravel.map.domain; import jakarta.persistence.Entity; -import jakarta.persistence.EnumType; -import jakarta.persistence.Enumerated; import jakarta.persistence.GeneratedValue; import jakarta.persistence.GenerationType; import jakarta.persistence.Id; @@ -28,8 +26,4 @@ public static Region of(String name) { private Region(String name) { this.name = name; } - - public boolean isEqualTo(String name) { - return this.name.equals(name); - } } diff --git a/src/main/java/com/dnd/dndtravel/map/domain/VisitOpacity.java b/src/main/java/com/dnd/dndtravel/map/domain/VisitOpacity.java deleted file mode 100644 index c88bbd7..0000000 --- a/src/main/java/com/dnd/dndtravel/map/domain/VisitOpacity.java +++ /dev/null @@ -1,19 +0,0 @@ -package com.dnd.dndtravel.map.domain; - -public enum VisitOpacity { - ZERO(0), ONE(1), TWO(2), THREE(3); - - private final int value; - - VisitOpacity(int value) { - this.value = value; - } - - public boolean isNotZero() { - return this != ZERO; - } - - public int toInt() { - return this.value; - } -} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java new file mode 100644 index 0000000..8082de3 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/MemberAttractionNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class MemberAttractionNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 방문기록 [memberAttractionId=%s]"; + + public MemberAttractionNotFoundException(long memberAttractionId) { + super(String.format(MESSAGE, memberAttractionId)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java new file mode 100644 index 0000000..fd78ecb --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/MemberNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class MemberNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 유저 [memberId=%s]"; + + public MemberNotFoundException(long memberId) { + super(String.format(MESSAGE, memberId)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java new file mode 100644 index 0000000..5b9c3c2 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoDeleteFailException.java @@ -0,0 +1,11 @@ +package com.dnd.dndtravel.map.exception; + +import com.amazonaws.SdkClientException; + +public class PhotoDeleteFailException extends RuntimeException{ + private static final String MESSAGE = "s3 이미지 삭제 실패 [imagePath=%s]"; + + public PhotoDeleteFailException(String existingFileName, SdkClientException e) { + super(String.format(MESSAGE,existingFileName), e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java new file mode 100644 index 0000000..307af0e --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoEmptyException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class PhotoEmptyException extends RuntimeException{ + private static final String MESSAGE = "이미지가 존재하지 않음"; + + public PhotoEmptyException() { + super(MESSAGE); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java new file mode 100644 index 0000000..f558cbd --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoInvalidException.java @@ -0,0 +1,8 @@ +package com.dnd.dndtravel.map.exception; + +public class PhotoInvalidException extends RuntimeException { + + public PhotoInvalidException(String message) { + super(message); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java b/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java new file mode 100644 index 0000000..0c4fc2a --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/PhotoUploadFailException.java @@ -0,0 +1,19 @@ +package com.dnd.dndtravel.map.exception; + +import java.io.IOException; + +import org.springframework.web.multipart.MultipartFile; + +import com.amazonaws.SdkClientException; + +public class PhotoUploadFailException extends RuntimeException { + private static final String MESSAGE = "s3 이미지 업로드 실패 [image=%s]"; + + public PhotoUploadFailException(MultipartFile image, IOException e) { + super(String.format(MESSAGE, image), e); + } + + public PhotoUploadFailException(String image, SdkClientException e) { + super(String.format(MESSAGE, image), e); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java b/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java new file mode 100644 index 0000000..7dae336 --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/map/exception/RegionNotFoundException.java @@ -0,0 +1,9 @@ +package com.dnd.dndtravel.map.exception; + +public class RegionNotFoundException extends RuntimeException { + private static final String MESSAGE = "존재하지 않는 지역 [region=%s]"; + + public RegionNotFoundException(String region) { + super(String.format(MESSAGE, region)); + } +} diff --git a/src/main/java/com/dnd/dndtravel/map/repository/PhotoRepository.java b/src/main/java/com/dnd/dndtravel/map/repository/PhotoRepository.java index d6485be..8963892 100644 --- a/src/main/java/com/dnd/dndtravel/map/repository/PhotoRepository.java +++ b/src/main/java/com/dnd/dndtravel/map/repository/PhotoRepository.java @@ -1,8 +1,6 @@ package com.dnd.dndtravel.map.repository; import java.util.List; -import java.util.Optional; - import org.springframework.data.jpa.repository.JpaRepository; import com.dnd.dndtravel.map.domain.Photo; diff --git a/src/main/java/com/dnd/dndtravel/map/repository/custom/MemberAttractionRepositoryCustom.java b/src/main/java/com/dnd/dndtravel/map/repository/custom/MemberAttractionRepositoryCustom.java index c630373..b855911 100644 --- a/src/main/java/com/dnd/dndtravel/map/repository/custom/MemberAttractionRepositoryCustom.java +++ b/src/main/java/com/dnd/dndtravel/map/repository/custom/MemberAttractionRepositoryCustom.java @@ -1,9 +1,6 @@ package com.dnd.dndtravel.map.repository.custom; import java.util.List; -import java.util.Optional; - -import com.dnd.dndtravel.map.domain.MemberAttraction; import com.dnd.dndtravel.map.repository.dto.projection.RecordProjection; public interface MemberAttractionRepositoryCustom { diff --git a/src/main/java/com/dnd/dndtravel/map/service/MapService.java b/src/main/java/com/dnd/dndtravel/map/service/MapService.java index 17c6375..b9f43bd 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/MapService.java +++ b/src/main/java/com/dnd/dndtravel/map/service/MapService.java @@ -15,6 +15,9 @@ import com.dnd.dndtravel.map.domain.MemberAttraction; import com.dnd.dndtravel.map.domain.MemberRegion; import com.dnd.dndtravel.map.domain.Region; +import com.dnd.dndtravel.map.exception.MemberAttractionNotFoundException; +import com.dnd.dndtravel.map.exception.MemberNotFoundException; +import com.dnd.dndtravel.map.exception.RegionNotFoundException; import com.dnd.dndtravel.map.repository.dto.projection.AttractionPhotoProjection; import com.dnd.dndtravel.map.repository.dto.projection.RecordProjection; import com.dnd.dndtravel.map.service.dto.RegionDto; @@ -45,8 +48,7 @@ public class MapService { @Transactional(readOnly = true) public RegionResponse allRegions(long memberId) { - //todo custom ex - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); // 유저의 지역 방문기록 전부 가져온다 List memberRegions = memberRegionRepository.findByMemberId(memberId); @@ -70,8 +72,8 @@ public RegionResponse allRegions(long memberId) { @Transactional public void recordAttraction(RecordDto recordDto, long memberId) { // validate - Region region = regionRepository.findByName(recordDto.region()).orElseThrow(() -> new RuntimeException("존재하지 않는 지역")); - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Region region = regionRepository.findByName(recordDto.region()).orElseThrow(() -> new RegionNotFoundException(recordDto.region())); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); Attraction attraction = attractionRepository.save(Attraction.of(region, recordDto.attractionName())); @@ -90,7 +92,7 @@ public void recordAttraction(RecordDto recordDto, long memberId) { @Transactional(readOnly = true) public List allRecords(long memberId, long cursorNo, int displayPerPage) { //validation - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); List memberAttractions = memberAttractionRepository.findByMemberId(memberId); if (memberAttractions.isEmpty()) { return List.of(); @@ -115,8 +117,9 @@ public List allRecords(long memberId, long cursorNo, i //기록 단건 조회 @Transactional(readOnly = true) public AttractionRecordDetailViewResponse findOneVisitRecord(long memberId, long memberAttractionId) { - Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException("존재하지 않는 유저")); - MemberAttraction memberAttraction = memberAttractionRepository.findById(memberAttractionId).orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + //todo memberAttraction 쿼리한방으로 줄일수도 있을것같다. + Member member = memberRepository.findById(memberId).orElseThrow(() -> new MemberNotFoundException(memberId)); + MemberAttraction memberAttraction = memberAttractionRepository.findById(memberAttractionId).orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); return AttractionRecordDetailViewResponse.from(memberAttraction); } @@ -124,7 +127,7 @@ public AttractionRecordDetailViewResponse findOneVisitRecord(long memberId, long @Transactional public void updateVisitRecord(RecordDto dto, long memberId, long memberAttractionId) { //validation - MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId).orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId).orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); //update memberAttraction.updateVisitRecord(dto.region(), dto.dateTime(), dto.memo()); @@ -146,7 +149,7 @@ public void updateVisitRecord(RecordDto dto, long memberId, long memberAttractio public void deleteRecord(long memberId, long memberAttractionId) { //validation MemberAttraction memberAttraction = memberAttractionRepository.findByIdAndMemberId(memberAttractionId, memberId) - .orElseThrow(() -> new RuntimeException("유효하지 않은 방문 상세 기록")); + .orElseThrow(() -> new MemberAttractionNotFoundException(memberAttractionId)); List photos = photoRepository.findByMemberAttractionId(memberAttraction.getId()); photoRepository.deleteAll(photos); @@ -212,9 +215,11 @@ private void updateRegionVisitCount(Member member, Region region) { } private void savePhotos(List photos, MemberAttraction memberAttractionEntity) { - for (MultipartFile photo : photos) { - String imageUrl = photoService.upload(photo); - photoRepository.save(Photo.of(memberAttractionEntity, imageUrl)); + if (photos != null && !photos.isEmpty()) { + for (MultipartFile photo : photos) { + String imageUrl = photoService.upload(photo); + photoRepository.save(Photo.of(memberAttractionEntity, imageUrl)); + } } } } diff --git a/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java b/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java index 4424ca3..c2a6087 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java +++ b/src/main/java/com/dnd/dndtravel/map/service/PhotoService.java @@ -16,7 +16,12 @@ import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.ObjectMetadata; import com.amazonaws.services.s3.model.PutObjectRequest; -import com.dnd.dndtravel.map.domain.Photo; + +import com.dnd.dndtravel.map.exception.PhotoDeleteFailException; +import com.dnd.dndtravel.map.exception.PhotoEmptyException; +import com.dnd.dndtravel.map.exception.PhotoInvalidException; +import com.dnd.dndtravel.map.exception.PhotoUploadFailException; + import lombok.RequiredArgsConstructor; @@ -31,7 +36,7 @@ public class PhotoService { public String upload(MultipartFile image) { if (image.isEmpty() || Objects.isNull(image.getOriginalFilename())) { - throw new RuntimeException("유효하지 않은 이미지"); + throw new PhotoEmptyException(); } validateFileExtension(image.getOriginalFilename()); return uploadImage(image); @@ -46,7 +51,7 @@ public void deleteBeforePhoto(List existingUrls) { try { amazonS3.deleteObject(bucketName, existingFileName); } catch (SdkClientException e) { - throw new RuntimeException("Failed to delete image from S3", e); + throw new PhotoDeleteFailException(existingFileName, e); } } } @@ -55,21 +60,21 @@ private String uploadImage(MultipartFile image) { try { return uploadImageToS3(image); } catch (IOException e) { - throw new RuntimeException("이미지 업로드 예외"); // custom ex (S3Exception) + throw new PhotoUploadFailException(image, e); } } private void validateFileExtension(String filename) { int lastDotIndex = filename.lastIndexOf("."); if (lastDotIndex == -1) { - throw new RuntimeException("파일 확장자가 없음"); + throw new PhotoInvalidException("파일 확장자가 없음"); } String extension = filename.substring(lastDotIndex + 1).toLowerCase(); List allowedExtentionList = Arrays.asList("jpg", "jpeg", "png"); if (!allowedExtentionList.contains(extension)) { - throw new RuntimeException("invalid file extension"); + throw new PhotoInvalidException("invalid file extension"); } } @@ -92,7 +97,7 @@ private String uploadImageToS3(MultipartFile image) throws IOException { //실제로 S3에 이미지 데이터를 넣는 부분이다. amazonS3.putObject(putObjectRequest); // put image to S3 } catch (SdkClientException e) { - throw new RuntimeException("Failed to upload image to S3", e); + throw new PhotoUploadFailException(s3FileName, e); } return amazonS3.getUrl(bucketName, s3FileName).toString(); diff --git a/src/main/java/com/dnd/dndtravel/map/service/dto/response/AttractionRecordResponse.java b/src/main/java/com/dnd/dndtravel/map/service/dto/response/AttractionRecordResponse.java index 4e8211d..45db14e 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/dto/response/AttractionRecordResponse.java +++ b/src/main/java/com/dnd/dndtravel/map/service/dto/response/AttractionRecordResponse.java @@ -1,20 +1,31 @@ package com.dnd.dndtravel.map.service.dto.response; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.NOT_REQUIRED; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import java.time.LocalDate; import java.util.List; import com.dnd.dndtravel.map.repository.dto.projection.RecordProjection; +import io.swagger.v3.oas.annotations.media.Schema; import lombok.Builder; @Builder public record AttractionRecordResponse( + @Schema(description = "방문기록 id", requiredMode = REQUIRED) long id, + @Schema(description = "방문기록 전체개수", requiredMode = REQUIRED) long entireRecordCount, + @Schema(description = "지역명", requiredMode = REQUIRED) + String region, + @Schema(description = "명소명", requiredMode = REQUIRED) String attractionName, + @Schema(description = "메모", requiredMode = NOT_REQUIRED) String memo, + @Schema(description = "방문날짜, ISO Date(yyyy-MM-dd)", requiredMode = NOT_REQUIRED) LocalDate visitDate, - String region, + @Schema(description = "이미지 URL들", requiredMode = NOT_REQUIRED) List photoUrls ) { public static AttractionRecordResponse from(RecordProjection recordProjection) { diff --git a/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java b/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java index 00fce0b..9f36e8a 100644 --- a/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java +++ b/src/main/java/com/dnd/dndtravel/map/service/dto/response/RegionResponse.java @@ -1,15 +1,26 @@ package com.dnd.dndtravel.map.service.dto.response; +import static io.swagger.v3.oas.annotations.media.Schema.RequiredMode.REQUIRED; + import com.dnd.dndtravel.map.service.dto.RegionDto; import com.dnd.dndtravel.member.domain.SelectedColor; import java.util.List; +import io.swagger.v3.oas.annotations.media.Schema; + public record RegionResponse( - List regions, // 지역별 opacity 정보, 땅 이름 - int visitCount, // 방문 지도 개수 - int totalCount, // 전체 땅 개수 - SelectedColor selectedColor // 선택된 컬러 + @Schema(description = "지역의 이름, opacity", requiredMode = REQUIRED) + List regions, + + @Schema(description = "방문 지역 개수", requiredMode = REQUIRED) + int visitCount, + + @Schema(description = "전체 지역 개수", requiredMode = REQUIRED) + int totalCount, + + @Schema(description = "유저가 선택한 색상", requiredMode = REQUIRED) + SelectedColor selectedColor ) { private static final int TOTAL_COUNT = 16; // 전체 지역구의 개수, 변경가능성이 낮아 16이라는 상수로 고정 diff --git a/src/main/java/com/dnd/dndtravel/member/controller/MemberController.java b/src/main/java/com/dnd/dndtravel/member/controller/MemberController.java new file mode 100644 index 0000000..f16dc1e --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/member/controller/MemberController.java @@ -0,0 +1,22 @@ +package com.dnd.dndtravel.member.controller; + +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RestController; + +import com.dnd.dndtravel.config.AuthenticationMember; +import com.dnd.dndtravel.member.service.MemberService; +import com.dnd.dndtravel.member.service.response.MyPageResponse; + +import lombok.RequiredArgsConstructor; + +@RequiredArgsConstructor +@RestController +public class MemberController { + + private final MemberService memberService; + + @GetMapping("/mypages") + public MyPageResponse myPage(AuthenticationMember authenticationMember) { + return memberService.myPageInfo(authenticationMember.id()); + } +} diff --git a/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java b/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java index 61dc5a1..cd6fd13 100644 --- a/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java +++ b/src/main/java/com/dnd/dndtravel/member/domain/SelectedColor.java @@ -24,4 +24,9 @@ public static SelectedColor convertToEnum(String selectedColor) { .filter(color -> color.getValue().equalsIgnoreCase(selectedColor)) .findAny().orElseThrow(() -> new RuntimeException("존재하지 않는 색상입니다")); } + + public static boolean isMatch(String color) { + return Arrays.stream(SelectedColor.values()) + .anyMatch(selectedColor -> selectedColor.getValue().equals(color)); + } } diff --git a/src/main/java/com/dnd/dndtravel/member/service/MemberService.java b/src/main/java/com/dnd/dndtravel/member/service/MemberService.java index 6b18278..718f672 100644 --- a/src/main/java/com/dnd/dndtravel/member/service/MemberService.java +++ b/src/main/java/com/dnd/dndtravel/member/service/MemberService.java @@ -3,8 +3,11 @@ import com.dnd.dndtravel.auth.repository.RefreshTokenRepository; import com.dnd.dndtravel.map.repository.MemberAttractionRepository; import com.dnd.dndtravel.map.repository.MemberRegionRepository; +import com.dnd.dndtravel.auth.service.MemberNameGenerator; import com.dnd.dndtravel.member.domain.Member; import com.dnd.dndtravel.member.repository.MemberRepository; +import com.dnd.dndtravel.member.service.response.MyPageResponse; + import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -17,9 +20,11 @@ public class MemberService { private final MemberAttractionRepository memberAttractionRepository; private final MemberRegionRepository memberRegionRepository; private final RefreshTokenRepository refreshTokenRepository; + private final MemberNameGenerator memberNameGenerator; @Transactional - public Member saveMember(String name, String email, String selectedColor) { + public Member saveMember(String email, String selectedColor) { + String name = memberNameGenerator.generateRandomName(); return memberRepository.findByEmail(email) .orElseGet(() -> memberRepository.save(Member.of(name, email,selectedColor))); } @@ -34,4 +39,10 @@ public void withdrawMember(long memberId) { memberRegionRepository.deleteById(memberId); refreshTokenRepository.deleteById(memberId); } + + @Transactional(readOnly = true) + public MyPageResponse myPageInfo(long memberId) { + Member member = memberRepository.findById(memberId).orElseThrow(() -> new RuntimeException()); + return new MyPageResponse(member.getName()); + } } diff --git a/src/main/java/com/dnd/dndtravel/member/service/response/MyPageResponse.java b/src/main/java/com/dnd/dndtravel/member/service/response/MyPageResponse.java new file mode 100644 index 0000000..ed4a4ca --- /dev/null +++ b/src/main/java/com/dnd/dndtravel/member/service/response/MyPageResponse.java @@ -0,0 +1,6 @@ +package com.dnd.dndtravel.member.service.response; + +public record MyPageResponse( + String name +) { +} diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 2da883f..1de3446 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -14,6 +14,10 @@ spring: hibernate: format_sql: true +logging: + level: + root: INFO # 로그 레벨 설정 + social-login: provider: apple: