diff --git a/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequestV2.java b/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequestV2.java new file mode 100644 index 00000000..33042483 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/auth/dto/AppleLoginRequestV2.java @@ -0,0 +1,15 @@ +package ssu.eatssu.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.global.log.annotation.LogMask; + +// 애플 계정을 통해서 갤럭시 기기에도 접속을 할 수도 있다고 생각해서, DeviceType을 받도록 설계 +@Schema(title = "애플 로그인 및 회원가입 V2") +public record AppleLoginRequestV2( + @LogMask + @Schema(description = "identityToken", example = "eyJraWQiOiJXNldjT0tCIiwiYWxnIjoi...") + String identityToken, + @Schema(description = "deviceType", example = "IOS") + DeviceType deviceType +) {} diff --git a/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequestV2.java b/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequestV2.java new file mode 100644 index 00000000..20baf406 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/auth/dto/KakaoLoginRequestV2.java @@ -0,0 +1,21 @@ +package ssu.eatssu.domain.auth.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import ssu.eatssu.domain.user.entity.DeviceType; +import ssu.eatssu.global.log.annotation.LogMask; + +@Schema(title = "카카오 로그인 및 회원가입 V2") +public record KakaoLoginRequestV2( + @LogMask + @NotBlank(message = "이메일을 입력해주세요.") + @Email(message = "올바른 이메일 주소를 입력해주세요.") + @Schema(description = "이메일", example = "test@email.com") + String email, + @LogMask + @Schema(description = "providerId", example = "10378247832195") + String providerId, + @Schema(description = "deviceType", example = "IOS") + DeviceType deviceType +) {} diff --git a/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java b/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java index 959cfc3a..3072cee1 100644 --- a/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java +++ b/src/main/java/ssu/eatssu/domain/auth/infrastructure/SecurityConfig.java @@ -27,7 +27,7 @@ public class SecurityConfig { }; private static final String[] AUTH_WHITELIST = { - "/", "/oauths/kakao", "/oauths/apple", "/menus/**", "/meals/**", "/admin/login", + "/", "/oauths/kakao", "/oauths/apple", "/menus/**", "/meals/**", "/admin/login", "/oauths/v2/kakao","/oauths/v2/apple", "/reviews", "/reviews/menus/**", "/reviews/meals/**", "/v2/reviews/statistics/**", "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/**", "/error-test/**" }; diff --git a/src/main/java/ssu/eatssu/domain/auth/presentation/OAuthController.java b/src/main/java/ssu/eatssu/domain/auth/presentation/OAuthController.java index 1ba3868f..d425ae70 100644 --- a/src/main/java/ssu/eatssu/domain/auth/presentation/OAuthController.java +++ b/src/main/java/ssu/eatssu/domain/auth/presentation/OAuthController.java @@ -13,9 +13,7 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; -import ssu.eatssu.domain.auth.dto.AppleLoginRequest; -import ssu.eatssu.domain.auth.dto.KakaoLoginRequest; -import ssu.eatssu.domain.auth.dto.ValidRequest; +import ssu.eatssu.domain.auth.dto.*; import ssu.eatssu.domain.auth.service.OAuthService; import ssu.eatssu.domain.user.dto.Tokens; import ssu.eatssu.global.handler.response.BaseResponse; @@ -31,6 +29,7 @@ public class OAuthController { private final OAuthService oauthService; + // TODO : 로그인 & 회원 가입 마이그레이션 이후에 지울 것. @Operation(summary = "카카오 회원가입, 로그인 [인증 토큰 필요 X]", description = """ 카카오 회원가입, 로그인 API 입니다.

가입된 회원일 경우 카카오 로그인, 미가입 회원일 경우 회원가입 후 자동 로그인됩니다. @@ -49,6 +48,25 @@ public BaseResponse kakaoLogin(@Valid @RequestBody KakaoLoginRequest req return BaseResponse.success(tokens); } + @Operation(summary = "카카오 회원가입, 로그인 V2 [인증 토큰 필요 X]", description = """ + 카카오 회원가입, 로그인 V2 API 입니다.

+ 가입된 회원일 경우 카카오 로그인, 미가입 회원일 경우 회원가입 후 자동 로그인됩니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "카카오 회원가입/로그인 성공") + }) + @PostMapping("/v2/kakao") + public BaseResponse kakaoLoginV2(@Valid @RequestBody KakaoLoginRequestV2 request) { + long startTime = System.currentTimeMillis(); + Tokens tokens = oauthService.kakaoLoginV2(request); + long endTime = System.currentTimeMillis(); + long duration = endTime - startTime; + log.info("OAuthWarmupRunner 완료 - 소요 시간: {} ms", duration); + + return BaseResponse.success(tokens); + } + + // TODO : 로그인 & 회원 가입 마이그레이션 이후에 지울 것. @Operation(summary = "애플 회원가입, 로그인 [인증 토큰 필요 X]", description = """ 애플 로그인, 회원가입 API 입니다.

가입된 회원일 경우 카카오 로그인, 미가입 회원일 경우 회원가입 후 자동 로그인됩니다. @@ -62,6 +80,19 @@ public BaseResponse appleLogin(@Valid @RequestBody AppleLoginRequest req return BaseResponse.success(tokens); } + @Operation(summary = "애플 회원가입, 로그인 V2 [인증 토큰 필요 X]", description = """ + 애플 로그인, 회원가입 API V2 입니다.

+ 가입된 회원일 경우 카카오 로그인, 미가입 회원일 경우 회원가입 후 자동 로그인됩니다. + """) + @ApiResponses(value = { + @ApiResponse(responseCode = "200", description = "애플 회원가입/로그인 성공") + }) + @PostMapping("/v2/apple") + public BaseResponse appleLoginV2(@Valid @RequestBody AppleLoginRequestV2 request) { + Tokens tokens = oauthService.appleLoginV2(request); + return BaseResponse.success(tokens); + } + @Operation(summary = "토큰 재발급", description = "accessToken, refreshToken 재발급 API 입니다.") @ApiResponses(value = { @ApiResponse(responseCode = "200", description = "토큰 재발급 성공") diff --git a/src/main/java/ssu/eatssu/domain/auth/security/CustomUserDetails.java b/src/main/java/ssu/eatssu/domain/auth/security/CustomUserDetails.java index eb05c2a5..f24179e3 100644 --- a/src/main/java/ssu/eatssu/domain/auth/security/CustomUserDetails.java +++ b/src/main/java/ssu/eatssu/domain/auth/security/CustomUserDetails.java @@ -5,6 +5,7 @@ import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.authority.SimpleGrantedAuthority; import org.springframework.security.core.userdetails.UserDetails; +import ssu.eatssu.domain.user.entity.DeviceType; import ssu.eatssu.domain.user.entity.User; import java.util.ArrayList; @@ -17,12 +18,14 @@ public class CustomUserDetails implements UserDetails { private final String email; private final String credentials; private final GrantedAuthority role; + private final DeviceType deviceType; public CustomUserDetails(User user) { this.id = user.getId(); this.email = user.getEmail(); this.credentials = user.getCredentials(); this.role = user.getRole(); + this.deviceType = user.getDeviceType(); } @Override diff --git a/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java b/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java index 327108b4..46dab1fb 100644 --- a/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java +++ b/src/main/java/ssu/eatssu/domain/auth/security/JwtAuthenticationFilter.java @@ -31,8 +31,8 @@ public class JwtAuthenticationFilter extends GenericFilterBean { "/v2/reviews/menus/**", "/v2/reviews/meals/**", "/actuator/**", "/error-test/**", "/swagger-ui.html", "/swagger-ui/**", "/v3/api-docs/**", "/swagger-resources/**", "/oauths/valid/token", "/admin/img/**", "/css/**", "/js/**", - "/favicon.ico", "/error/**", "/webjars/**", "/h2-console/**" - ); + "/favicon.ico", "/error/**", "/webjars/**", "/h2-console/**", + "/oauths/v2/kakao", "/oauths/v2/apple"); private final JwtTokenProvider jwtTokenProvider; @Override diff --git a/src/main/java/ssu/eatssu/domain/auth/security/JwtTokenProvider.java b/src/main/java/ssu/eatssu/domain/auth/security/JwtTokenProvider.java index e13ffaa1..2efd9a99 100644 --- a/src/main/java/ssu/eatssu/domain/auth/security/JwtTokenProvider.java +++ b/src/main/java/ssu/eatssu/domain/auth/security/JwtTokenProvider.java @@ -146,7 +146,7 @@ public Authentication getAuthentication(String token) throws JsonProcessingExcep UserPrincipalDto userPrincipalDto = objectMapper.readValue(claims.getSubject(), UserPrincipalDto.class); CustomUserDetails principal = new CustomUserDetails(userPrincipalDto.getId(), userPrincipalDto.getEmail(), "", - authorities.get(0)); + authorities.get(0),userPrincipalDto.getDeviceType()); return new UsernamePasswordAuthenticationToken(principal, token, authorities); } diff --git a/src/main/java/ssu/eatssu/domain/auth/security/UserPrincipalDto.java b/src/main/java/ssu/eatssu/domain/auth/security/UserPrincipalDto.java index d2d189e6..163756f7 100644 --- a/src/main/java/ssu/eatssu/domain/auth/security/UserPrincipalDto.java +++ b/src/main/java/ssu/eatssu/domain/auth/security/UserPrincipalDto.java @@ -4,6 +4,7 @@ import lombok.Builder; import lombok.Getter; import lombok.NoArgsConstructor; +import ssu.eatssu.domain.user.entity.DeviceType; @Getter @Builder @@ -12,13 +13,15 @@ public class UserPrincipalDto { private Long id; private String email; + private DeviceType deviceType; private String role; public static UserPrincipalDto from(CustomUserDetails userDetails) { return UserPrincipalDto.builder() .id(userDetails.getId()) .email(userDetails.getEmail()) + .deviceType(userDetails.getDeviceType()) .role(userDetails.getRole().getAuthority()) .build(); } -} \ No newline at end of file +} diff --git a/src/main/java/ssu/eatssu/domain/auth/service/OAuthService.java b/src/main/java/ssu/eatssu/domain/auth/service/OAuthService.java index 7c263e70..2d6e7ad3 100644 --- a/src/main/java/ssu/eatssu/domain/auth/service/OAuthService.java +++ b/src/main/java/ssu/eatssu/domain/auth/service/OAuthService.java @@ -6,15 +6,13 @@ import org.springframework.security.core.Authentication; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import ssu.eatssu.domain.auth.dto.AppleLoginRequest; -import ssu.eatssu.domain.auth.dto.KakaoLoginRequest; -import ssu.eatssu.domain.auth.dto.OAuthInfo; -import ssu.eatssu.domain.auth.dto.ValidRequest; +import ssu.eatssu.domain.auth.dto.*; import ssu.eatssu.domain.auth.entity.AppleAuthenticator; import ssu.eatssu.domain.auth.entity.OAuthProvider; import ssu.eatssu.domain.auth.security.JwtTokenProvider; import ssu.eatssu.domain.auth.util.RandomNicknameUtil; import ssu.eatssu.domain.user.dto.Tokens; +import ssu.eatssu.domain.user.entity.DeviceType; import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.domain.user.service.UserService; @@ -41,6 +39,21 @@ public Tokens kakaoLogin(KakaoLoginRequest request) { return generateOauthJwtTokens(user.getEmail(), KAKAO, request.providerId()); } + /** + * V1 -> V2로 넘어가면서 DeviceType(IOS,ANDROID) 정보를 추가로 받게 되었고, 기존에 가입한 유저들은 추가로 기입해 주게 됩니다. + */ + public Tokens kakaoLoginV2(KakaoLoginRequestV2 request) { + User user = userRepository.findByProviderId(request.providerId()) + .orElseGet(() -> userService.joinV2(request.email(), KAKAO, request.providerId(),request.deviceType())); + + if (user.getDeviceType() == null) { + user.updateDeviceType(request.deviceType()); + } + + return generateOauthJwtTokens(user.getEmail(), KAKAO, request.providerId()); + } + + public Tokens appleLogin(AppleLoginRequest request) { OAuthInfo oAuthInfo = appleAuthenticator.getOAuthInfoByIdentityToken(request.identityToken()); @@ -52,6 +65,24 @@ public Tokens appleLogin(AppleLoginRequest request) { return generateOauthJwtTokens(user.getEmail(), APPLE, oAuthInfo.providerId()); } + /** + * V1 -> V2로 넘어가면서 DeviceType(IOS,ANDROID) 정보를 추가로 받게 되었고, 기존에 가입한 유저들은 추가로 기입해 주게 됩니다. + */ + public Tokens appleLoginV2(AppleLoginRequestV2 request) { + OAuthInfo oAuthInfo = appleAuthenticator.getOAuthInfoByIdentityToken(request.identityToken()); + + User user = userRepository.findByProviderId(oAuthInfo.providerId()) + .orElseGet(() -> userService.joinV2(oAuthInfo.email(), APPLE, oAuthInfo.providerId(),request.deviceType())); + + updateAppleUserEmail(user, oAuthInfo.email()); + + if (user.getDeviceType() == null) { + user.updateDeviceType(request.deviceType()); + } + + return generateOauthJwtTokens(user.getEmail(), APPLE, oAuthInfo.providerId()); + } + public Tokens refreshTokens(Authentication authentication) { return jwtTokenProvider.generateTokens(authentication); } @@ -97,5 +128,4 @@ private Tokens generateOauthJwtTokens(String email, OAuthProvider provider, Stri private String makeOauthCredentials(OAuthProvider provider, String providerId) { return provider + providerId; } - } diff --git a/src/main/java/ssu/eatssu/domain/inquiry/presentation/InquiryController.java b/src/main/java/ssu/eatssu/domain/inquiry/presentation/InquiryController.java index b5e346b9..081a050b 100644 --- a/src/main/java/ssu/eatssu/domain/inquiry/presentation/InquiryController.java +++ b/src/main/java/ssu/eatssu/domain/inquiry/presentation/InquiryController.java @@ -21,6 +21,10 @@ import ssu.eatssu.domain.slack.service.SlackService; import ssu.eatssu.global.handler.response.BaseResponse; +/** + * 문의하기는 카카오톡으로 이동되어 사용되지 않고 있습니다. + */ +@Deprecated @RestController @RequiredArgsConstructor @RequestMapping("/inquiries") diff --git a/src/main/java/ssu/eatssu/domain/review/dto/MealReviewResponse.java b/src/main/java/ssu/eatssu/domain/review/dto/MealReviewResponse.java index b6eeca59..7412264d 100644 --- a/src/main/java/ssu/eatssu/domain/review/dto/MealReviewResponse.java +++ b/src/main/java/ssu/eatssu/domain/review/dto/MealReviewResponse.java @@ -1,17 +1,17 @@ package ssu.eatssu.domain.review.dto; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Builder; -import lombok.Getter; -import ssu.eatssu.domain.review.entity.Review; -import ssu.eatssu.domain.review.entity.ReviewMenuLike; - import java.time.LocalDate; import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Set; import java.util.stream.Collectors; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Getter; +import ssu.eatssu.domain.review.entity.Review; +import ssu.eatssu.domain.review.entity.ReviewMenuLike; @AllArgsConstructor @Builder @@ -31,7 +31,7 @@ public class MealReviewResponse { private String writerNickname; @Schema(description = "평점", example = "4") - private Double rating; + private Integer rating; @Schema(description = "리뷰 작성 날짜(format = yyyy-MM-dd)", example = "2023-04-07") private LocalDate writtenAt; @@ -60,26 +60,40 @@ public class MealReviewResponse { public static MealReviewResponse from(Review review, Long userId, - List validMenus,Double rating) { + List validMenus, Integer rating) { List imageUrls = new ArrayList<>(); review.getReviewImages().forEach(i -> imageUrls.add(i.getImageUrl())); - // 좋아요한 메뉴 ID 모음 - Set likedMenuIds = review.getMenuLikes().stream() - .filter(ReviewMenuLike::getIsLike) - .map(like -> like.getMenu().getId()) - .collect(Collectors.toSet()); - - List menuNames = validMenus.stream() - .map(valid -> new MenuIdNameLikeDto( - valid.getMenuId(), - valid.getName(), - likedMenuIds.contains(valid.getMenuId()) - )) - .toList(); + List menuNames; + if (review.getMeal() != null) { + Set likedMenuIds = review.getMenuLikes().stream() + .filter(ReviewMenuLike::getIsLike) + .map(like -> like.getMenu().getId()) + .collect(Collectors.toSet()); + + menuNames = validMenus.stream() + .map(valid -> new MenuIdNameLikeDto( + valid.getMenuId(), + valid.getName(), + likedMenuIds.contains(valid.getMenuId()) + )) + .toList(); + } else if (review.getMenu() != null) { + menuNames = Collections.singletonList( + new MenuIdNameLikeDto(review.getMenu().getId(), + review.getMenu().getName(), + false) + ); + } else { + menuNames = Collections.emptyList(); + } + Integer resolvedRating = (review.getRating() != null) + ? review.getRating() + : (review.getRatings() != null ? review.getRatings().getMainRating() : null); + MealReviewResponseBuilder builder = MealReviewResponse.builder() .reviewId(review.getId()) - .rating(rating) + .rating(resolvedRating) .writtenAt(review.getCreatedDate().toLocalDate()) .content(review.getContent()) .imageUrls(imageUrls) diff --git a/src/main/java/ssu/eatssu/domain/review/dto/ReviewDetail.java b/src/main/java/ssu/eatssu/domain/review/dto/ReviewDetail.java index ca5979cd..32db01aa 100644 --- a/src/main/java/ssu/eatssu/domain/review/dto/ReviewDetail.java +++ b/src/main/java/ssu/eatssu/domain/review/dto/ReviewDetail.java @@ -1,6 +1,11 @@ package ssu.eatssu.domain.review.dto; import io.swagger.v3.oas.annotations.media.Schema; +import java.time.LocalDate; +import java.util.ArrayList; +import java.util.List; +import java.util.Set; +import java.util.stream.Collectors; import lombok.AllArgsConstructor; import lombok.Builder; import lombok.Getter; @@ -8,12 +13,6 @@ import ssu.eatssu.domain.review.entity.Review; import ssu.eatssu.domain.review.entity.ReviewMenuLike; -import java.time.LocalDate; -import java.util.ArrayList; -import java.util.List; -import java.util.Set; -import java.util.stream.Collectors; - @AllArgsConstructor @Builder @Schema(title = "리뷰 상세") @@ -58,9 +57,13 @@ public static ReviewDetail from(Review review, Long userId) { .map(like -> like.getMenu().getId()) .collect(Collectors.toSet()); + Integer rating = (review.getRating() != null) + ? review.getRating() + : review.getRatings().getMainRating(); + ReviewDetailBuilder builder = ReviewDetail.builder() .reviewId(review.getId()) - .rating(review.getRatings().getMainRating()) + .rating(rating) .writtenAt(review.getCreatedDate().toLocalDate()) .content(review.getContent()) .imageUrls(imageUrls) diff --git a/src/main/java/ssu/eatssu/domain/review/dto/UploadReviewRequest.java b/src/main/java/ssu/eatssu/domain/review/dto/UploadReviewRequest.java index 9963d290..8d0e2d6e 100644 --- a/src/main/java/ssu/eatssu/domain/review/dto/UploadReviewRequest.java +++ b/src/main/java/ssu/eatssu/domain/review/dto/UploadReviewRequest.java @@ -1,7 +1,6 @@ package ssu.eatssu.domain.review.dto; import io.swagger.v3.oas.annotations.media.Schema; -import jakarta.validation.constraints.Max; import jakarta.validation.constraints.Size; import lombok.AccessLevel; import lombok.Getter; @@ -41,7 +40,7 @@ public UploadReviewRequest(int mainRating, String content) { } public Review toReviewEntity(User user, Menu menu) { - Ratings ratings = Ratings.of(this.mainRating,this.amountRating,this.tasteRating); + Ratings ratings = Ratings.of(this.mainRating, this.amountRating, this.tasteRating); return Review.builder() .user(user) .content(this.content) diff --git a/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java b/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java index d817cfcb..e4c87a4c 100644 --- a/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java +++ b/src/main/java/ssu/eatssu/domain/review/repository/ReviewRepository.java @@ -1,5 +1,7 @@ package ssu.eatssu.domain.review.repository; +import java.util.Collection; +import java.util.List; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; import org.springframework.data.jpa.repository.JpaRepository; @@ -9,9 +11,6 @@ import ssu.eatssu.domain.menu.entity.Menu; import ssu.eatssu.domain.review.entity.Review; -import java.util.Collection; -import java.util.List; - public interface ReviewRepository extends JpaRepository, ReviewRepositoryCustom { List findAllByMenu(Menu menu); @@ -31,4 +30,22 @@ public interface ReviewRepository extends JpaRepository, ReviewRep Page findReviewsByMealIds(@Param("mealIds") List mealIds, @Param("lastReviewId") Long lastReviewId, Pageable pageable); + + @Query(""" + SELECT r FROM Review r + WHERE (r.meal.id = :mealId + OR r.menu.id IN (SELECT mm.menu.id FROM MealMenu mm WHERE mm.meal.id = :mealId)) + """) + List findAllMealAndMenuReviews(@Param("mealId") Long mealId); + + @Query(""" + SELECT r FROM Review r + WHERE (r.meal.id = :mealId + OR r.menu.id IN (SELECT mm.menu.id FROM MealMenu mm WHERE mm.meal.id = :mealId)) + AND (:lastReviewId IS NULL OR r.id < :lastReviewId) + ORDER BY r.id DESC + """) + Page findMealAndMenuReviews(@Param("mealId") Long mealId, + @Param("lastReviewId") Long lastReviewId, + Pageable pageable); } diff --git a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java index 190ff9f5..9de77832 100644 --- a/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java +++ b/src/main/java/ssu/eatssu/domain/review/service/ReviewServiceV2.java @@ -1,5 +1,17 @@ package ssu.eatssu.domain.review.service; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MEAL; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MENU; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_REVIEW; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; +import static ssu.eatssu.global.handler.response.BaseResponseStatus.REVIEW_PERMISSION_DENIED; + +import java.util.Collections; +import java.util.List; +import java.util.Map; +import java.util.Objects; +import java.util.Optional; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.context.ApplicationEventPublisher; @@ -23,13 +35,13 @@ import ssu.eatssu.domain.review.dto.MenuIdNameDto; import ssu.eatssu.domain.review.dto.MenuLikeRequest; import ssu.eatssu.domain.review.dto.MenuReviewsV2Response; +import ssu.eatssu.domain.review.dto.RatingAverages; import ssu.eatssu.domain.review.dto.RestaurantReviewResponse; import ssu.eatssu.domain.review.dto.ReviewDetail; import ssu.eatssu.domain.review.dto.ReviewRatingCount; import ssu.eatssu.domain.review.dto.UpdateMealReviewRequest; import ssu.eatssu.domain.review.dto.ValidMenuForViewResponse; import ssu.eatssu.domain.review.entity.Review; -import ssu.eatssu.domain.review.repository.ReviewImageRepository; import ssu.eatssu.domain.review.repository.ReviewRepository; import ssu.eatssu.domain.review.utils.MenuFilterUtil; import ssu.eatssu.domain.slice.dto.SliceResponse; @@ -39,19 +51,6 @@ import ssu.eatssu.global.handler.response.BaseException; import ssu.eatssu.global.log.event.LogEvent; -import java.util.Collections; -import java.util.List; -import java.util.Map; -import java.util.Objects; -import java.util.Optional; -import java.util.stream.Collectors; - -import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MEAL; -import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_MENU; -import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_REVIEW; -import static ssu.eatssu.global.handler.response.BaseResponseStatus.NOT_FOUND_USER; -import static ssu.eatssu.global.handler.response.BaseResponseStatus.REVIEW_PERMISSION_DENIED; - @Slf4j @Service @RequiredArgsConstructor @@ -61,9 +60,7 @@ public class ReviewServiceV2 { private final MenuRepository menuRepository; private final MealRepository mealRepository; private final MealMenuRepository mealMenuRepository; - private final ReviewImageRepository reviewImageRepository; private final ApplicationEventPublisher eventPublisher; - private final MealRatingService mealRatingService; /** @@ -207,14 +204,8 @@ public SliceResponse findMealReviewList(Long mealId, Long la return SliceResponse.empty(); } - List validMenuIds = validMenus.stream().map(ValidMenuForViewResponse.MenuDto::getMenuId).toList(); - List mealIds = mealMenuRepository.findMealIdsByMenuIds(validMenuIds); - if (mealIds.isEmpty()) { - log.warn("No related mealIds found for validMenuIds={} in mealId={}", validMenuIds, mealId); - return SliceResponse.empty(); - } - - Page pageReviews = reviewRepository.findReviewsByMealIds(mealIds, lastReviewId, pageable); + Page pageReviews = reviewRepository.findMealAndMenuReviews(mealId, lastReviewId, + pageable); Long userId = (userDetails != null) ? userDetails.getId() : null; @@ -222,7 +213,7 @@ public SliceResponse findMealReviewList(Long mealId, Long la pageReviews.getContent() .stream() .map(review -> MealReviewResponse.from(review, - userId, validMenus,mealRatingService.getMainRatingAverage(review.getMeal().getId()))) + userId, validMenus, review.getRating())) .collect(Collectors.toList()); return SliceResponse.builder() @@ -308,15 +299,15 @@ public MenuReviewsV2Response findMenuReviews(Long menuId) { */ public MealReviewsV2Response findMealReviews(Long mealId) { Meal meal = mealRepository.findById(mealId).orElseThrow(() -> new BaseException(NOT_FOUND_MEAL)); - List reviews = reviewRepository.findAllByMeal(meal); + List combinedReviews = reviewRepository.findAllMealAndMenuReviews(mealId); List menus = mealMenuRepository.findMenusByMeal(meal); if (menus.isEmpty()) { log.warn("No menus found for mealId={}", meal.getId()); } List validMenus = menus.stream() - .filter(menu -> !MenuFilterUtil.isExcludedFromReview( - menu.getName())) +// .filter(menu -> !MenuFilterUtil.isExcludedFromReview( +// menu.getName())) .map(menu -> ValidMenuForViewResponse.MenuDto.builder() .menuId(menu.getId()) .name(menu.getName()) @@ -327,13 +318,32 @@ public MealReviewsV2Response findMealReviews(Long mealId) { log.warn("No valid menus for review found in mealId={}", mealId); } - Double averageRating = mealRatingService.getMainRatingAverage(meal.getId()); - - if (!reviews.isEmpty() && averageRating == 0.0) { + long reviewCount = combinedReviews.size(); + + Double mainRatingAverage = combinedReviews.stream() + .map(review -> { + Integer main = (review.getRatings() != null) ? review.getRatings() + .getMainRating() : null; + return (main != null) ? main : review.getRating(); + }) + .filter(Objects::nonNull) + .mapToInt(Integer::intValue) + .average() + .stream() + .boxed() + .findFirst() + .orElse(null); + + RatingAverages averageRating = RatingAverages.builder() + .mainRating(mainRatingAverage) + .build(); + ReviewRatingCount ratingCountMap = ReviewRatingCount.from(combinedReviews); + + if (!combinedReviews.isEmpty() && averageRating.mainRating() == null) { log.warn("All reviews have null/invalid ratings for mealId={}", mealId); } - Integer likeCount = Optional.ofNullable(menus) + Integer likeCount = Optional.of(menus) .orElse(Collections.emptyList()) .stream() .filter(Objects::nonNull) @@ -343,8 +353,6 @@ public MealReviewsV2Response findMealReviews(Long mealId) { .sum(); - ReviewRatingCount reviewRatingCount = ReviewRatingCount.from(reviews); - return MealReviewsV2Response .builder() .menuList(validMenus.stream() @@ -354,9 +362,9 @@ public MealReviewsV2Response findMealReviews(Long mealId) { menu.getName() )) .collect(Collectors.toList())) - .totalReviewCount((long) reviews.size()) - .reviewRatingCount(reviewRatingCount) - .rating(Math.round(averageRating * 10) / 10.0) + .totalReviewCount(reviewCount) + .reviewRatingCount(ratingCountMap) + .rating(averageRating.mainRating()) .likeCount(likeCount) .build(); } diff --git a/src/main/java/ssu/eatssu/domain/slack/entity/SlackChannel.java b/src/main/java/ssu/eatssu/domain/slack/entity/SlackChannel.java index d9b142dc..8a39b807 100644 --- a/src/main/java/ssu/eatssu/domain/slack/entity/SlackChannel.java +++ b/src/main/java/ssu/eatssu/domain/slack/entity/SlackChannel.java @@ -4,9 +4,8 @@ public enum SlackChannel { - REPORT_CHANNEL("#신고"), - ADDMENU_CHANNEL("#메뉴_추가"), - ERROR_CHANNEL("#장애"), + REPORT_CHANNEL("#01-신고"), + // TODO: 회의에서 제안 후 삭제한다 USER_INQUIRY_CHANNEL("#유저-문의"), SERVER_ERROR("C092J4J6F0U"); diff --git a/src/main/java/ssu/eatssu/domain/user/entity/DeviceType.java b/src/main/java/ssu/eatssu/domain/user/entity/DeviceType.java new file mode 100644 index 00000000..a4679293 --- /dev/null +++ b/src/main/java/ssu/eatssu/domain/user/entity/DeviceType.java @@ -0,0 +1,5 @@ +package ssu.eatssu.domain.user.entity; + +public enum DeviceType { + IOS, ANDROID +} diff --git a/src/main/java/ssu/eatssu/domain/user/entity/User.java b/src/main/java/ssu/eatssu/domain/user/entity/User.java index 58e0c6f0..5dc57fc0 100644 --- a/src/main/java/ssu/eatssu/domain/user/entity/User.java +++ b/src/main/java/ssu/eatssu/domain/user/entity/User.java @@ -58,6 +58,8 @@ public class User extends BaseTimeEntity { private String credentials; @Enumerated(EnumType.STRING) private UserStatus status; + @Enumerated(EnumType.STRING) + private DeviceType deviceType; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "department_id") private Department department; @@ -66,7 +68,7 @@ public class User extends BaseTimeEntity { * Oauth 회원가입 용 생성자 */ private User(@NotNull String email, String nickname, @NotNull Role role, @NotNull OAuthProvider provider, - @NotNull String providerId, @NotNull UserStatus status, @NotNull String credentials) { + @NotNull String providerId, @NotNull UserStatus status, @NotNull String credentials, DeviceType deviceType) { this.email = email; this.nickname = nickname; this.role = role; @@ -74,8 +76,12 @@ private User(@NotNull String email, String nickname, @NotNull Role role, @NotNul this.providerId = providerId; this.status = status; this.credentials = credentials; + this.deviceType = deviceType; } + // TODO : 회원 가입 V2 마이그레이션 이후 삭제. + // 기본 생성자 : DeviceType을 Null로 받고 있는데 마이그레이션 이후 기본 생성자도 NotNull 붙이기. + // admin 정적 펙토리 : 여기도 null 넣는 부분 바꿔주기. /** * <--Static Factory Method--> * Oauth 회원가입 @@ -83,7 +89,18 @@ private User(@NotNull String email, String nickname, @NotNull Role role, @NotNul public static User create(@NotNull String email, @NotNull String nickname, @NotNull OAuthProvider provider, String providerId, String credentials) { - return new User(email, nickname, Role.USER, provider, providerId, UserStatus.ACTIVE, credentials); + return new User(email, nickname, Role.USER, provider, providerId, UserStatus.ACTIVE, credentials, null); + } + + /** + * <--Static Factory Method--> + * Oauth 회원가입 V2 + */ + public static User createV2(@NotNull String email, @NotNull String nickname, @NotNull OAuthProvider provider, + String providerId, + String credentials, + DeviceType deviceType) { + return new User(email, nickname, Role.USER, provider, providerId, UserStatus.ACTIVE, credentials, deviceType); } /** @@ -92,7 +109,7 @@ public static User create(@NotNull String email, @NotNull String nickname, @NotN * Role 은 다른 방법으로 세팅할 예정 */ public static User adminJoin(@NotNull String loginId, @NotNull String credentials) { - return new User(loginId, null, Role.USER, OAuthProvider.EATSSU, loginId, UserStatus.INACTIVE, credentials); + return new User(loginId, null, Role.USER, OAuthProvider.EATSSU, loginId, UserStatus.INACTIVE, credentials, null); } public void updateNickname(@NotNull String nickname) { @@ -107,4 +124,6 @@ public void updateDepartment(Department department) { this.department = department; } + // 회원 가입 v2 마이그레이션을 위한 메서드 (원래는 가입시 DeviceType을 받아야 합니다.) + public void updateDeviceType(DeviceType deviceType) { this.deviceType = deviceType; } } diff --git a/src/main/java/ssu/eatssu/domain/user/service/UserService.java b/src/main/java/ssu/eatssu/domain/user/service/UserService.java index a9020694..d970b265 100644 --- a/src/main/java/ssu/eatssu/domain/user/service/UserService.java +++ b/src/main/java/ssu/eatssu/domain/user/service/UserService.java @@ -21,6 +21,7 @@ import ssu.eatssu.domain.user.dto.MyPageResponse; import ssu.eatssu.domain.user.dto.NicknameUpdateRequest; import ssu.eatssu.domain.user.dto.UpdateDepartmentRequest; +import ssu.eatssu.domain.user.entity.DeviceType; import ssu.eatssu.domain.user.entity.User; import ssu.eatssu.domain.user.repository.UserRepository; import ssu.eatssu.domain.user.util.NicknameValidator; @@ -56,6 +57,13 @@ public User join(String email, OAuthProvider provider, String providerId) { return userRepository.save(user); } + public User joinV2(String email, OAuthProvider provider, String providerId, DeviceType deviceType) { + String credentials = createCredentials(provider, providerId); + String nickname = randomNicknameUtil.generate(); + User user = User.createV2(email, nickname, provider, providerId, credentials, deviceType); + return userRepository.save(user); + } + public void updateNickname(CustomUserDetails userDetails, NicknameUpdateRequest request) { User user = userRepository.findById(userDetails.getId()) .orElseThrow(() -> new BaseException(NOT_FOUND_USER)); diff --git a/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java index 6690b3de..9c203f00 100644 --- a/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java +++ b/src/main/java/ssu/eatssu/global/log/ControllerLogAspect.java @@ -11,14 +11,12 @@ import org.aspectj.lang.reflect.MethodSignature; import org.springframework.security.core.Authentication; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.UserDetails; import org.springframework.stereotype.Component; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; import ssu.eatssu.domain.auth.security.CustomUserDetails; import ssu.eatssu.domain.slack.service.SlackErrorNotifier; import ssu.eatssu.global.handler.response.BaseException; -import ssu.eatssu.global.handler.response.BaseResponseStatus; import ssu.eatssu.global.log.annotation.LogMask; import java.lang.reflect.Field; @@ -27,7 +25,6 @@ import java.util.stream.Collectors; import java.util.stream.IntStream; - @Aspect @Component @Slf4j @@ -54,10 +51,11 @@ public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable { Object[] args = joinPoint.getArgs(); String userId = getUserIdFromSecurityContext(); + String deviceType = getDeviceTypeFromSecurityContext(); - String userIdLog = "userId=" + userId; + String userLog = "userId=" + userId + ", deviceType=" + deviceType; - // 나머지 요청 인자 + // 나머지 요청 인자 조합 String otherArgsJson = IntStream.range(0, args.length) .filter(i -> !(args[i] instanceof HttpServletRequest)) .filter(i -> !(args[i] instanceof CustomUserDetails)) @@ -81,7 +79,7 @@ public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable { }) .collect(Collectors.joining(", ")); - String argsJson = userIdLog + (otherArgsJson.isEmpty() ? "" : ", " + otherArgsJson); + String argsJson = userLog + (otherArgsJson.isEmpty() ? "" : ", " + otherArgsJson); log.info("REQUEST {} {} args={}", method, uri, argsJson); @@ -113,23 +111,33 @@ public Object logApi(ProceedingJoinPoint joinPoint) throws Throwable { } private String getCauseMessage(Throwable e) { - if (e instanceof BaseException) { - BaseException baseException = (BaseException) e; + if (e instanceof BaseException baseException) { return baseException.getStatus().getMessage(); } - String message = e.getMessage(); - return message != null ? message : e.getClass().getSimpleName(); + return e.getMessage() != null ? e.getMessage() : e.getClass().getSimpleName(); } private String getUserIdFromSecurityContext() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - if (authentication != null && authentication.isAuthenticated() && authentication.getPrincipal() instanceof CustomUserDetails) { - CustomUserDetails userDetails = (CustomUserDetails) authentication.getPrincipal(); + if (authentication != null && authentication.isAuthenticated() && + authentication.getPrincipal() instanceof CustomUserDetails userDetails) { return String.valueOf(userDetails.getId()); } return "anonymous"; } + private String getDeviceTypeFromSecurityContext() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication != null && authentication.isAuthenticated() && + authentication.getPrincipal() instanceof CustomUserDetails userDetails) { + + return userDetails.getDeviceType() != null + ? userDetails.getDeviceType().name() + : "null"; + } + return "unknown"; + } + private Map toSafeMap(Object arg) { Map result = new HashMap<>(); for (Field field : arg.getClass().getDeclaredFields()) {