diff --git a/README.md b/README.md index f6c73204..c3a33182 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,10 @@ +## 아키텍처 다이어그램 +![image](https://github.com/user-attachments/assets/25a84d99-a57e-42c9-afcf-1f9cb9adc9da) + + ## 협업 규칙 ### Github 협업 규칙 diff --git a/src/main/generated/com/example/mody/domain/member/entity/QMember.java b/src/main/generated/com/example/mody/domain/member/entity/QMember.java index 51045878..20ef3d23 100644 --- a/src/main/generated/com/example/mody/domain/member/entity/QMember.java +++ b/src/main/generated/com/example/mody/domain/member/entity/QMember.java @@ -60,9 +60,9 @@ public class QMember extends EntityPathBase { public final StringPath providerId = createString("providerId"); - public final ListPath recommendations = this.createList("recommendations", com.example.mody.domain.recommendation.entity.Recommendation.class, com.example.mody.domain.recommendation.entity.QRecommendation.class, PathInits.DIRECT2); + public final ListPath recommendationLikes = this.createList("recommendationLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); - public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); + public final ListPath recommendations = this.createList("recommendations", com.example.mody.domain.recommendation.entity.Recommendation.class, com.example.mody.domain.recommendation.entity.QRecommendation.class, PathInits.DIRECT2); public final NumberPath reportCount = createNumber("reportCount", Integer.class); diff --git a/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java index e5e23219..92d17470 100644 --- a/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java +++ b/src/main/generated/com/example/mody/domain/recommendation/entity/QRecommendation.java @@ -40,7 +40,7 @@ public class QRecommendation extends EntityPathBase { public final com.example.mody.domain.member.entity.QMember member; - public final ListPath RecommendLikes = this.createList("RecommendLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); + public final ListPath recommendationLikes = this.createList("recommendationLikes", com.example.mody.domain.recommendation.entity.mapping.MemberRecommendationLike.class, com.example.mody.domain.recommendation.entity.mapping.QMemberRecommendationLike.class, PathInits.DIRECT2); public final EnumPath recommendType = createEnum("recommendType", com.example.mody.domain.recommendation.enums.RecommendType.class); diff --git a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java index 25e39852..1a080f47 100644 --- a/src/main/java/com/example/mody/domain/auth/controller/AuthController.java +++ b/src/main/java/com/example/mody/domain/auth/controller/AuthController.java @@ -96,26 +96,25 @@ public class AuthController { ) ), @ApiResponse( - responseCode = "404", - description = "사용자를 찾을 수 없음", + responseCode = "COMMON401", + description = "로그인하지 않은 경우에 발생합니다.(엑세스 토큰을 넣지 않았을 때)", content = @Content( mediaType = "application/json", examples = @ExampleObject( value = """ - { - "timestamp": "2024-01-13T10:00:00", - "code": "MEMBER404", - "message": "해당 회원을 찾을 수 없습니다.", - "result": null - } - """ + { + "timestamp": "2025-02-17T22:23:22.7640118", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ ) ) ) }) @PostMapping("/signup/complete") public BaseResponse completeRegistration( - @AuthenticationPrincipal CustomUserDetails userDetails, + @AuthenticationPrincipal CustomUserDetails customUserDetails, @Valid @RequestBody @Parameter( description = "회원가입 완료 요청 정보", @@ -137,7 +136,7 @@ public BaseResponse completeRegistration( ) ) MemberRegistrationRequest request ) { - memberCommandService.completeRegistration(userDetails.getMember().getId(), request); + memberCommandService.completeRegistration(customUserDetails.getMember(), request); return BaseResponse.onSuccess(null); } @@ -243,18 +242,17 @@ public BaseResponse reissueToken( ) ), @ApiResponse( - responseCode = "AUTH401", + responseCode = "COMMON401", description = "인증되지 않은 사용자", content = @Content( mediaType = "application/json", examples = @ExampleObject( value = """ { - "timestamp": "2024-01-13T10:00:00", - "code": "AUTH001", - "message": "JWT가 없습니다.", - "result": null - } + "timestamp": "2025-02-17T22:23:22.7640118", + "code": "COMMON401", + "message": "인증이 필요합니다." + } """ ) ) diff --git a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java index ff993dbf..9909a8d5 100644 --- a/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java +++ b/src/main/java/com/example/mody/domain/auth/handler/OAuth2SuccessHandler.java @@ -58,13 +58,13 @@ public void onAuthenticationSuccess(HttpServletRequest request, HttpServletRespo Member member = memberRepository.findByProviderId(oAuth2User.getOAuth2Response().getProviderId()) .orElseGet(() -> saveMember(oAuth2User)); - // 새로 가입한 멤버인지 아닌지 확인 - boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt()); +// // 새로 가입한 멤버인지 아닌지 확인 +// boolean isNewMember = member.getCreatedAt().equals(member.getUpdatedAt()); // Access Token, Refresh Token 발급 String newAccessToken = authCommandService.processLoginSuccess(member, response); - String tempUrl = (isNewMember || !member.isRegistrationCompleted()) ? FRONT_SIGNUP_URL : FRONT_HOME_URL; + String tempUrl = (!member.isRegistrationCompleted()) ? FRONT_SIGNUP_URL : FRONT_HOME_URL; String targetUrl = UriComponentsBuilder.fromUriString(tempUrl) .build().toUriString(); diff --git a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java index 11599ba8..ea607add 100644 --- a/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java +++ b/src/main/java/com/example/mody/domain/auth/jwt/JwtAuthenticationFilter.java @@ -35,9 +35,8 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { private final JwtProvider jwtProvider; - private final MemberRepository memberRepository; - private final ObjectMapper objectMapper; private final MemberQueryService memberQueryService; + private final ObjectMapper objectMapper; @Override protected boolean shouldNotFilter(HttpServletRequest request) throws ServletException { @@ -47,7 +46,9 @@ protected boolean shouldNotFilter(HttpServletRequest request) throws ServletExce uri = uri.substring(contextPath.length()); } log.info("JwtAuthenticationFilter - Request URI after context removal: {}", uri); - boolean skip = uri.startsWith("/auth/") && !uri.startsWith("/auth/signup/complete") || + boolean skip = uri.startsWith("/auth/") + && !uri.startsWith("/auth/signup/complete") + && !uri.startsWith("/auth/logout")|| uri.startsWith("/oauth2/") || uri.startsWith("/email/") || uri.startsWith("/swagger-ui/") || @@ -70,8 +71,7 @@ protected void doFilterInternal( // 만약 토큰이 있다면 if (token != null) { String memberId = jwtProvider.validateTokenAndGetSubject(token); - Member member = memberRepository.findById(Long.parseLong(memberId)) - .orElseThrow(() -> new RestApiException(AuthErrorStatus.INVALID_ACCESS_TOKEN)); + Member member = memberQueryService.findMemberById(Long.parseLong(memberId)); CustomUserDetails customUserDetails = new CustomUserDetails(member); diff --git a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java index b68246ab..1f5625c1 100644 --- a/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java +++ b/src/main/java/com/example/mody/domain/auth/security/OAuth2UserService.java @@ -57,7 +57,6 @@ private Member saveMember(OAuth2Response oAuth2Response) { Member member = Member.builder() .providerId(oAuth2Response.getProviderId()) .provider(oAuth2Response.getProvider()) - .nickname(oAuth2Response.getName()) .status(Status.ACTIVE) .role(Role.ROLE_USER) .loginType(LoginType.KAKAO) diff --git a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java index 5e3ca045..1d32797c 100644 --- a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java +++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeController.java @@ -7,79 +7,23 @@ import com.example.mody.domain.bodytype.service.memberbodytype.MemberBodyTypeQueryService; import com.example.mody.global.common.base.BaseResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; -@Tag(name = "체형 분석", description = "체형 분석 API") +@Tag(name = "체형 분석", description = "체형 분석 관련 API") @RestController @RequestMapping("/body-analysis") @RequiredArgsConstructor -public class BodyTypeController { +public class BodyTypeController implements BodyTypeControllerInterface { private final MemberBodyTypeCommandService memberBodyTypeCommandService; private final MemberBodyTypeQueryService memberBodyTypeQueryService; @PostMapping("/result") - @Operation(summary = "체형 분석 API", description = "OpenAi를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 보내주세요.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 성공"), - @ApiResponse( - responseCode = "401", - description = "Access Token이 필요합니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "COMMON401", - "message": "인증이 필요합니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "체형을 찾을 수 없습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "BODY_TYPE404", - "message": "체형을 찾을 수 없습니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "500", - description = "GPT가 적절한 응답을 하지 못 했습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "ANALYSIS108", - "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요." - } - """ - ) - ) - ), - }) + @Operation(summary = "체형 분석 API", description = "OpenAI를 사용해서 사용자의 체형을 분석하는 API입니다. Request Body에는 질문에 맞는 답변 목록을 보내주세요.") public BaseResponse analyzeBodyType( @AuthenticationPrincipal CustomUserDetails customUserDetails, @Valid @RequestBody BodyTypeAnalysisRequest request @@ -87,69 +31,8 @@ public BaseResponse analyzeBodyType( return BaseResponse.onSuccess(memberBodyTypeCommandService.analyzeBodyType(customUserDetails.getMember(), request.getAnswer())); } -// @GetMapping() -// @Operation(summary = "체형 질문 문항 조회 API - 프론트와 협의 필요(API 연동 안 해도 됨)", -// description = "체형 분석을 하기 위해 질문 문항을 받아 오는 API입니다. 이 부분은 서버에서 바로 프롬프트로 넣는 방법도 있기 때문에 프론트와 협의 후 진행하겠습니다.") -// @ApiResponses({ -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "COMMON200",description = "OK, 성공") -// }) -// public BaseResponse getQuestion() { -// return BaseResponse.onSuccess(null); -// } - @GetMapping("/result") @Operation(summary = "체형 분석 결과 조회 API", description = "사용자의 체형 분석 결과를 받아 오는 API입니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 결과 조회 성공"), - @ApiResponse( - responseCode = "400", - description = "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "JSON_PARSING400", - "message": "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "401", - description = "Access Token이 필요합니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "COMMON401", - "message": "인증이 필요합니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "체형 분석 결과를 찾을 수 없습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "MEMBER_BODY_TYPE404", - "message": "체형 분석 결과를 찾을 수 없습니다." - } - """ - ) - ) - ), - }) public BaseResponse getBodyType(@AuthenticationPrincipal CustomUserDetails customUserDetails) { return BaseResponse.onSuccess(memberBodyTypeQueryService.getBodyTypeAnalysis(customUserDetails.getMember())); } diff --git a/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeControllerInterface.java b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeControllerInterface.java new file mode 100644 index 00000000..7928eb6a --- /dev/null +++ b/src/main/java/com/example/mody/domain/bodytype/controller/BodyTypeControllerInterface.java @@ -0,0 +1,124 @@ +package com.example.mody.domain.bodytype.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.bodytype.dto.request.BodyTypeAnalysisRequest; +import com.example.mody.domain.bodytype.dto.response.BodyTypeAnalysisResponse; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; + +public interface BodyTypeControllerInterface { + + @ApiResponses({ + @ApiResponse(responseCode = "200", description = "체형 분석 성공"), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "체형을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "BODY_TYPE404", + "message": "체형을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "GPT가 적절한 응답을 하지 못 했습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "ANALYSIS108", + "message": "GPT가 올바르지 않은 답변을 했습니다. 관리자에게 문의하세요." + } + """ + ) + ) + ), + }) + BaseResponse analyzeBodyType( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody BodyTypeAnalysisRequest request); + + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "체형 분석 결과 조회 성공"), + @ApiResponse( + responseCode = "400", + description = "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "JSON_PARSING400", + "message": "체형 분석 결과를 처리하는 중 JSON 파싱에 실패했습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "체형 분석 결과를 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "MEMBER_BODY_TYPE404", + "message": "체형 분석 결과를 찾을 수 없습니다." + } + """ + ) + ) + ), + }) + BaseResponse getBodyType(@AuthenticationPrincipal CustomUserDetails customUserDetails); +} diff --git a/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java index 7845d86d..dfd66d1f 100644 --- a/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java +++ b/src/main/java/com/example/mody/domain/bodytype/dto/request/BodyTypeAnalysisRequest.java @@ -5,8 +5,6 @@ import lombok.NoArgsConstructor; import lombok.Setter; -import java.util.List; - /** * 체형 분석 요청 DTO */ diff --git a/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java index 217032a5..09659fcb 100644 --- a/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java +++ b/src/main/java/com/example/mody/domain/bodytype/entity/BodyType.java @@ -1,6 +1,7 @@ package com.example.mody.domain.bodytype.entity; import com.example.mody.domain.bodytype.entity.mapping.MemberBodyType; +import com.example.mody.domain.member.entity.Member; import com.example.mody.global.common.base.BaseEntity; import jakarta.persistence.*; import lombok.*; @@ -9,6 +10,7 @@ import java.util.ArrayList; import java.util.List; +import java.util.Objects; @Entity @Getter @@ -30,4 +32,20 @@ public class BodyType extends BaseEntity { @OneToMany(mappedBy = "bodyType", cascade = CascadeType.ALL) private List memberBodyTypeList = new ArrayList<>(); + + @Override + public boolean equals(final Object o) { + if (this == o) { + return true; + } + if (!(o instanceof BodyType that)) { + return false; + } + return Objects.equals(that.getId(), this.id); + } + + @Override + public int hashCode() { + return Objects.hash(id); + } } \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java index fc6af18b..fc398cab 100644 --- a/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java +++ b/src/main/java/com/example/mody/domain/bodytype/repository/MemberBodyTypeRepository.java @@ -10,7 +10,6 @@ @Repository public interface MemberBodyTypeRepository extends JpaRepository { Optional findTopByMemberOrderByCreatedAt(Member member); - Optional findMemberBodyTypeByMember(Member member); Long countAllByMember(Member member); Optional findTopByMemberOrderByCreatedAtDesc(Member member); } diff --git a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java index b5d66e5d..6264f19b 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/bodytype/BodyTypeQueryServiceImpl.java @@ -18,8 +18,7 @@ public class BodyTypeQueryServiceImpl implements BodyTypeQueryService { // 체형 이름으로 BodyType 클래스 조회 @Override public BodyType findByBodyTypeName(String bodyTypeName) { - Optional optionalBodyType = bodyTypeRepository.findByName(bodyTypeName); - BodyType bodyType = optionalBodyType.orElseThrow(()-> new BodyTypeException(BodyTypeErrorStatus.BODY_TYPE_NOT_FOUND)); - return bodyType; + return bodyTypeRepository.findByName(bodyTypeName) + .orElseThrow(() -> new BodyTypeException(BodyTypeErrorStatus.BODY_TYPE_NOT_FOUND)); } } diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java index f89dc89a..7fe2ec12 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandService.java @@ -4,5 +4,5 @@ import com.example.mody.domain.member.entity.Member; public interface MemberBodyTypeCommandService { - public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers); + BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers); } diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java index 8ebd2b92..fe57be42 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeCommandServiceImpl.java @@ -23,12 +23,9 @@ public class MemberBodyTypeCommandServiceImpl implements MemberBodyTypeCommandSe @Override public BodyTypeAnalysisResponse analyzeBodyType(Member member, String answers) { - // 사용자 정보(닉네임, 성별) 조회 - String nickname = member.getNickname(); - Gender gender = member.getGender(); // OpenAi API를 통한 체형 분석 - String content = chatGptService.getContent(nickname, gender, answers); + String content = chatGptService.getContent(member.getNickname(), member.getGender(), answers); BodyTypeAnalysisResponse bodyTypeAnalysisResponse = chatGptService.analyzeBodyType(content); // MemberBodyType을 DB에 저장 diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java index 9d394ff0..d7d9ec36 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryService.java @@ -5,7 +5,7 @@ import com.example.mody.domain.member.entity.Member; public interface MemberBodyTypeQueryService { - public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member); + BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member); - public MemberBodyType getMemberBodyType(Member member); + MemberBodyType getMemberBodyType(Member member); } diff --git a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java index d2b64cb3..6888d1c7 100644 --- a/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java +++ b/src/main/java/com/example/mody/domain/bodytype/service/memberbodytype/MemberBodyTypeQueryServiceImpl.java @@ -12,15 +12,13 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import java.util.Optional; - @Service @RequiredArgsConstructor @Transactional(readOnly = true) public class MemberBodyTypeQueryServiceImpl implements MemberBodyTypeQueryService { private final MemberBodyTypeRepository memberBodyTypeRepository; - private final ObjectMapper objectMapper = new ObjectMapper(); + private final ObjectMapper objectMapper; @Override public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member) { @@ -37,7 +35,7 @@ public BodyTypeAnalysisResponse getBodyTypeAnalysis(Member member) { // Member로 MemberBodyType 조회 @Override public MemberBodyType getMemberBodyType(Member member) { - Optional optionalMemberBodyType = memberBodyTypeRepository.findTopByMemberOrderByCreatedAtDesc(member); - return optionalMemberBodyType.orElseThrow(() -> new BodyTypeException(BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND)); + return memberBodyTypeRepository.findTopByMemberOrderByCreatedAtDesc(member) + .orElseThrow(() -> new BodyTypeException(BodyTypeErrorStatus.MEMBER_BODY_TYPE_NOT_FOUND)); } } diff --git a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java index 69db1ef2..6b827b3c 100644 --- a/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java +++ b/src/main/java/com/example/mody/domain/chatgpt/service/ChatGptService.java @@ -33,8 +33,8 @@ public final class ChatGptService { private final OpenAiApiClient openAiApiClient; // ChatGPT API와의 통신을 담당 private final PromptManager promptManager; // 프롬프트 생성 - private final ObjectMapper objectMapper = new ObjectMapper(); // JSON 응답 변환 - private final CrawlerService crawlerService; + private final ObjectMapper objectMapper; // JSON 응답 변환 + private final CrawlerService crawlerService; // 크롤링 서비스 @Value("${openai.model}") private String model; // OpenAI 모델 @@ -71,7 +71,7 @@ private ChatGPTResponse getChatGPTResponse(String nickName, Gender gender, Strin String prompt = promptManager.createBodyTypeAnalysisPrompt(nickName, gender, answers); // OpenAI API 호출 - ChatGPTResponse response = openAiApiClient.sendRequestToModel( + return openAiApiClient.sendRequestToModel( model, List.of( new Message(systemRole, prompt) @@ -79,7 +79,6 @@ private ChatGPTResponse getChatGPTResponse(String nickName, Gender gender, Strin maxTokens, temperature ); - return response; } // 체형 분석 메서드 diff --git a/src/main/java/com/example/mody/domain/image/controller/S3Controller.java b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java index bbb2da71..6bb70145 100644 --- a/src/main/java/com/example/mody/domain/image/controller/S3Controller.java +++ b/src/main/java/com/example/mody/domain/image/controller/S3Controller.java @@ -7,10 +7,6 @@ import com.example.mody.domain.image.service.S3Service; import com.example.mody.global.common.base.BaseResponse; import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -23,63 +19,12 @@ @RestController @RequiredArgsConstructor @RequestMapping("/image") -public class S3Controller { +public class S3Controller implements S3ControllerInterface { private final S3Service s3Service; @PostMapping(value = "/upload/posts") @Operation(summary = "게시글 presigned url 생성 API", description = "게시글 파일 업로드(put) presigned url을 생성하는 API 입니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), - @ApiResponse( - responseCode = "401", - description = "Access Token이 필요합니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "COMMON401", - "message": "인증이 필요합니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "S3 버킷을 찾을 수 없습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "S3_404", - "message": "지정된 S3 버킷을 찾을 수 없습니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "500", - description = "presigned url 생성을 실패하였습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "S3_500", - "message": "S3 presigned url 생성 중 오류가 발생했습니다." - } - """ - ) - ) - ), - }) public BaseResponse> getPostPresignedUrl( @AuthenticationPrincipal CustomUserDetails customUserDetails, @Valid @RequestBody PostPresignedUrlRequest request @@ -90,57 +35,6 @@ public BaseResponse> getPostPresignedUrl( @PostMapping(value = "/upload/profiles") @Operation(summary = "프로필 사진 presigned url 생성 API", description = "프로필 사진 파일 업로드(put) presigned url을 생성하는 API 입니다.") - @ApiResponses({ - @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), - @ApiResponse( - responseCode = "401", - description = "Access Token이 필요합니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "COMMON401", - "message": "인증이 필요합니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "404", - description = "S3 버킷을 찾을 수 없습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "S3_404", - "message": "지정된 S3 버킷을 찾을 수 없습니다." - } - """ - ) - ) - ), - @ApiResponse( - responseCode = "500", - description = "presigned url 생성을 실패하였습니다.", - content = @Content( - mediaType = "application/json", - examples = @ExampleObject( - value = """ - { - "timestamp": "2025-01-26T15:15:54.334Z", - "code": "S3_500", - "message": "S3 presigned url 생성 중 오류가 발생했습니다." - } - """ - ) - ) - ), - }) public BaseResponse getProfilePresignedUrl( @AuthenticationPrincipal CustomUserDetails customUserDetails, @Valid @RequestBody ProfilePresignedUrlRequest request @@ -148,17 +42,4 @@ public BaseResponse getProfilePresignedUrl( PresignedUrlResponse presignedUrlResponse = s3Service.getProfilePresignedUrl(customUserDetails.getMember().getId(), request.getFilename()); return BaseResponse.onSuccess(presignedUrlResponse); } - -// // 테스트용(실제 사용 X) -// @GetMapping(value = "/getS3Url") -// @Operation(summary = "자체 S3 URL 조회 - 테스트용으로 API 연동 시 신경 안 써도 되는 API 입니다.", -// description = "프론트에서 S3에 파일 업로드 후 반환하는 S3 URL을 서버에서 생성해 확인해보는 테스트용 API 입니다.") -// @ApiResponses({ -// @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "S3 url 반환 성공") -// }) -// public BaseResponse getS3Url(@RequestParam String key) { -// S3UrlResponse s3UrlResponse = s3Service.getS3Url(key); -// return BaseResponse.onSuccess(s3UrlResponse); -// } - } diff --git a/src/main/java/com/example/mody/domain/image/controller/S3ControllerInterface.java b/src/main/java/com/example/mody/domain/image/controller/S3ControllerInterface.java new file mode 100644 index 00000000..2f3d767a --- /dev/null +++ b/src/main/java/com/example/mody/domain/image/controller/S3ControllerInterface.java @@ -0,0 +1,131 @@ +package com.example.mody.domain.image.controller; + +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.image.dto.request.PostPresignedUrlRequest; +import com.example.mody.domain.image.dto.request.ProfilePresignedUrlRequest; +import com.example.mody.domain.image.dto.response.PresignedUrlResponse; +import com.example.mody.global.common.base.BaseResponse; +import io.swagger.v3.oas.annotations.media.Content; +import io.swagger.v3.oas.annotations.media.ExampleObject; +import io.swagger.v3.oas.annotations.responses.ApiResponse; +import io.swagger.v3.oas.annotations.responses.ApiResponses; +import jakarta.validation.Valid; +import org.springframework.security.core.annotation.AuthenticationPrincipal; +import org.springframework.web.bind.annotation.RequestBody; + +import java.util.List; + +public interface S3ControllerInterface { + + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "S3 버킷을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_404", + "message": "지정된 S3 버킷을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "presigned url 생성을 실패하였습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_500", + "message": "S3 presigned url 생성 중 오류가 발생했습니다." + } + """ + ) + ) + ), + }) + BaseResponse> getPostPresignedUrl( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody PostPresignedUrlRequest request + ); + + @ApiResponses({ + @io.swagger.v3.oas.annotations.responses.ApiResponse(responseCode = "200",description = "presigned url 생성을 성공하였습니다."), + @ApiResponse( + responseCode = "401", + description = "Access Token이 필요합니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "COMMON401", + "message": "인증이 필요합니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "404", + description = "S3 버킷을 찾을 수 없습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_404", + "message": "지정된 S3 버킷을 찾을 수 없습니다." + } + """ + ) + ) + ), + @ApiResponse( + responseCode = "500", + description = "presigned url 생성을 실패하였습니다.", + content = @Content( + mediaType = "application/json", + examples = @ExampleObject( + value = """ + { + "timestamp": "2025-01-26T15:15:54.334Z", + "code": "S3_500", + "message": "S3 presigned url 생성 중 오류가 발생했습니다." + } + """ + ) + ) + ), + }) + BaseResponse getProfilePresignedUrl( + @AuthenticationPrincipal CustomUserDetails customUserDetails, + @Valid @RequestBody ProfilePresignedUrlRequest request + ); +} diff --git a/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java index b9695ddb..96f54da9 100644 --- a/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java +++ b/src/main/java/com/example/mody/domain/image/dto/request/PostPresignedUrlRequest.java @@ -3,7 +3,6 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; import java.util.List; @@ -11,7 +10,6 @@ * 게시글 S3 presigned url 요청 DTO */ @Getter -@Setter @NoArgsConstructor public class PostPresignedUrlRequest { @Schema(description = "업로드할 파일 목록", example = "[\"a.png\", \"b.jpg\"]") diff --git a/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java index 6fd9d300..ceca5dd0 100644 --- a/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java +++ b/src/main/java/com/example/mody/domain/image/dto/request/ProfilePresignedUrlRequest.java @@ -3,15 +3,11 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; import lombok.NoArgsConstructor; -import lombok.Setter; - -import java.util.List; /** * 프로필 사진 S3 presigned url 요청 DTO */ @Getter -@Setter @NoArgsConstructor public class ProfilePresignedUrlRequest { @Schema(description = "업로드할 파일", example = "a.png") diff --git a/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java index 2fdf9219..d0896291 100644 --- a/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java +++ b/src/main/java/com/example/mody/domain/image/dto/response/PresignedUrlResponse.java @@ -7,9 +7,8 @@ * S3 presigned url 응답 DTO */ @Getter -@Setter -@NoArgsConstructor -@EqualsAndHashCode +@Builder +@AllArgsConstructor @Schema(description = "S3 presigned url 응답 DTO") public class PresignedUrlResponse { @@ -18,10 +17,10 @@ public class PresignedUrlResponse { @Schema(description = "S3 key 값", example = "{folder}/{memberId}/{uuid}/{filename}") private String key; - public static PresignedUrlResponse of(String preSignedUrl, String key){ - PresignedUrlResponse response = new PresignedUrlResponse(); - response.setPresignedUrl(preSignedUrl); - response.setKey(key); - return response; + public static PresignedUrlResponse of(String preSignedUrl, String key) { + return PresignedUrlResponse.builder() + .presignedUrl(preSignedUrl) + .key(key) + .build(); } } diff --git a/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java index 9f30d03c..a3c44470 100644 --- a/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java +++ b/src/main/java/com/example/mody/domain/image/dto/response/S3UrlResponse.java @@ -1,18 +1,14 @@ package com.example.mody.domain.image.dto.response; import io.swagger.v3.oas.annotations.media.Schema; -import lombok.AllArgsConstructor; -import lombok.Getter; -import lombok.NoArgsConstructor; -import lombok.Setter; +import lombok.*; /** * S3 url 응답 DTO */ @Getter -@Setter +@Builder @AllArgsConstructor -@NoArgsConstructor @Schema(description = "S3 url 응답 DTO") public class S3UrlResponse { @@ -20,8 +16,8 @@ public class S3UrlResponse { private String s3Url; public static S3UrlResponse from(String s3Url){ - S3UrlResponse response = new S3UrlResponse(); - response.setS3Url(s3Url); - return response; + return S3UrlResponse.builder() + .s3Url(s3Url) + .build(); } } \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/image/service/S3Service.java b/src/main/java/com/example/mody/domain/image/service/S3Service.java index 11cc3f7f..8f94809a 100644 --- a/src/main/java/com/example/mody/domain/image/service/S3Service.java +++ b/src/main/java/com/example/mody/domain/image/service/S3Service.java @@ -8,7 +8,6 @@ import com.amazonaws.services.s3.model.CannedAccessControlList; import com.amazonaws.services.s3.model.GeneratePresignedUrlRequest; import com.example.mody.domain.image.dto.response.PresignedUrlResponse; -import com.example.mody.domain.image.dto.response.S3UrlResponse; import com.example.mody.global.common.exception.RestApiException; import com.example.mody.global.common.exception.code.status.S3ErrorStatus; import lombok.RequiredArgsConstructor; @@ -22,28 +21,28 @@ import java.util.Date; import java.util.List; import java.util.UUID; +import java.util.regex.Pattern; @Slf4j @Service @RequiredArgsConstructor -@Transactional(readOnly = true) public class S3Service { private final AmazonS3 amazonS3Client; @Value("${cloud.aws.s3.bucket}") private String bucket; - @Value("${cloud.aws.region.static}") - private String region; + private static final Pattern FILENAME_SANITIZE_PATTERN = Pattern.compile("[^/]+://[^/]+/"); + @Transactional(readOnly = true) public List getPostPresignedUrls(Long memberId, List filenames) { // 게시물당 하나의 UUID를 생성 String uuid = UUID.randomUUID().toString(); List presignedUrls = new ArrayList<>(); for (String filename : filenames) { - String sanitizedFilename = filename.replaceAll("[^/]+://[^/]+/", ""); - String key = String.format("post/%d/%s/%s", memberId, uuid, sanitizedFilename); + String sanitizedFilename = FILENAME_SANITIZE_PATTERN.matcher(filename).replaceAll(""); + String key = String.format("post/%d/%s/%s", memberId, uuid, sanitizedFilename); // key값 설정(post 디렉터리 + 멤버ID + 랜덤 값 + filename) Date expiration = getExpiration(); // 유효 기간 GeneratePresignedUrlRequest generatePresignedUrlRequest = generatePresignedUrl(key, expiration); @@ -53,10 +52,11 @@ public List getPostPresignedUrls(Long memberId, List postImageUrls) { // 파일 삭제 실패해도 다음 파일 삭제를 수행하도록 예외를 터뜨리는 것이 아닌 로그만 찍음 for (String imageUrl : postImageUrls) { @@ -114,11 +110,4 @@ public void deleteFile(List postImageUrls) { // 파일 삭제 실패해 private String extractKey(String imageUrl) { return imageUrl.substring(imageUrl.indexOf(".com/") + 5); // 해당 index + 5 값이 key 값의 시작 인덱스 값 } - -// // 프론트에서 전달받은 key를 이용해 S3 URL 생성 및 반환(테스트용) -// public S3UrlResponse getS3Url(String key) { -// String s3Url = String.format("https://%s.s3.%s.amazonaws.com/%s", bucket, region, key); -// log.info("s3Url: {}", s3Url); -// return S3UrlResponse.from(s3Url); -// } } \ No newline at end of file diff --git a/src/main/java/com/example/mody/domain/member/entity/Member.java b/src/main/java/com/example/mody/domain/member/entity/Member.java index 841d779e..175a88c0 100644 --- a/src/main/java/com/example/mody/domain/member/entity/Member.java +++ b/src/main/java/com/example/mody/domain/member/entity/Member.java @@ -66,7 +66,7 @@ public class Member extends BaseEntity { cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List RecommendLikes = new ArrayList<>(); + private List recommendationLikes = new ArrayList<>(); /** * 회원이 작성한 게시글 목록 diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java index a417aea4..818cd2a2 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandService.java @@ -4,10 +4,12 @@ import com.example.mody.domain.auth.dto.request.MemberRegistrationRequest; import com.example.mody.domain.auth.dto.response.LoginResponse; +import com.example.mody.domain.auth.security.CustomUserDetails; +import com.example.mody.domain.member.entity.Member; import jakarta.servlet.http.HttpServletResponse; public interface MemberCommandService { - void completeRegistration(Long memberId, MemberRegistrationRequest request); + void completeRegistration(Member member, MemberRegistrationRequest request); LoginResponse joinMember(MemberJoinRequest request, HttpServletResponse response); diff --git a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java index b68fce8c..e646218e 100644 --- a/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/member/service/MemberCommandServiceImpl.java @@ -29,9 +29,7 @@ public class MemberCommandServiceImpl implements MemberCommandService { private final AuthCommandService authCommandService; @Override - public void completeRegistration(Long memberId, MemberRegistrationRequest request) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new MemberException(MemberErrorStatus.MEMBER_NOT_FOUND)); + public void completeRegistration(Member member, MemberRegistrationRequest request) { member.completeRegistration( request.getNickname(), diff --git a/src/main/java/com/example/mody/domain/post/controller/PostController.java b/src/main/java/com/example/mody/domain/post/controller/PostController.java index 3daf39fd..10234c69 100644 --- a/src/main/java/com/example/mody/domain/post/controller/PostController.java +++ b/src/main/java/com/example/mody/domain/post/controller/PostController.java @@ -1,11 +1,9 @@ package com.example.mody.domain.post.controller; -import com.example.mody.domain.member.entity.Member; import com.example.mody.domain.member.repository.MemberRepository; import com.example.mody.domain.post.dto.request.PostUpdateRequest; import com.example.mody.domain.post.dto.response.PostResponse; -import com.example.mody.domain.post.entity.Post; import io.swagger.v3.oas.annotations.Parameters; import org.springframework.security.core.annotation.AuthenticationPrincipal; @@ -20,9 +18,8 @@ import org.springframework.web.bind.annotation.RestController; import com.example.mody.domain.auth.security.CustomUserDetails; -import com.example.mody.domain.member.repository.MemberRepository; import com.example.mody.domain.post.dto.request.PostCreateRequest; -import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponses; import com.example.mody.domain.post.exception.annotation.ExistsPost; import com.example.mody.domain.post.service.PostCommandService; import com.example.mody.domain.post.service.PostQueryService; @@ -30,7 +27,6 @@ import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.Parameter; -import io.swagger.v3.oas.annotations.Parameters; import io.swagger.v3.oas.annotations.media.Content; import io.swagger.v3.oas.annotations.media.ExampleObject; import io.swagger.v3.oas.annotations.media.Schema; @@ -61,12 +57,12 @@ public class PostController { description = "게시글 목록 조회 성공" ) }) - public BaseResponse getAllPosts( + public BaseResponse getAllPosts( @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestParam(name = "cursor", required = false) Long cursor, @RequestParam(name = "size", defaultValue = "15") Integer size) { - PostListResponse postListResponse = postQueryService.getPosts(customUserDetails.getMember(), size, cursor); - return BaseResponse.onSuccess(postListResponse); + PostResponses postResponses = postQueryService.getPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postResponses); } /** @@ -272,12 +268,12 @@ public BaseResponse togglePostLike( description = "게시글 목록 조회 성공" ) }) - public BaseResponse getLikedPosts( + public BaseResponse getLikedPosts( @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestParam(name = "cursor", required = false) Long cursor, @RequestParam(name = "size", defaultValue = "15") Integer size) { - PostListResponse postListResponse = postQueryService.getLikedPosts(customUserDetails.getMember(), size, cursor); - return BaseResponse.onSuccess(postListResponse); + PostResponses postResponses = postQueryService.getLikedPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postResponses); } @GetMapping("/me") @@ -288,12 +284,12 @@ public BaseResponse getLikedPosts( description = "게시글 목록 조회 성공" ) }) - public BaseResponse getMyPosts( + public BaseResponse getMyPosts( @AuthenticationPrincipal CustomUserDetails customUserDetails, @RequestParam(name = "cursor", required = false) Long cursor, @RequestParam(name = "size", defaultValue = "15") Integer size) { - PostListResponse postListResponse = postQueryService.getMyPosts(customUserDetails.getMember(), size, cursor); - return BaseResponse.onSuccess(postListResponse); + PostResponses postResponses = postQueryService.getMyPosts(customUserDetails.getMember(), size, cursor); + return BaseResponse.onSuccess(postResponses); } @PostMapping("/{postId}/reports") diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java index 4821d2b9..34ef07b5 100644 --- a/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostImageResponse.java @@ -9,7 +9,7 @@ @AllArgsConstructor(access = AccessLevel.PRIVATE) public class PostImageResponse { private String s3Url; - public static PostImageResponse of(PostImage postImage){ + public static PostImageResponse from(PostImage postImage){ return new PostImageResponse(postImage.getUrl()); } } diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java index e3b3baba..8fd72f94 100644 --- a/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostResponse.java @@ -13,6 +13,7 @@ public class PostResponse { private Long postId; private Long writerId; private String writerNickName; + private Boolean isMine; private String content; private Boolean isPublic; private Integer likeCount; @@ -23,6 +24,7 @@ public class PostResponse { public PostResponse(Long postId, Long writerId, String nickName, + Boolean isMine, String content, Boolean isPublic, Integer likeCount, @@ -32,13 +34,14 @@ public PostResponse(Long postId, this.postId = postId; this.writerId = writerId; this.writerNickName = nickName; + this.isMine = isMine; this.content = content; this.isPublic = isPublic; this.likeCount = likeCount; this.isLiked = isLiked; this.bodyType = bodyType; this.files = files.stream() - .map(PostImageResponse::of) + .map(PostImageResponse::from) .toList(); } } diff --git a/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java b/src/main/java/com/example/mody/domain/post/dto/response/PostResponses.java similarity index 50% rename from src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java rename to src/main/java/com/example/mody/domain/post/dto/response/PostResponses.java index 7186a315..75a531e3 100644 --- a/src/main/java/com/example/mody/domain/post/dto/response/PostListResponse.java +++ b/src/main/java/com/example/mody/domain/post/dto/response/PostResponses.java @@ -5,17 +5,18 @@ import lombok.AllArgsConstructor; import lombok.Getter; +import java.util.ArrayList; import java.util.List; @Getter @AllArgsConstructor(access = AccessLevel.PRIVATE) -public class PostListResponse { +public class PostResponses { private List postResponses; private CursorPagination cursorPagination; - public static PostListResponse of(Boolean hasNext, List postResponses){ + public static PostResponses of(Boolean hasNext, List postResponses){ Long cursor = hasNext ? postResponses.getLast().getPostId() : null; - return new PostListResponse(postResponses, new CursorPagination(hasNext, cursor)); + return new PostResponses(postResponses, new CursorPagination(hasNext, cursor)); } /** @@ -25,8 +26,16 @@ public static PostListResponse of(Boolean hasNext, List postRespon * @param cursor 반환하는 게시물 중 마지막 게시물과 클라이언트에 대한 likeId * @return */ - public static PostListResponse of(Boolean hasNext, List postResponses, Long cursor){ + public static PostResponses of(Boolean hasNext, List postResponses, Long cursor){ Long newCursor = hasNext ? cursor : null; - return new PostListResponse(postResponses, new CursorPagination(hasNext, newCursor)); + return new PostResponses(postResponses, new CursorPagination(hasNext, newCursor)); + } + + public static PostResponses of(PostResponses firstPosts, PostResponses secondPosts){ + List newPostResponses = new ArrayList<>( + firstPosts.getPostResponses().size() + secondPosts.getPostResponses().size()); + newPostResponses.addAll(firstPosts.getPostResponses()); + newPostResponses.addAll(secondPosts.getPostResponses()); + return new PostResponses(newPostResponses, secondPosts.cursorPagination); } } diff --git a/src/main/java/com/example/mody/domain/post/entity/Post.java b/src/main/java/com/example/mody/domain/post/entity/Post.java index c66ee581..c6335cec 100644 --- a/src/main/java/com/example/mody/domain/post/entity/Post.java +++ b/src/main/java/com/example/mody/domain/post/entity/Post.java @@ -5,6 +5,8 @@ import java.util.ArrayList; import java.util.List; +import jakarta.persistence.*; +import lombok.*; import org.hibernate.annotations.DynamicUpdate; import com.example.mody.domain.bodytype.entity.BodyType; @@ -12,25 +14,15 @@ import com.example.mody.domain.post.entity.mapping.MemberPostLike; import com.example.mody.global.common.base.BaseEntity; -import jakarta.persistence.CascadeType; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.OneToMany; -import jakarta.persistence.Table; -import lombok.AccessLevel; -import lombok.Getter; -import lombok.NoArgsConstructor; - @Entity(name = "post") @Getter @NoArgsConstructor(access = AccessLevel.PROTECTED) -@Table(name = "post") +@Table(name = "post", indexes = { + @Index(name = "idx_bodytype_post", + columnList = "body_type_id, post_id"), + @Index(name = "idx_member_post", + columnList = "member_id, post_id") +}) @DynamicUpdate public class Post extends BaseEntity { @@ -62,14 +54,15 @@ public class Post extends BaseEntity { private String content; @Column(nullable = false) - private Integer likeCount; + private Integer likeCount = 0; @Column(nullable = false) private Boolean isPublic; @Column(nullable = false) - private Integer reportCount; + private Integer reportCount = 0; + @Builder public Post(Member member, BodyType bodyType, String content, Boolean isPublic) { this.member = member; this.bodyType = bodyType; @@ -78,6 +71,7 @@ public Post(Member member, BodyType bodyType, String content, Boolean isPublic) this.likeCount = 0; this.reportCount = 0; this.images = new ArrayList<>(); + this.likes = new ArrayList<>(); } public void decreaseLikeCount() { diff --git a/src/main/java/com/example/mody/domain/post/entity/PostImage.java b/src/main/java/com/example/mody/domain/post/entity/PostImage.java index 1b71ffaf..b44da966 100644 --- a/src/main/java/com/example/mody/domain/post/entity/PostImage.java +++ b/src/main/java/com/example/mody/domain/post/entity/PostImage.java @@ -23,6 +23,11 @@ public class PostImage extends BaseEntity { @Column(nullable = false) private String url; + /** + * 필드 개수가 적고, 필드들이 필수값이므로 빌더 패턴을 사용하지 않고 생성자를 사용함 + * @param post + * @param s3Url + */ public PostImage(Post post, String s3Url){ this.post = post; this.url = s3Url; diff --git a/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java index 0595a9b0..8ea98ce7 100644 --- a/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java +++ b/src/main/java/com/example/mody/domain/post/entity/mapping/MemberPostLike.java @@ -1,5 +1,6 @@ package com.example.mody.domain.post.entity.mapping; +import jakarta.persistence.*; import org.hibernate.annotations.DynamicInsert; import org.hibernate.annotations.DynamicUpdate; @@ -7,15 +8,6 @@ import com.example.mody.domain.post.entity.Post; import com.example.mody.global.common.base.BaseEntity; -import jakarta.persistence.Column; -import jakarta.persistence.Entity; -import jakarta.persistence.FetchType; -import jakarta.persistence.GeneratedValue; -import jakarta.persistence.GenerationType; -import jakarta.persistence.Id; -import jakarta.persistence.JoinColumn; -import jakarta.persistence.ManyToOne; -import jakarta.persistence.Table; import lombok.AccessLevel; import lombok.AllArgsConstructor; import lombok.Builder; @@ -29,7 +21,10 @@ @AllArgsConstructor @DynamicInsert @DynamicUpdate -@Table(name = "member_post_like") +@Table(name = "member_post_like", indexes = { + @Index(name = "idx_member_post", + columnList = "member_id, post_id") +}) public class MemberPostLike extends BaseEntity { @Id diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java index dafacb5a..863a3f1c 100644 --- a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java +++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepository.java @@ -2,16 +2,18 @@ import com.example.mody.domain.bodytype.entity.BodyType; import com.example.mody.domain.member.entity.Member; -import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponses; import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; import com.example.mody.domain.post.entity.Post; -import jakarta.persistence.EntityManager; import java.util.Optional; public interface PostCustomRepository { - public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType); + public PostResponses getBodyTypePosts(Optional cursorPost, Integer size, Member member, BodyType bodyType); + public PostResponses getOtherBodyTypePosts(Optional cursorPost, Integer size, Member member, BodyType bodyType); public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member); - public PostListResponse getMyPosts(Long cursor, Integer size, Member member); + public PostResponses getMyPosts(Long cursor, Integer size, Member member); + public PostResponses getRecentPosts(Long cursor, Integer size, Member member); + } diff --git a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java index 12db84a3..ef648179 100644 --- a/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java +++ b/src/main/java/com/example/mody/domain/post/repository/PostCustomRepositoryImpl.java @@ -1,9 +1,10 @@ package com.example.mody.domain.post.repository; import com.example.mody.domain.bodytype.entity.BodyType; +import com.example.mody.domain.bodytype.entity.QBodyType; import com.example.mody.domain.member.entity.Member; import com.example.mody.domain.member.entity.QMember; -import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponses; import com.example.mody.domain.post.dto.response.PostResponse; import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; import com.example.mody.domain.post.entity.Post; @@ -12,19 +13,15 @@ import com.example.mody.domain.post.entity.mapping.QMemberPostLike; import com.querydsl.core.BooleanBuilder; import com.querydsl.core.group.GroupBy; -import com.querydsl.core.types.ConstantImpl; import com.querydsl.core.types.OrderSpecifier; import com.querydsl.core.types.Projections; import com.querydsl.core.types.dsl.*; -import com.querydsl.core.types.dsl.StringTemplate; -import com.querydsl.jpa.JPAExpressions; import com.querydsl.jpa.impl.JPAQueryFactory; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.stereotype.Repository; -import java.time.format.DateTimeFormatter; import java.util.*; import java.util.function.BiFunction; @@ -39,6 +36,7 @@ public class PostCustomRepositoryImpl implements PostCustomRepository{ private final QMember qMember = QMember.member; private final QMemberPostLike qMemberPostLike = QMemberPostLike.memberPostLike; private final QPostImage qPostImage = QPostImage.postImage; + private final QBodyType qBodyType = QBodyType.bodyType; /** @@ -50,60 +48,47 @@ public class PostCustomRepositoryImpl implements PostCustomRepository{ * @return */ @Override - public PostListResponse getPostList(Optional cursorPost, Integer size, Member member, Optional bodyType) { + public PostResponses getBodyTypePosts(Optional cursorPost, Integer size, Member member, BodyType bodyType) { BooleanBuilder predicate = new BooleanBuilder(); predicate.and(qPost.isPublic.eq(true)); // 공개여부 == true + predicate.and(qPost.bodyType.eq(bodyType)); if(cursorPost.isPresent()){ - String customCursor = createCustomCursor(cursorPost.get(), bodyType); - log.info(customCursor); - predicate.and(applyCustomCursor(customCursor, bodyType)); - } - - BooleanExpression isLiked = Expressions.asBoolean(false); - if (member != null){ - isLiked = isLikedResult(member); + predicate.and(qPost.id.lt(cursorPost.get().getId())); } //동적 정렬 List> orderSpecifiers = new ArrayList<>(); - if (bodyType.isPresent()) { - orderSpecifiers.add(matchedBodyTypeAsInteger(bodyType).desc()); // bodyType이 존재할 때만 정렬 조건 추가 - } - orderSpecifiers.add(qPost.createdAt.desc()); // 항상 createdAt으로 정렬 + orderSpecifiers.add(qPost.id.desc()); // id를 auto_increment 를 사용하므로 created_at을 대신하여 id로 최신순 정렬을 함 - List postIds = jpaQueryFactory - .select(qPost.id) - .from(qPost) - .where(predicate) - .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) - .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 - .fetch(); + List postIds = getPostIds(predicate, size, orderSpecifiers); + List postResponses = getPostResponsesByIds(postIds, member, orderSpecifiers); - Map postResponseMap = jpaQueryFactory - .from(qPost) - .leftJoin(qPost.member, qMember) - .leftJoin(qPost.images, qPostImage) - .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member))) - .where(qPost.id.in(postIds)) - .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) - .transform(GroupBy.groupBy(qPost.id).as( - Projections.constructor(PostResponse.class, - qPost.id, - qMember.id, - qMember.nickname, - qPost.content, - qPost.isPublic, - qPost.likeCount, - qMemberPostLike.isNotNull(), - qPost.bodyType.name, - GroupBy.list(qPostImage) - ))); + // 여기서는 목록 개수가 size 개수와 동일한 경우도 hasNext가 true임. + return PostResponses.of(hasNext.apply(postResponses, size-1), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } - List postResponses = new ArrayList<>(postResponseMap.values()); + @Override + public PostResponses getOtherBodyTypePosts(Optional cursorPost, Integer size, Member member, BodyType bodyType) { + BooleanBuilder predicate = new BooleanBuilder(); + + predicate.and(qPost.isPublic.eq(true)); // 공개여부 == true + predicate.and(qPost.bodyType.ne(bodyType)); + + if(cursorPost.isPresent()){ + predicate.and(qPost.id.lt(cursorPost.get().getId())); + } - return PostListResponse.of(hasNext.apply(postResponses, size), + //동적 정렬 + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(qPost.id.desc()); + + List postIds = getPostIds(predicate, size+1, orderSpecifiers); //하나 더 가져와서 다음 요소가 존재하는지 확인 + List postResponses = getPostResponsesByIds(postIds, member, orderSpecifiers); + + return PostResponses.of(hasNext.apply(postResponses, size), postResponses.subList(0, Math.min(size, postResponses.size()))); } @@ -121,39 +106,12 @@ public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member predicate.and(qMemberPostLike.id.lt(cursor)); } - predicate.and(qMemberPostLike.member.eq(member)); - - List postIds = jpaQueryFactory - .select(qPost.id) - .from(qPost) - .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member))) - .where(predicate) - .orderBy(qMemberPostLike.createdAt.desc()) - .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 - .fetch(); - - Map postResponseMap = jpaQueryFactory - .from(qPost) - .leftJoin(qPost.member, qMember) - .leftJoin(qPost.images, qPostImage) - .where(qPost.id.in(postIds)) - .transform(GroupBy.groupBy(qPost.id).as( - Projections.constructor(PostResponse.class, - qPost.id, - qMember.id, - qMember.nickname, - qPost.content, - qPost.isPublic, - qPost.likeCount, - Expressions.asBoolean(Expressions.TRUE), - qPost.bodyType.name, - GroupBy.list(qPostImage) - ))); + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(qMemberPostLike.id.desc()); // 최근에 누른 좋아요 순으로 정렬 - List postResponses = postIds.stream() - .map(postResponseMap::get) - .filter(Objects::nonNull) - .toList(); + List postIds = getMemberLikedPostIds( + predicate, size+1, member, orderSpecifiers); + List postResponses = getLikedPostResponsesByIds(postIds, member); return new LikedPostsResponse( hasNext.apply(postResponses, size), @@ -161,7 +119,7 @@ public LikedPostsResponse getLikedPosts(Long cursor, Integer size, Member member } @Override - public PostListResponse getMyPosts(Long cursor, Integer size, Member member) { + public PostResponses getMyPosts(Long cursor, Integer size, Member member) { BooleanBuilder predicate = new BooleanBuilder(); predicate.and(qPost.member.eq(member)); @@ -170,28 +128,74 @@ public PostListResponse getMyPosts(Long cursor, Integer size, Member member) { predicate.and(qPost.id.lt(cursor)); } - log.info(predicate.toString()); + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(qPost.id.desc()); + + List postIds = getPostIds(predicate, size+1, orderSpecifiers); + List postResponses = getPostResponsesByIds(postIds, member, orderSpecifiers); + + return PostResponses.of(hasNext.apply(postResponses, size), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } - List postIds = jpaQueryFactory + @Override + public PostResponses getRecentPosts(Long cursor, Integer size, Member member) { + BooleanBuilder predicate = new BooleanBuilder(); + + predicate.and(qPost.isPublic.eq(true)); + + if(cursor != null){ + predicate.and(qPost.id.lt(cursor)); + } + + List> orderSpecifiers = new ArrayList<>(); + orderSpecifiers.add(qPost.id.desc()); + + List postIds = getPostIds(predicate, size+1, orderSpecifiers); + List postResponses = getPostResponsesByIds(postIds, member, orderSpecifiers); + + return PostResponses.of(hasNext.apply(postResponses, size), + postResponses.subList(0, Math.min(size, postResponses.size()))); + } + + private List getPostIds(BooleanBuilder predicate, Integer size, + List> orderSpecifiers){ + return jpaQueryFactory .select(qPost.id) .from(qPost) .where(predicate) - .orderBy(qPost.createdAt.desc()) - .limit(size+1) //하나 더 가져와서 다음 요소가 존재하는지 확인 + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .limit(size) .fetch(); + } + private List getMemberLikedPostIds(BooleanBuilder predicate, Integer size, Member member, + List> orderSpecifiers){ + return jpaQueryFactory + .select(qPost.id) + .from(qPost) + .innerJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member))) + .where(predicate) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) + .limit(size) + .fetch(); + } + private List getPostResponsesByIds (List postIds, Member member, + List> orderSpecifiers){ Map postResponseMap = jpaQueryFactory .from(qPost) - .leftJoin(qPost.member, qMember) + .innerJoin(qPost.member, qMember) .leftJoin(qPost.images, qPostImage) .leftJoin(qMemberPostLike).on(qMemberPostLike.post.eq(qPost).and(qMemberPostLike.member.eq(member))) + .innerJoin(qPost.bodyType, qBodyType) .where(qPost.id.in(postIds)) - .orderBy( qPost.createdAt.desc()) + .orderBy(orderSpecifiers.toArray(new OrderSpecifier[0])) .transform(GroupBy.groupBy(qPost.id).as( Projections.constructor(PostResponse.class, qPost.id, qMember.id, qMember.nickname, + qMember.eq(member), qPost.content, qPost.isPublic, qPost.likeCount, @@ -201,87 +205,40 @@ public PostListResponse getMyPosts(Long cursor, Integer size, Member member) { ))); List postResponses = new ArrayList<>(postResponseMap.values()); - - return PostListResponse.of(hasNext.apply(postResponses, size), - postResponses.subList(0, Math.min(size, postResponses.size()))); - } - - private BiFunction , Integer, Boolean> hasNext = (list, size) -> list.size() > size; - - /** - * // 정렬 기준. 특정 바디 타입이 요구되지 않으면 전부 1 - * @param bodyType - * @return - */ - private NumberExpression matchedBodyTypeAsInteger(Optional bodyType){ - if(bodyType.isPresent()){ - return new CaseBuilder() - .when(qPost.bodyType.eq(bodyType.get())).then(1) - .otherwise(0); - } - return Expressions.asNumber(0); + return postResponses; } - private StringExpression matchedBodyTypeAsString(Optional bodyType){ - if(bodyType.isPresent()){ - return new CaseBuilder() - .when(qPost.bodyType.eq(bodyType.get())).then("1") - .otherwise("0"); - } - return Expressions.asString("0"); - } - - private BooleanExpression isLikedResult(Member member){ - return JPAExpressions - .selectFrom(qMemberPostLike) - .where(qMemberPostLike.member.eq(member).and(qMemberPostLike.post.eq(qPost))) - .exists(); - } - - private String createCustomCursor(Post cursor, Optional bodyType){ - if (cursor == null || bodyType == null) { - return null; - } - - String isMatchedBodyType = "0"; - if(bodyType.isPresent()){ - if(cursor.getBodyType().getId().equals(bodyType.get().getId())){ - isMatchedBodyType = "1"; - } - } - - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - return isMatchedBodyType + String.format("%19s", formatter.format(cursor.getCreatedAt())).replaceAll(" ","0"); - } + private List getLikedPostResponsesByIds (List postIds, Member member){ + Map postResponseMap = jpaQueryFactory + .from(qPost) + .innerJoin(qPost.member, qMember) + .leftJoin(qPost.images, qPostImage) + .innerJoin(qPost.bodyType, qBodyType) + .where(qPost.id.in(postIds)) + .transform(GroupBy.groupBy(qPost.id).as( + Projections.constructor(PostResponse.class, + qPost.id, + qMember.id, + qMember.nickname, + qMember.eq(member), + qPost.content, + qPost.isPublic, + qPost.likeCount, + Expressions.asBoolean(Expressions.TRUE), + qPost.bodyType.name, + GroupBy.list(qPostImage) + ))); - /** - * createdAt을 'YYYYMMDDHHmmss' 로 변환 - * @return - */ - private StringTemplate mySqlDateFormat(){ - return Expressions.stringTemplate( - "CAST(DATE_FORMAT({0}, {1}) AS STRING)", - qPost.createdAt, - ConstantImpl.create("%Y%m%d%H%i%s") - ); + return orderByPostIds(postIds, postResponseMap); } - private StringExpression formatCreatedAt(StringTemplate stringTemplate){ - return StringExpressions.lpad(stringTemplate, 19, '0'); + private List orderByPostIds(List postIds, Map postResponseMap){ + return postIds.stream() + .map(postResponseMap::get) + .filter(Objects::nonNull) + .toList(); } - private BooleanExpression applyCustomCursor(String customCursor, Optional bodyType) { - if (customCursor == null) { // 커서가 없으면 조건 없음 - return null; - } - - // bodyType 순서 계산 - StringExpression isMatchedBodyType = matchedBodyTypeAsString(bodyType); // bodyType 일치 여부 - StringTemplate postCreatedAtTemplate = mySqlDateFormat(); //DATE_FORMAT으로 날짜 형태 변경 - StringExpression formattedCreatedAt = formatCreatedAt(postCreatedAtTemplate); // 날짜 형태 변경을 포맷 - - return isMatchedBodyType.concat(formattedCreatedAt) - .lt(customCursor); - } + private BiFunction , Integer, Boolean> hasNext = (list, size) -> list.size() > size; } diff --git a/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java index 27e3b2dc..a4e3ddba 100644 --- a/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java +++ b/src/main/java/com/example/mody/domain/post/service/PostCommandServiceImpl.java @@ -16,6 +16,7 @@ import com.example.mody.global.common.exception.RestApiException; import com.example.mody.global.common.exception.code.status.S3ErrorStatus; import org.springframework.http.HttpMethod; +import org.springframework.security.core.parameters.P; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -66,10 +67,12 @@ public void createPost(PostCreateRequest postCreateRequest, Member member) { BodyType bodyType = optionalBodyType.orElseThrow(() -> new BodyTypeException(MEMBER_BODY_TYPE_NOT_FOUND)); - Post post = new Post(member, - bodyType, - postCreateRequest.getContent(), - postCreateRequest.getIsPublic()); + Post post = Post.builder() + .member(member) + .bodyType(bodyType) + .content(postCreateRequest.getContent()) + .isPublic(postCreateRequest.getIsPublic()) + .build(); postCreateRequest.getS3Urls().forEach(s3Url -> { validateS3Url(s3Url); // 유효한 S3 url인지 검증 diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryService.java b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java index 430729be..34cd1581 100644 --- a/src/main/java/com/example/mody/domain/post/service/PostQueryService.java +++ b/src/main/java/com/example/mody/domain/post/service/PostQueryService.java @@ -1,12 +1,12 @@ package com.example.mody.domain.post.service; import com.example.mody.domain.member.entity.Member; -import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponses; import com.example.mody.domain.post.dto.response.PostResponse; public interface PostQueryService { - public PostListResponse getPosts(Member member, Integer size, Long cursor); - public PostListResponse getLikedPosts(Member member, Integer size, Long cursor); - public PostListResponse getMyPosts(Member member, Integer size, Long cursor); + public PostResponses getPosts(Member member, Integer size, Long cursor); + public PostResponses getLikedPosts(Member member, Integer size, Long cursor); + public PostResponses getMyPosts(Member member, Integer size, Long cursor); public PostResponse getPost(Member member, Long postId); } diff --git a/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java index ecc2e9dc..8531fa7c 100644 --- a/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java +++ b/src/main/java/com/example/mody/domain/post/service/PostQueryServiceImpl.java @@ -4,7 +4,7 @@ import com.example.mody.domain.bodytype.service.BodyTypeService; import com.example.mody.domain.exception.PostException; import com.example.mody.domain.member.entity.Member; -import com.example.mody.domain.post.dto.response.PostListResponse; +import com.example.mody.domain.post.dto.response.PostResponses; import com.example.mody.domain.post.dto.response.PostResponse; import com.example.mody.domain.post.dto.response.recode.LikedPostsResponse; import com.example.mody.domain.post.entity.Post; @@ -33,25 +33,25 @@ public class PostQueryServiceImpl implements PostQueryService { @Override @Transactional(readOnly = true) - public PostListResponse getPosts(Member member, Integer size, Long cursor) { - + public PostResponses getPosts(Member member, Integer size, Long cursor) { if(size<=0){ throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); } - Optional cursorPost = getCursorPost(cursor); - - if(member != null){ - Optional bodyTypeOptional = bodyTypeService.findLastBodyType(member); - return postRepository.getPostList(cursorPost, size, member, bodyTypeOptional); + Optional bodyTypeOptional = bodyTypeService.findLastBodyType(member); + if(bodyTypeOptional.isEmpty()){ + return getRecentPostResponses(member, size, cursor); } + BodyType userBodyType = bodyTypeOptional.get(); + + Optional cursorPost = getCursorPost(cursor); - return postRepository.getPostList(cursorPost, size, member, Optional.empty()); + return getPostsByBodyType(member, size, cursorPost, userBodyType); } @Override @Transactional(readOnly = true) - public PostListResponse getLikedPosts(Member member, Integer size, Long cursor) { + public PostResponses getLikedPosts(Member member, Integer size, Long cursor) { if(size<=0){ throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); } @@ -63,12 +63,12 @@ public PostListResponse getLikedPosts(Member member, Integer size, Long cursor) nextLike = Optional.ofNullable(findByMemberAndPostId(member, postResponse.getPostId()).getId()); } - return PostListResponse.of(likedPostsResponse.hasNext(), likedPostsResponse.postResponses(), nextLike.orElse(null)); + return PostResponses.of(likedPostsResponse.hasNext(), likedPostsResponse.postResponses(), nextLike.orElse(null)); } @Override @Transactional(readOnly = true) - public PostListResponse getMyPosts(Member member, Integer size, Long cursor) { + public PostResponses getMyPosts(Member member, Integer size, Long cursor) { if(size<=0){ throw new RestApiException(GlobalErrorStatus.NEGATIVE_PAGE_SIZE_REQUEST); } @@ -96,10 +96,42 @@ public PostResponse getPost(Member member, Long postId){ Optional existingLike = postLikeRepository.findByPostAndMember(post, member); - PostResponse postResponse = new PostResponse(post.getId(), post.getMember().getId(),post.getMember().getNickname(), post.getContent(), post.getIsPublic(), post.getLikeCount(), existingLike.isPresent() ,post.getBodyType().getName(), post.getImages()); + PostResponse postResponse = new PostResponse(post.getId(), post.getMember().getId(),post.getMember().getNickname(),post.getMember().equals(member), post.getContent(), post.getIsPublic(), post.getLikeCount(), existingLike.isPresent() ,post.getBodyType().getName(), post.getImages()); return postResponse; } + private PostResponses getRecentPostResponses(Member member, Integer size, Long cursor){ + return postRepository.getRecentPosts(cursor, size, member); + } + + private PostResponses getPostsByBodyType( + Member member, Integer size, Optional cursorPost, BodyType userBodyType){ + // 커서가 존재하지 않거나(== 최초 조회) 커서 post의 바디타입이 유저의 바디타입과 일치하는 경우 + if(cursorPost.isEmpty() || + cursorPost.get().getBodyType().equals(userBodyType)){ + return getBodyTypePosts(member, size, cursorPost, userBodyType); + } + return getOtherBodyTypePosts(member, size,Optional.empty(), userBodyType); + } + + private PostResponses getBodyTypePosts( + Member member, Integer size, Optional cursorPost, BodyType userBodyType){ + PostResponses bodyTypePosts = postRepository.getBodyTypePosts(cursorPost, size, member, userBodyType); + + int insufficientCount = size - bodyTypePosts.getPostResponses().size(); + if (insufficientCount > 0){ + PostResponses otherBodyTypePosts = getOtherBodyTypePosts( + member, insufficientCount,Optional.empty(), userBodyType); + return PostResponses.of(bodyTypePosts, otherBodyTypePosts); + } + return bodyTypePosts; + } + + private PostResponses getOtherBodyTypePosts( + Member member, Integer size, Optional cursorPost, BodyType userBodyType){ + return postRepository.getOtherBodyTypePosts(cursorPost, size, member, userBodyType); + } + } diff --git a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java index 0da5e2a8..906e8fed 100644 --- a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java +++ b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationController.java @@ -9,12 +9,6 @@ import com.example.mody.domain.recommendation.service.RecommendationCommendService; import com.example.mody.domain.recommendation.service.RecommendationQueryService; import com.example.mody.global.common.base.BaseResponse; -import io.swagger.v3.oas.annotations.Operation; -import io.swagger.v3.oas.annotations.media.Content; -import io.swagger.v3.oas.annotations.media.ExampleObject; -import io.swagger.v3.oas.annotations.media.Schema; -import io.swagger.v3.oas.annotations.responses.ApiResponse; -import io.swagger.v3.oas.annotations.responses.ApiResponses; import io.swagger.v3.oas.annotations.tags.Tag; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; @@ -40,7 +34,7 @@ public BaseResponse getStyleCategories() { return BaseResponse.onSuccess(categoryResponse); } - // 스타일 추천 좋아요 + // 추천 좋아요 @PostMapping("/{recommendationId}/like") public BaseResponse toggleStyleLike( @PathVariable(name = "recommendationId") Long recommendationId, diff --git a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java index 8a68a82b..855c1ce2 100644 --- a/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java +++ b/src/main/java/com/example/mody/domain/recommendation/controller/RecommendationControllerInterface.java @@ -13,8 +13,6 @@ 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 jakarta.validation.Valid; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.*; public interface RecommendationControllerInterface { @@ -30,11 +28,11 @@ public interface RecommendationControllerInterface { BaseResponse getStyleCategories(); - @Operation(summary = "스타일 추천 좋아요 API", description = "스타일 추천에 대한 좋아요 기능") + @Operation(summary = "추천 좋아요 API", description = "추천에 대한 좋아요 기능") @PostMapping("/{recommendationId}/like") @ApiResponses({ - @ApiResponse(responseCode = "COMMON200", description = "스타일 추천에 좋아요 성공"), - @ApiResponse(responseCode = "RECOMMENDATION404", description = "요청한 스타일 추천 결과물이 존재하지 않는 경우") + @ApiResponse(responseCode = "COMMON200", description = "추천에 좋아요 성공"), + @ApiResponse(responseCode = "RECOMMENDATION404", description = "요청한 추천 결과물이 존재하지 않는 경우") }) BaseResponse toggleStyleLike(Long recommendationId,CustomUserDetails customUserDetails); diff --git a/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java b/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java index 40a53ca6..651dd341 100644 --- a/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java +++ b/src/main/java/com/example/mody/domain/recommendation/entity/Recommendation.java @@ -52,7 +52,7 @@ public class Recommendation extends BaseEntity { cascade = CascadeType.ALL, orphanRemoval = true, fetch = FetchType.LAZY) - private List RecommendLikes = new ArrayList<>(); + private List recommendationLikes = new ArrayList<>(); public void increaseLikeCount() { this.likeCount++; diff --git a/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java b/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java index fb4c1b6f..16e7b380 100644 --- a/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java +++ b/src/main/java/com/example/mody/domain/recommendation/service/CrawlerService.java @@ -21,24 +21,29 @@ @RequiredArgsConstructor public class CrawlerService { - public String getRandomImageUrl(String keyword) { + private static final String PINTEREST_SEARCH_URL_TEMPLATE = "https://kr.pinterest.com/search/pins/?q="; + private static final String IMAGE_XPATH = "//img[contains(@src, 'https')]"; + private static final int MAX_IMAGES = 10; + + public String getRandomImageUrl(String keyword) { WebDriver driver = getWebDriver(); WebDriverWait wait = new WebDriverWait(driver, Duration.ofSeconds(30)); try { - String searchUrl = "https://kr.pinterest.com/search/pins/?q=" + URLEncoder.encode(keyword, StandardCharsets.UTF_8); + // 페이지 접속 + String searchUrl = PINTEREST_SEARCH_URL_TEMPLATE + URLEncoder.encode(keyword + " 스타일", StandardCharsets.UTF_8); driver.navigate().to(searchUrl); log.info("Pinterest 검색 URL 접속 완료: {}", searchUrl); // 이미지 태그 검색 결과 로드 - wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath("//img[contains(@src, 'https')]"))); + wait.until(ExpectedConditions.presenceOfElementLocated(By.xpath(IMAGE_XPATH))); log.info("이미지 태그 검색 결과 로드 완료"); Set imageUrls = new HashSet<>(); - List images = driver.findElements(By.xpath("//img[contains(@src, 'https')]")); + List images = driver.findElements(By.xpath(IMAGE_XPATH)); - int maxImages = Math.min(images.size(), 7); // 최대 이미지 7개(핀터레스트 첫 줄 사진이 7개) + int maxImages = Math.min(images.size(), MAX_IMAGES); for (int i = 0; i < maxImages; i++) { String url = images.get(i).getAttribute("src"); if (url != null && !url.isEmpty()) { @@ -61,8 +66,6 @@ public String getRandomImageUrl(String keyword) { } catch (Exception e) { log.error("크롤링 실패 (키워드: {}):", keyword); throw new RestApiException(CrawlerErrorStatus.CRAWLING_FAILED); - } finally { - driver.quit(); } } @@ -74,7 +77,7 @@ private WebDriver getWebDriver() { options.addArguments("--disable-dev-shm-usage"); // /dev/shm 사용 비활성화(Docker 환경에서 크롬 크래시 문제 해결) options.addArguments("--ignore-ssl-errors=yes"); options.addArguments("--ignore-certificate-errors"); // SSL 차단 대비 - + options.addArguments("--window-size=1920,1080"); // 해상도 설정(가로 1920px, 세로 1080px) return new ChromeDriver(options); } } diff --git a/src/main/java/com/example/mody/global/config/SecurityConfig.java b/src/main/java/com/example/mody/global/config/SecurityConfig.java index 37a1e9bc..9da5a5c4 100644 --- a/src/main/java/com/example/mody/global/config/SecurityConfig.java +++ b/src/main/java/com/example/mody/global/config/SecurityConfig.java @@ -56,6 +56,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .sessionCreationPolicy(SessionCreationPolicy.STATELESS)) .httpBasic(httpBasic -> httpBasic.disable()) .authorizeHttpRequests(authz -> authz + .requestMatchers("/auth/signup/complete", "/auth/logout").authenticated() .requestMatchers("/auth/**", "/oauth2/**").permitAll() .requestMatchers("/swagger-ui/**", "/v3/api-docs/**", "/swagger-ui.html").permitAll() .requestMatchers("/email/**").permitAll() @@ -130,7 +131,7 @@ public CorsConfigurationSource corsConfigurationSource() { @Bean public JwtAuthenticationFilter jwtAuthenticationFilter() { - return new JwtAuthenticationFilter(jwtProvider, memberRepository, objectMapper, memberQueryService); + return new JwtAuthenticationFilter(jwtProvider, memberQueryService, objectMapper); } @Bean diff --git a/src/main/java/com/example/mody/global/config/SwaggerConfig.java b/src/main/java/com/example/mody/global/config/SwaggerConfig.java index 8f839d4d..4c5fae5d 100644 --- a/src/main/java/com/example/mody/global/config/SwaggerConfig.java +++ b/src/main/java/com/example/mody/global/config/SwaggerConfig.java @@ -14,8 +14,10 @@ @Configuration @OpenAPIDefinition( servers = { - @Server(url = "https://kkoalla.app:8443", description = "모디 https 개발 서버입니다."), + @Server(url = "https://kkoalla.app:8443/dev", description = "모디 https 개발 서버입니다."), + @Server(url = "https://kkoalla.app:8443/prod", description = "모디 https 배포 서버입니다."), @Server(url = "http://3.37.4.11:8000", description = "모디 http 개발 서버입니다."), + @Server(url = "http://3.37.4.11:8080", description = "모디 http 배포 서버입니다."), @Server(url = "http://localhost:8080", description = "모디 local 서버입니다.") } ) diff --git a/src/main/java/com/example/mody/global/templates/PromptManager.java b/src/main/java/com/example/mody/global/templates/PromptManager.java index c8052a3b..ce8bb3f5 100644 --- a/src/main/java/com/example/mody/global/templates/PromptManager.java +++ b/src/main/java/com/example/mody/global/templates/PromptManager.java @@ -18,6 +18,7 @@ public String createBodyTypeAnalysisPrompt(String nickName, Gender gender, Strin """ ## 명령 닉네임과 성별, 그리고 사용자의 답변을 기반으로 체형 타입(네추럴, 스트레이트, 웨이브 중 하나)을 분석하고, 설명과 스타일링 팁을 제공해줘. + 이 때 강조할 부분과 보완할 부분을 기계적이지 않고 자세하게 답변해줘. 결과는 JSON 형식으로 반환해줘. ## 사용자 정보