From 484ef4750d91860d3526f750f2114d17a728e42d Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Wed, 15 Oct 2025 10:24:38 +0900 Subject: [PATCH 01/28] =?UTF-8?q?chore[initdate]:=20ddl=20=ED=83=80?= =?UTF-8?q?=EC=9E=85=20=EC=9E=AC=EC=A0=81=EC=9A=A9?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/backend/src/main/resources/application.yml b/backend/src/main/resources/application.yml index 1e2e258..8badc76 100644 --- a/backend/src/main/resources/application.yml +++ b/backend/src/main/resources/application.yml @@ -61,10 +61,10 @@ spring: jdbc: initialize-schema: never - jpa: - show-sql: true - hibernate: - ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} +# jpa: +# show-sql: true +# hibernate: +# ddl-auto: ${SPRING_JPA_HIBERNATE_DDL_AUTO} properties: hibernate: From d1a9d4d4400ebd73c279e5df424d2106d56f4465 Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Wed, 15 Oct 2025 11:18:27 +0900 Subject: [PATCH 02/28] =?UTF-8?q?chore[infra]:=20sql=20=EB=A1=9C=EA=B7=B8?= =?UTF-8?q?=20=EC=A4=84=EC=9D=B4=EA=B8=B0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/src/main/resources/application-prod.yml | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 9fa63b6..498a506 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -17,6 +17,7 @@ spring: connection-timeout: 30000 max-lifetime: 1800000 # MySQL wait_timeout(기본 28800)보다 짧게 jpa: + show-sql: false open-in-view: false # 프로덕션 권장 hibernate: ddl-auto: ${PROD_JPA_HIBERNATE_DDL_AUTO} # 운영 DB는 보통 validate (또는 none) @@ -88,4 +89,12 @@ sentry: dsn: ${PROD_SENTRY_DSN} environment: "prod" release: "my-app@0.1.0-prod" - send-default-pii: true \ No newline at end of file + send-default-pii: true + +logging: + level: + org.hibernate.SQL: WARN # SQL 문 로그 줄이기 + org.hibernate.orm.jdbc.bind: OFF # 바인딩 파라미터 로그 끔(Hibernate 6) + org.hibernate.type.descriptor.jdbc: OFF + org.springframework.jdbc.core: ERROR # JdbcTemplate 디버그 억제 + com.zaxxer.hikari: INFO # 커넥션 풀 상태만 간결하게 \ No newline at end of file From c23dee2fc5d4cb2af7d8d8c18f67cc11007e5c37 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 11:51:43 +0900 Subject: [PATCH 03/28] =?UTF-8?q?Fix[post]:=EA=B2=8C=EC=8B=9C=EA=B8=80=20?= =?UTF-8?q?=EC=82=AD=EC=A0=9C,=20=EB=B3=B8=EC=9D=B8=20=EA=B2=8C=EC=8B=9C?= =?UTF-8?q?=EB=AC=BC=20404=20=EC=82=AD=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/domain/poll/service/PollService.java | 2 +- .../domain/post/controller/PostController.java | 9 +++++++++ .../ai/lawyer/domain/post/service/PostService.java | 1 + .../lawyer/domain/post/service/PostServiceImpl.java | 13 ++++++++++--- 4 files changed, 21 insertions(+), 4 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 63e7853..0d1ddca 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -37,7 +37,7 @@ public interface PollService { PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId); void patchUpdatePoll(Long pollId, PollUpdateDto pollUpdateDto); void closePoll(Long pollId); - void deletePoll(Long pollId, Long memberId); + void deletePoll(Long pollId, Long memberId); // ===== 검증 관련 ===== void validatePollCreate(PollCreateDto dto); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java index 38e00a6..fd860c2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostController.java @@ -114,6 +114,15 @@ public ResponseEntity> deletePost(@PathVariable Long postId) { return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); } + @Operation(summary = "게시글 삭제(관리자)") + @DeleteMapping("/admin/{postId}") + public ResponseEntity> deletePostAdmin(@PathVariable Long postId) { + //AuthUtil.validateAdmin(); 관리자 + AuthUtil.getAuthenticatedMemberId(); // 모든 유저 + postService.deletePostAdmin(postId); + return ResponseEntity.ok(new ApiResponse<>(200, "게시글이 삭제되었습니다.", null)); + } + @ExceptionHandler(ResponseStatusException.class) public ResponseEntity> handleResponseStatusException(ResponseStatusException ex) { int code = ex.getStatusCode().value(); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java index 65860de..b87dde0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostService.java @@ -26,6 +26,7 @@ public interface PostService { PostDto updatePost(Long postId, PostUpdateDto postUpdateDto); void patchUpdatePost(Long postId, PostUpdateDto postUpdateDto); void deletePost(Long postId); + void deletePostAdmin(Long postId); PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId); // ===== 본인 게시글 관련 ===== diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 2024b2e..2b007cb 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -100,9 +100,9 @@ public PostDetailDto getPostById(Long postId) { public List getPostsByMemberId(Long memberId) { Member member = AuthUtil.getMemberOrThrow(memberId); List posts = postRepository.findByMember(member); - if (posts.isEmpty()) { - throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); - } +// if (posts.isEmpty()) { +// throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); +// } return posts.stream() .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) // 최신순 정렬 .map(post -> convertToDto(post, memberId)) @@ -145,6 +145,13 @@ public void deletePost(Long postId) { postRepository.delete(post); } + @Override + public void deletePostAdmin(Long postId) { + Post post = postRepository.findById(postId) + .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "삭제할 게시글을 찾을 수 없습니다.")); + postRepository.delete(post); + } + @Override public List getAllPosts(Long memberId) { return postRepository.findAll().stream() From 532e5ae3b6a640b518c3bdad3e4666c575b3e111 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 15:01:18 +0900 Subject: [PATCH 04/28] Fix[post]:gender enum --- .../lawyer/domain/poll/repository/PollVoteRepositoryImpl.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java index dfefe91..faa1172 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java @@ -97,7 +97,8 @@ public List countStaticsByPollOptionIds(List pollOptionIds .fetch(); return tuples.stream() .map(t -> { - String gender = t.get(1, String.class); + Member.Gender genderEnum = t.get(1, Member.Gender.class); + String gender = genderEnum != null ? genderEnum.name() : "기타"; Integer age = t.get(2, Integer.class); String ageGroup = getAgeGroup(age); Long voteCount = t.get(3, Long.class); From 913721bd877ca8052c181f1f9153afd2459ef463 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 15:06:13 +0900 Subject: [PATCH 05/28] =?UTF-8?q?fix[member]:=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=ED=96=88=EC=9D=84=20=EB=95=8C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20member=5Fid=EB=A5=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ai/lawyer/global/oauth/CustomOAuth2UserService.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java index 251dc48..c3b3e41 100644 --- a/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java +++ b/backend/src/main/java/com/ai/lawyer/global/oauth/CustomOAuth2UserService.java @@ -48,10 +48,12 @@ public OAuth2User loadUser(OAuth2UserRequest userRequest) throws OAuth2Authentic member = createOAuth2Member(userInfo); } else { // 기존 OAuth2 회원 로그인 - log.info("기존 OAuth2 사용자 로그인: email={}, provider={}", userInfo.getEmail(), registrationId); + log.info("기존 OAuth2 사용자 로그인: email={}, provider={}, memberId={}", userInfo.getEmail(), registrationId, member.getMemberId()); } - oauth2MemberRepository.save(member); + // 엔티티를 저장하고 영속화된 엔티티를 반환받아야 memberId가 할당됨 + member = oauth2MemberRepository.save(member); + log.info("OAuth2 회원 저장 완료: memberId={}, loginId={}", member.getMemberId(), member.getLoginId()); // OAuth2 provider의 access token을 Redis에 저장 (연동 해제용) saveOAuth2ProviderAccessToken(userInfo.getEmail(), accessToken); From c37aa81c4504cdc7efa307c25d837baf21f15858 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 15:31:18 +0900 Subject: [PATCH 06/28] =?UTF-8?q?fix[member]:=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=ED=96=88=EC=9D=84=20=EB=95=8C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20member=5Fid=EB=A5=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ai/lawyer/global/util/AuthUtil.java | 77 +++++++++++++++---- 1 file changed, 61 insertions(+), 16 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java index aa8a7a6..6495bf3 100644 --- a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java @@ -1,43 +1,56 @@ package com.ai.lawyer.global.util; import org.springframework.security.core.Authentication; +import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; -import org.springframework.security.core.userdetails.User; import org.springframework.web.server.ResponseStatusException; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Autowired; import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import com.ai.lawyer.domain.member.entity.Member; @Component public class AuthUtil { private static MemberRepository memberRepository; + private static OAuth2MemberRepository oauth2MemberRepository; @Autowired public AuthUtil(MemberRepository memberRepository) { AuthUtil.memberRepository = memberRepository; } + @Autowired(required = false) + public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberRepository) { + AuthUtil.oauth2MemberRepository = oauth2MemberRepository; + } + public static Long getCurrentMemberId() { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); if (authentication != null && authentication.isAuthenticated()) { Object principal = authentication.getPrincipal(); System.out.println("[AuthUtil] principal class: " + principal.getClass().getName() + ", value: " + principal); - if (principal instanceof org.springframework.security.core.userdetails.User user) { - try { - return Long.parseLong(user.getUsername()); - } catch (NumberFormatException e) { - return null; + switch (principal) { + case org.springframework.security.core.userdetails.User user -> { + try { + return Long.parseLong(user.getUsername()); + } catch (NumberFormatException e) { + return null; + } + } + case String str -> { + try { + return Long.parseLong(str); + } catch (NumberFormatException e) { + return null; + } } - } else if (principal instanceof String str) { - try { - return Long.parseLong(str); - } catch (NumberFormatException e) { - return null; + case Long l -> { + return l; + } + default -> { } - } else if (principal instanceof Long l) { - return l; } } return null; @@ -50,13 +63,45 @@ public static String getCurrentMemberRole() { } return authentication.getAuthorities().stream() .findFirst() - .map(auth -> auth.getAuthority()) + .map(GrantedAuthority::getAuthority) .orElse(null); } + /** + * memberId로 회원을 조회합니다. (Member 또는 OAuth2Member) + * OAuth2Member인 경우 Member 객체로 변환하여 반환합니다. + * @param memberId 회원 ID + * @return Member 객체 + * @throws ResponseStatusException 회원을 찾을 수 없는 경우 + */ public static Member getMemberOrThrow(Long memberId) { - return memberRepository.findById(memberId) - .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다")); + // 먼저 Member 테이블에서 조회 + java.util.Optional member = memberRepository.findById(memberId); + if (member.isPresent()) { + return member.get(); + } + + // Member 테이블에 없으면 OAuth2Member 테이블에서 조회 + if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = + oauth2MemberRepository.findById(memberId); + if (oauth2Member.isPresent()) { + // OAuth2Member를 Member로 변환 (엔티티 호환성을 위해) + com.ai.lawyer.domain.member.entity.OAuth2Member oauth = oauth2Member.get(); + return Member.builder() + .memberId(oauth.getMemberId()) + .loginId(oauth.getLoginId()) + .name(oauth.getName()) + .age(oauth.getAge()) + .gender(oauth.getGender()) + .role(oauth.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); + } + } + + // 둘 다 없으면 예외 발생 + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다"); } public static Long getAuthenticatedMemberId() { From cc4e8e1fbd429be540b008f42989e796a98061f0 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 15:50:40 +0900 Subject: [PATCH 07/28] =?UTF-8?q?fix[member]:=EC=86=8C=EC=85=9C=20?= =?UTF-8?q?=EB=A1=9C=EA=B7=B8=EC=9D=B8=EC=9C=BC=EB=A1=9C=20=EC=A7=84?= =?UTF-8?q?=ED=96=89=ED=96=88=EC=9D=84=20=EB=95=8C=20=ED=86=A0=ED=81=B0?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20member=5Fid=EB=A5=BC=20=EC=A1=B0=ED=9A=8C?= =?UTF-8?q?=ED=95=98=EB=8A=94=20=EB=A1=9C=EC=A7=81=EC=97=90=20=EB=8C=80?= =?UTF-8?q?=ED=95=9C=20=EB=B2=84=EA=B7=B8=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../com/ai/lawyer/global/jwt/CookieUtil.java | 22 +++++++++++-------- .../src/main/resources/application-dev.yml | 4 +++- .../src/main/resources/application-prod.yml | 2 ++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java index 8de82fd..ba29ff5 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/CookieUtil.java @@ -26,14 +26,18 @@ public class CookieUtil { // 쿠키 보안 설정 상수 private static final boolean HTTP_ONLY = true; - private static final boolean SECURE_IN_PRODUCTION = false; // 개발환경에서는 false (HTTP), 운영환경에서는 true로 변경 (HTTPS) private static final String COOKIE_PATH = "/"; - private static final String SAME_SITE = "Lax"; // Lax: 같은 사이트 요청에서 쿠키 전송 허용 private static final int COOKIE_EXPIRE_IMMEDIATELY = 0; @Value("${custom.cookie.domain:}") private String cookieDomain; + @Value("${custom.cookie.secure:false}") + private boolean cookieSecure; + + @Value("${custom.cookie.same-site:Lax}") + private String cookieSameSite; + public void setTokenCookies(HttpServletResponse response, String accessToken, String refreshToken) { setAccessTokenCookie(response, accessToken); setRefreshTokenCookie(response, refreshToken); @@ -58,26 +62,26 @@ public void clearTokenCookies(HttpServletResponse response) { * ResponseCookie를 생성합니다 (SameSite 지원). */ private ResponseCookie createResponseCookie(String name, String value, int maxAge) { - log.debug("=== 쿠키 생성 중: name={}, cookieDomain='{}', isEmpty={}", - name, cookieDomain, cookieDomain == null || cookieDomain.isEmpty()); + log.info("=== 쿠키 생성 중: name={}, domain='{}', secure={}, sameSite={}", + name, cookieDomain, cookieSecure, cookieSameSite); ResponseCookie.ResponseCookieBuilder builder = ResponseCookie.from(name, value) .httpOnly(HTTP_ONLY) - .secure(SECURE_IN_PRODUCTION) + .secure(cookieSecure) .path(COOKIE_PATH) .maxAge(Duration.ofSeconds(maxAge)) - .sameSite(SAME_SITE); + .sameSite(cookieSameSite); // 도메인이 설정되어 있으면 추가 if (cookieDomain != null && !cookieDomain.isEmpty()) { - log.debug("쿠키 도메인 설정: {}", cookieDomain); + log.info("쿠키 도메인 설정: {}", cookieDomain); builder.domain(cookieDomain); } else { - log.debug("쿠키 도메인 설정 안 함 (빈 값 또는 null)"); + log.info("쿠키 도메인 설정 안 함 (빈 값 또는 null)"); } ResponseCookie cookie = builder.build(); - log.debug("생성된 쿠키: {}", cookie); + log.info("생성된 쿠키: {}", cookie); return cookie; } diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index efdd512..7a69790 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -71,4 +71,6 @@ custom: frontend: url: ${DEV_FRONTEND_URL} cookie: - domain: ${DEV_COOKIE_DOMAIN} + domain: ${DEV_COOKIE_DOMAIN:} # 개발환경: 도메인 설정 없음 (localhost) + secure: false # HTTP 환경 (localhost) + same-site: Lax # 개발환경에서는 Lax로 충분 diff --git a/backend/src/main/resources/application-prod.yml b/backend/src/main/resources/application-prod.yml index 498a506..0f0d51e 100644 --- a/backend/src/main/resources/application-prod.yml +++ b/backend/src/main/resources/application-prod.yml @@ -84,6 +84,8 @@ custom: url: ${PROD_FRONTEND_URL} cookie: domain: ${PROD_COOKIE_DOMAIN:.trybalaw.com} # 운영환경: 모든 서브도메인에서 쿠키 공유 + secure: true # HTTPS 환경에서는 반드시 true + same-site: None # 크로스 도메인 쿠키 전송 허용 (api.trybalaw.com <-> www.trybalaw.com) sentry: dsn: ${PROD_SENTRY_DSN} From 81f3403b637be492419557887dea08cf492834d8 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 16:08:07 +0900 Subject: [PATCH 08/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../ai/lawyer/global/jwt/CookieUtilTest.java | 49 ++++- .../oauth/CustomOAuth2UserServiceTest.java | 18 ++ .../ai/lawyer/global/util/AuthUtilTest.java | 173 ++++++++++++++++++ 3 files changed, 238 insertions(+), 2 deletions(-) create mode 100644 backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java index 739b637..14b1336 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/CookieUtilTest.java @@ -8,7 +8,6 @@ import org.junit.jupiter.api.Test; import org.junit.jupiter.api.extension.ExtendWith; import org.mockito.ArgumentCaptor; -import org.mockito.InjectMocks; import org.mockito.Mock; import org.mockito.junit.jupiter.MockitoExtension; import org.slf4j.Logger; @@ -32,7 +31,6 @@ class CookieUtilTest { @Mock private HttpServletResponse response; - @InjectMocks private CookieUtil cookieUtil; private static final String ACCESS_TOKEN = "testAccessToken"; @@ -43,6 +41,12 @@ class CookieUtilTest { @BeforeEach void setUp() { log.info("=== 테스트 초기화 ==="); + cookieUtil = new CookieUtil(); + // 테스트 환경 설정: 개발 환경 (HTTP, SameSite=Lax) + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieDomain", ""); + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSecure", false); + org.springframework.test.util.ReflectionTestUtils.setField(cookieUtil, "cookieSameSite", "Lax"); + log.info("CookieUtil 설정 완료: domain='', secure=false, sameSite=Lax"); } @Test @@ -330,4 +334,45 @@ void cookieMaxAgeAttribute_ExpiryTime() { log.info("=== 토큰 만료 시간 테스트 완료 ==="); } + + @Test + @DisplayName("프로덕션 환경 - Secure=true, SameSite=None, Domain 설정") + void productionCookieSettings() { + // given + log.info("=== 프로덕션 환경 쿠키 설정 테스트 시작 ==="); + CookieUtil prodCookieUtil = new CookieUtil(); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieDomain", ".trybalaw.com"); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSecure", true); + org.springframework.test.util.ReflectionTestUtils.setField(prodCookieUtil, "cookieSameSite", "None"); + log.info("프로덕션 설정: domain=.trybalaw.com, secure=true, sameSite=None"); + + // when + prodCookieUtil.setTokenCookies(response, ACCESS_TOKEN, REFRESH_TOKEN); + + // then + ArgumentCaptor headerCaptor = ArgumentCaptor.forClass(String.class); + verify(response, times(2)).addHeader(eq("Set-Cookie"), headerCaptor.capture()); + + var setCookieHeaders = headerCaptor.getAllValues(); + + // 액세스 토큰 쿠키 검증 + String accessCookieHeader = setCookieHeaders.getFirst(); + assertThat(accessCookieHeader).contains(ACCESS_TOKEN_NAME + "=" + ACCESS_TOKEN); + assertThat(accessCookieHeader).contains("HttpOnly"); + assertThat(accessCookieHeader).contains("Secure"); + assertThat(accessCookieHeader).contains("Domain=.trybalaw.com"); + assertThat(accessCookieHeader).contains("SameSite=None"); + log.info("프로덕션 액세스 토큰 쿠키 검증 완료: {}", accessCookieHeader); + + // 리프레시 토큰 쿠키 검증 + String refreshCookieHeader = setCookieHeaders.get(1); + assertThat(refreshCookieHeader).contains(REFRESH_TOKEN_NAME + "=" + REFRESH_TOKEN); + assertThat(refreshCookieHeader).contains("HttpOnly"); + assertThat(refreshCookieHeader).contains("Secure"); + assertThat(refreshCookieHeader).contains("Domain=.trybalaw.com"); + assertThat(refreshCookieHeader).contains("SameSite=None"); + log.info("프로덕션 리프레시 토큰 쿠키 검증 완료: {}", refreshCookieHeader); + + log.info("=== 프로덕션 환경 쿠키 설정 테스트 완료 ==="); + } } diff --git a/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java index b887718..bb7ef00 100644 --- a/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/oauth/CustomOAuth2UserServiceTest.java @@ -96,4 +96,22 @@ private Map createNaverAttributes() { return attributes; } + + @Test + @DisplayName("OAuth2 회원 저장 후 반환된 엔티티 사용 - memberId 할당 확인") + void oauth2MemberSave_ReturnsEntityWithMemberId() { + // given - 이 테스트는 CustomOAuth2UserService에서 save() 반환값을 사용하는지 검증 + // 실제 구현에서는 다음과 같이 수정되어야 함: + // member = oauth2MemberRepository.save(member); + + // when - save() 호출 시 memberId가 할당된 엔티티가 반환됨 + // JPA의 @GeneratedValue 전략 사용 시, save()는 영속화된 엔티티를 반환하며 + // 이 엔티티에는 자동 생성된 ID가 포함되어 있음 + + // then - 반환된 엔티티의 memberId를 사용해야 JWT 토큰 생성 시 올바른 ID가 포함됨 + // 이를 통해 소셜 로그인 후 API 호출 시 member_id 조회가 정상 동작함 + + // 이 테스트는 문서화 목적으로, 실제 동작은 Integration Test에서 검증됨 + assertThat(true).isTrue(); // 개념 검증용 테스트 + } } diff --git a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java new file mode 100644 index 0000000..5ad682d --- /dev/null +++ b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java @@ -0,0 +1,173 @@ +package com.ai.lawyer.global.util; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.domain.member.entity.OAuth2Member; +import com.ai.lawyer.domain.member.repositories.MemberRepository; +import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.web.server.ResponseStatusException; + +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.BDDMockito.given; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +@DisplayName("AuthUtil 테스트") +class AuthUtilTest { + + @Mock + private MemberRepository memberRepository; + + @Mock + private OAuth2MemberRepository oauth2MemberRepository; + + private Member localMember; + private OAuth2Member oauth2Member; + + @BeforeEach + void setUp() { + AuthUtil authUtil = new AuthUtil(memberRepository); + authUtil.setOauth2MemberRepository(oauth2MemberRepository); + + localMember = Member.builder() + .memberId(1L) + .loginId("local@test.com") + .password("encodedPassword") + .name("로컬사용자") + .age(30) + .gender(Member.Gender.MALE) + .role(Member.Role.USER) + .build(); + + oauth2Member = OAuth2Member.builder() + .memberId(2L) + .loginId("oauth@test.com") + .email("oauth@test.com") + .name("소셜사용자") + .age(25) + .gender(Member.Gender.FEMALE) + .provider(OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + } + + @Test + @DisplayName("로컬 회원 조회 성공") + void getMemberOrThrow_LocalMember_Success() { + // given + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + assertThat(result.getName()).isEqualTo("로컬사용자"); + + verify(memberRepository).findById(memberId); + } + + @Test + @DisplayName("OAuth2 회원 조회 성공 - Member 테이블에 없을 때") + void getMemberOrThrow_OAuth2Member_Success() { + // given + Long memberId = 2L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getLoginId()).isEqualTo("oauth@test.com"); + assertThat(result.getName()).isEqualTo("소셜사용자"); + assertThat(result.getAge()).isEqualTo(25); + assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); + assertThat(result.getRole()).isEqualTo(Member.Role.USER); + + verify(memberRepository).findById(memberId); + verify(oauth2MemberRepository).findById(memberId); + } + + @Test + @DisplayName("OAuth2 회원을 Member로 변환 - 비밀번호는 빈 문자열") + void getMemberOrThrow_OAuth2Member_NoPassword() { + // given + Long memberId = 2L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result.getPassword()).isEqualTo(""); + } + + @Test + @DisplayName("회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_MemberNotFound_ThrowsException() { + // given + Long memberId = 999L; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(memberRepository).findById(memberId); + verify(oauth2MemberRepository).findById(memberId); + } + + @Test + @DisplayName("로컬 회원 우선 조회 - 양쪽 테이블에 같은 ID가 있을 때") + void getMemberOrThrow_PrioritizeLocalMember() { + // given + Long memberId = 1L; + given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + // OAuth2 repository는 호출되지 않아야 함 + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + + verify(memberRepository).findById(memberId); + // OAuth2 repository는 호출되지 않음을 검증 + org.mockito.Mockito.verifyNoInteractions(oauth2MemberRepository); + } + + @Test + @DisplayName("OAuth2MemberRepository가 null일 때도 정상 동작") + void getMemberOrThrow_NullOAuth2Repository() { + // given + Long memberId = 1L; + // OAuth2 repository를 설정하지 않음 + given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + } +} From 9ba2cb1e7c892c26c91bdf50a3503584c26feca2 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 16:31:48 +0900 Subject: [PATCH 09/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../global/jwt/JwtAuthenticationFilter.java | 9 +- .../ai/lawyer/global/jwt/TokenProvider.java | 9 ++ .../com/ai/lawyer/global/util/AuthUtil.java | 40 ++++++++ .../lawyer/global/jwt/TokenProviderTest.java | 75 +++++++++++++++ .../ai/lawyer/global/util/AuthUtilTest.java | 95 +++++++++++++++++++ 5 files changed, 226 insertions(+), 2 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java index a7a8978..a7d9ba9 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/JwtAuthenticationFilter.java @@ -162,6 +162,7 @@ private void setAuthentication(String token) { Long memberId = tokenProvider.getMemberIdFromToken(token); String loginId = tokenProvider.getLoginIdFromToken(token); String role = tokenProvider.getRoleFromToken(token); + String loginType = tokenProvider.getLoginTypeFromToken(token); if (memberId == null) { log.warn(LOG_MEMBER_ID_EXTRACTION_FAILED); @@ -174,7 +175,7 @@ private void setAuthentication(String token) { // memberId를 principal로 하는 인증 객체 생성 // getName()은 memberId를 반환 (PollController 호환) - // getDetails()는 loginId를 반환 (MemberController 호환) + // getDetails()는 loginId와 loginType을 포함한 맵을 반환 UsernamePasswordAuthenticationToken authentication = new UsernamePasswordAuthenticationToken(memberId, null, authorities) { @Override @@ -184,11 +185,15 @@ public String getName() { @Override public Object getDetails() { - return loginId; + return java.util.Map.of( + "loginId", loginId != null ? loginId : "", + "loginType", loginType != null ? loginType : "LOCAL" + ); } }; SecurityContextHolder.getContext().setAuthentication(authentication); + log.debug("JWT 인증 설정 완료: memberId={}, loginId={}, loginType={}", memberId, loginId, loginType); } catch (Exception e) { log.warn(LOG_SET_AUTH_FAILED, e.getMessage()); } diff --git a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java index 88ac99a..5a819ce 100644 --- a/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java +++ b/backend/src/main/java/com/ai/lawyer/global/jwt/TokenProvider.java @@ -37,6 +37,7 @@ public class TokenProvider { private static final String CLAIM_LOGIN_ID = "loginId"; private static final String CLAIM_MEMBER_ID = "memberId"; private static final String CLAIM_ROLE = "role"; + private static final String CLAIM_LOGIN_TYPE = "loginType"; // 로그 메시지 상수 private static final String LOG_ACCESS_TOKEN_SAVED = "=== Access token Hash 저장 성공: key={}, expiry={} ==="; @@ -60,12 +61,16 @@ public String generateAccessToken(com.ai.lawyer.domain.member.entity.MemberAdapt Date now = new Date(); Date expiry = new Date(now.getTime() + jwtProperties.getAccessToken().getExpirationSeconds() * MILLIS_PER_SECOND); + // 로그인 타입 결정 (OAuth2Member인지 Member인지 확인) + String loginType = (member instanceof com.ai.lawyer.domain.member.entity.OAuth2Member) ? "OAUTH2" : "LOCAL"; + String accessToken = Jwts.builder() .setIssuedAt(now) .setExpiration(expiry) .claim(CLAIM_LOGIN_ID, member.getLoginId()) .claim(CLAIM_MEMBER_ID, member.getMemberId()) .claim(CLAIM_ROLE, member.getRole().name()) + .claim(CLAIM_LOGIN_TYPE, loginType) .signWith(getSigningKey(), SignatureAlgorithm.HS256) .compact(); @@ -176,6 +181,10 @@ public String getLoginIdFromToken(String token) { return getClaimFromToken(token, CLAIM_LOGIN_ID, String.class, LOG_LOGIN_ID_EXTRACTION_FAILED); } + public String getLoginTypeFromToken(String token) { + return getClaimFromToken(token, CLAIM_LOGIN_TYPE, String.class, "토큰에서 로그인 타입 추출 실패: {}"); + } + /** * 토큰에서 특정 Claim을 추출하는 공통 메서드 */ diff --git a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java index 6495bf3..8c0fbc8 100644 --- a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java @@ -104,6 +104,46 @@ public static Member getMemberOrThrow(Long memberId) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다"); } + /** + * memberId와 loginType으로 회원을 조회합니다. + * loginType이 "LOCAL"이면 Member 테이블에서, "OAUTH2"이면 OAuth2Member 테이블에서 조회합니다. + * @param memberId 회원 ID + * @param loginType 로그인 타입 ("LOCAL" 또는 "OAUTH2") + * @return Member 객체 + * @throws ResponseStatusException 회원을 찾을 수 없는 경우 + */ + public static Member getMemberOrThrow(Long memberId, String loginType) { + if ("OAUTH2".equals(loginType)) { + // OAuth2 회원 조회 + if (oauth2MemberRepository != null) { + java.util.Optional oauth2Member = + oauth2MemberRepository.findById(memberId); + if (oauth2Member.isPresent()) { + // OAuth2Member를 Member로 변환 + com.ai.lawyer.domain.member.entity.OAuth2Member oauth = oauth2Member.get(); + return Member.builder() + .memberId(oauth.getMemberId()) + .loginId(oauth.getLoginId()) + .name(oauth.getName()) + .age(oauth.getAge()) + .gender(oauth.getGender()) + .role(oauth.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); + } + } + } else { + // LOCAL 회원 조회 (기본값) + java.util.Optional member = memberRepository.findById(memberId); + if (member.isPresent()) { + return member.get(); + } + } + + // 찾지 못한 경우 예외 발생 + throw new ResponseStatusException(HttpStatus.NOT_FOUND, "회원 정보를 찾을 수 없습니다"); + } + public static Long getAuthenticatedMemberId() { try { Long memberId = getCurrentMemberId(); diff --git a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java index 1894ce9..cd129a4 100644 --- a/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/jwt/TokenProviderTest.java @@ -118,6 +118,7 @@ void generateAccessToken_Success() { assertThat(claims.get("loginId", String.class)).as("loginId claim 일치").isEqualTo("test@example.com"); assertThat(claims.get("memberId", Long.class)).as("memberId claim 일치").isEqualTo(1L); assertThat(claims.get("role", String.class)).as("role claim 일치").isEqualTo("USER"); + assertThat(claims.get("loginType", String.class)).as("loginType claim 일치").isEqualTo("LOCAL"); assertThat(claims.getIssuedAt()).as("발급 시간 존재").isNotNull(); assertThat(claims.getExpiration()).as("만료 시간 존재").isNotNull(); assertThat(claims.getExpiration()).as("만료 시간이 발급 시간 이후").isAfter(claims.getIssuedAt()); @@ -571,6 +572,80 @@ void deleteAllTokens_Success() { log.info("=== 모든 토큰 삭제 테스트 완료 ==="); } + @Test + @DisplayName("토큰에서 loginType 추출 성공 - LOCAL") + void getLoginTypeFromToken_Success_Local() { + // given + log.info("=== 토큰에서 loginType 추출 테스트 시작 (LOCAL) ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + String token = tokenProvider.generateAccessToken(member); + log.info("토큰 생성 완료"); + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(token); + log.info("loginType 추출 완료: {}", loginType); + + // then + assertThat(loginType).as("loginType이 null이 아님").isNotNull(); + assertThat(loginType).as("loginType 일치").isEqualTo("LOCAL"); + log.info("=== 토큰에서 loginType 추출 테스트 완료 (LOCAL) ==="); + } + + @Test + @DisplayName("토큰에서 loginType 추출 성공 - OAUTH2") + void getLoginTypeFromToken_Success_OAuth2() { + // given + log.info("=== 토큰에서 loginType 추출 테스트 시작 (OAUTH2) ==="); + willDoNothing().given(hashOperations).put(anyString(), anyString(), anyString()); + given(redisTemplate.expire(anyString(), any(Duration.class))).willReturn(true); + + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + com.ai.lawyer.domain.member.entity.OAuth2Member.builder() + .memberId(2L) + .loginId("oauth@example.com") + .email("oauth@example.com") + .name("OAuth User") + .age(30) + .gender(Member.Gender.MALE) + .provider(com.ai.lawyer.domain.member.entity.OAuth2Member.Provider.KAKAO) + .providerId("kakao123") + .role(Member.Role.USER) + .build(); + + String token = tokenProvider.generateAccessToken(oauth2Member); + log.info("OAuth2 토큰 생성 완료"); + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(token); + log.info("loginType 추출 완료: {}", loginType); + + // then + assertThat(loginType).as("loginType이 null이 아님").isNotNull(); + assertThat(loginType).as("loginType 일치").isEqualTo("OAUTH2"); + log.info("=== 토큰에서 loginType 추출 테스트 완료 (OAUTH2) ==="); + } + + @Test + @DisplayName("토큰에서 loginType 추출 실패 - 유효하지 않은 토큰") + void getLoginTypeFromToken_Fail_InvalidToken() { + // given + log.info("=== 토큰에서 loginType 추출 실패 테스트 시작 ==="); + String invalidToken = "invalid.token.format"; + + // when + log.info("loginType 추출 호출 중..."); + String loginType = tokenProvider.getLoginTypeFromToken(invalidToken); + log.info("loginType 추출 결과: {}", loginType); + + // then + assertThat(loginType).as("유효하지 않은 토큰에서는 null 반환").isNull(); + log.info("=== 토큰에서 loginType 추출 실패 테스트 완료 ==="); + } + @Test @DisplayName("여러 사용자의 토큰 생성 및 검증 - 멀티 유저 시나리오") void multipleUsers_TokenGeneration() { diff --git a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java index 5ad682d..c85441b 100644 --- a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java @@ -170,4 +170,99 @@ void getMemberOrThrow_NullOAuth2Repository() { assertThat(result).isNotNull(); assertThat(result.getLoginId()).isEqualTo("local@test.com"); } + + @Test + @DisplayName("loginType으로 로컬 회원 조회 성공") + void getMemberOrThrow_WithLoginType_Local_Success() { + // given + Long memberId = 1L; + String loginType = "LOCAL"; + given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(1L); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + assertThat(result.getName()).isEqualTo("로컬사용자"); + + verify(memberRepository).findById(memberId); + // OAuth2 repository는 호출되지 않음 + org.mockito.Mockito.verifyNoInteractions(oauth2MemberRepository); + } + + @Test + @DisplayName("loginType으로 OAuth2 회원 조회 성공") + void getMemberOrThrow_WithLoginType_OAuth2_Success() { + // given + Long memberId = 2L; + String loginType = "OAUTH2"; + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getMemberId()).isEqualTo(2L); + assertThat(result.getLoginId()).isEqualTo("oauth@test.com"); + assertThat(result.getName()).isEqualTo("소셜사용자"); + assertThat(result.getAge()).isEqualTo(25); + assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); + + verify(oauth2MemberRepository).findById(memberId); + // Member repository는 호출되지 않음 + org.mockito.Mockito.verifyNoInteractions(memberRepository); + } + + @Test + @DisplayName("loginType이 LOCAL이지만 회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_WithLoginType_Local_NotFound() { + // given + Long memberId = 999L; + String loginType = "LOCAL"; + given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(memberRepository).findById(memberId); + } + + @Test + @DisplayName("loginType이 OAUTH2이지만 회원을 찾을 수 없을 때 예외 발생") + void getMemberOrThrow_WithLoginType_OAuth2_NotFound() { + // given + Long memberId = 999L; + String loginType = "OAUTH2"; + given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty()); + + // when & then + assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) + .isInstanceOf(ResponseStatusException.class) + .hasMessageContaining("회원 정보를 찾을 수 없습니다"); + + verify(oauth2MemberRepository).findById(memberId); + } + + @Test + @DisplayName("loginType이 null일 때는 기본값 LOCAL로 처리") + void getMemberOrThrow_WithLoginType_Null_DefaultsToLocal() { + // given + Long memberId = 1L; + String loginType = null; + given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + + // when + Member result = AuthUtil.getMemberOrThrow(memberId, loginType); + + // then + assertThat(result).isNotNull(); + assertThat(result.getLoginId()).isEqualTo("local@test.com"); + verify(memberRepository).findById(memberId); + } } From 3181c149059fb0ab4c55541d79c3e27e19162684 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 16:37:52 +0900 Subject: [PATCH 10/28] Fix[post]:work --- .../repository/PollVoteRepositoryImpl.java | 36 +++++++++++-------- 1 file changed, 22 insertions(+), 14 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java index faa1172..b241faf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java @@ -12,7 +12,10 @@ import org.springframework.stereotype.Repository; import com.querydsl.core.Tuple; +import java.util.ArrayList; +import java.util.HashMap; import java.util.List; +import java.util.Map; import com.ai.lawyer.domain.poll.dto.PollAgeStaticsDto.AgeGroupCountDto; import com.ai.lawyer.domain.poll.dto.PollGenderStaticsDto.GenderCountDto; @@ -95,20 +98,25 @@ public List countStaticsByPollOptionIds(List pollOptionIds .where(pollOptions.getPollItemsId().in(pollOptionIds)) .groupBy(pollOptions.getPollItemsId(), member.getGender(), member.getAge()) .fetch(); - return tuples.stream() - .map(t -> { - Member.Gender genderEnum = t.get(1, Member.Gender.class); - String gender = genderEnum != null ? genderEnum.name() : "기타"; - Integer age = t.get(2, Integer.class); - String ageGroup = getAgeGroup(age); - Long voteCount = t.get(3, Long.class); - return PollStaticsDto.builder() - .gender(gender) - .ageGroup(ageGroup) - .voteCount(voteCount) - .build(); - }) - .toList(); + + // gender와 ageGroup별로 voteCount 합산 + Map staticsMap = new HashMap<>(); + for (Tuple t : tuples) { + Member.Gender genderEnum = t.get(1, Member.Gender.class); + String gender = genderEnum != null ? genderEnum.name() : "기타"; + Integer age = t.get(2, Integer.class); + String ageGroup = getAgeGroup(age); + Long voteCount = t.get(3, Long.class); + String key = gender + "_" + ageGroup; + staticsMap.put(key, staticsMap.getOrDefault(key, 0) + voteCount.intValue()); + } + + List result = new ArrayList<>(); + for (Map.Entry entry : staticsMap.entrySet()) { + String[] key = entry.getKey().split("_"); + result.add(new PollStaticsDto(key[0], key[1], entry.getValue().longValue())); + } + return result; } private String getAgeGroup(Integer age) { From 49d9c167b1de325c5af24e7dc1ad8add6efaf59c Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 16:57:05 +0900 Subject: [PATCH 11/28] =?UTF-8?q?fix[poll]:=ED=88=AC=ED=91=9C=ED=95=98?= =?UTF-8?q?=EA=B8=B0=20=EC=98=88=EC=99=B8=EC=B2=98=EB=A6=AC=20=EC=B6=94?= =?UTF-8?q?=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/poll/controller/PollController.java | 7 +------ .../ai/lawyer/domain/poll/service/PollService.java | 1 + .../lawyer/domain/poll/service/PollServiceImpl.java | 13 +++++++++++++ 3 files changed, 15 insertions(+), 6 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java index 1bc4f83..37f5934 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/controller/PollController.java @@ -155,12 +155,7 @@ public ResponseEntity>> getClosedPolls() { public ResponseEntity> voteByIndex(@PathVariable Long pollId, @RequestParam int index) { Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); Long memberId = Long.parseLong(authentication.getName()); - List options = pollService.getPollOptions(pollId); - if (index < 1 || index > options.size()) { - throw new ResponseStatusException(org.springframework.http.HttpStatus.BAD_REQUEST, "index가 옵션 범위를 벗어났습니다."); - } - Long pollItemsId = options.get(index - 1).getPollItemsId(); - PollVoteDto result = pollService.vote(pollId, pollItemsId, memberId); + PollVoteDto result = pollService.voteByIndex(pollId, index, memberId); return ResponseEntity.ok(new ApiResponse<>(200, "투표가 성공적으로 완료되었습니다.", result)); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java index 0d1ddca..f416229 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollService.java @@ -28,6 +28,7 @@ public interface PollService { // ===== 투표 관련 ===== PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId); + PollVoteDto voteByIndex(Long pollId, int index, Long memberId); // ===== 투표 취소 관련 ===== void cancelVote(Long pollId, Long memberId); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index fbc3488..45476e1 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -156,6 +156,19 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { .build(); } + @Override + public PollVoteDto voteByIndex(Long pollId, int index, Long memberId) { + List options = getPollOptions(pollId); + if (options == null || options.isEmpty()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "투표 항목이 존재하지 않습니다."); + } + if (index < 1 || index > options.size()) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "index가 옵션 범위를 벗어났습니다."); + } + Long pollItemsId = options.get(index - 1).getPollItemsId(); + return vote(pollId, pollItemsId, memberId); + } + @Override public PollStaticsResponseDto getPollStatics(Long pollId) { if (!pollRepository.existsById(pollId)) { From 3e7cc84d3fa94ed431d82afa3ef522aa26fe13c8 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 17:01:52 +0900 Subject: [PATCH 12/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/controller/MemberController.java | 31 ++++++++++++++++--- 1 file changed, 26 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java index 8c90d7a..d6bd726 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/controller/MemberController.java @@ -117,7 +117,14 @@ public ResponseEntity oauth2SuccessPage(Authentication authentication, H if (principal instanceof Long) { memberId = (Long) principal; - loginId = (String) authentication.getDetails(); + // Details가 Map이면 loginId 추출 + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); + } else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + } } else if (principal instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); loginId = member.getLoginId(); @@ -203,10 +210,17 @@ public ResponseEntity logout(Authentication authentication, Http if (authentication != null) { // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) - if (authentication.getDetails() instanceof String) { - loginId = (String) authentication.getDetails(); + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); log.info("JWT Details로 로그아웃: loginId={}", loginId); } + // 1-2순위: 이전 버전 호환성 (String으로 저장된 경우) + else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details(legacy)로 로그아웃: loginId={}", loginId); + } // 2순위: PrincipalDetails (OAuth2 직접 로그인) else if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); @@ -257,10 +271,17 @@ public ResponseEntity> withdraw(Authentication authenticatio if (authentication != null) { // 1순위: authentication.getDetails()에서 loginId 추출 (JWT 필터가 설정) - if (authentication.getDetails() instanceof String) { - loginId = (String) authentication.getDetails(); + if (authentication.getDetails() instanceof Map) { + @SuppressWarnings("unchecked") + Map details = (Map) authentication.getDetails(); + loginId = details.get("loginId"); log.info("JWT Details로 회원 탈퇴: loginId={}", loginId); } + // 1-2순위: 이전 버전 호환성 (String으로 저장된 경우) + else if (authentication.getDetails() instanceof String) { + loginId = (String) authentication.getDetails(); + log.info("JWT Details(legacy)로 회원 탈퇴: loginId={}", loginId); + } // 2순위: PrincipalDetails (OAuth2 직접 로그인) else if (authentication.getPrincipal() instanceof PrincipalDetails principalDetails) { com.ai.lawyer.domain.member.entity.MemberAdapter member = principalDetails.getMember(); From ea1ede87ce6e3e886ac3ab6ccd0e0110017efbfe Mon Sep 17 00:00:00 2001 From: asowjdan Date: Wed, 15 Oct 2025 22:20:04 +0900 Subject: [PATCH 13/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../member/repositories/MemberRepository.java | 5 + .../MemberRepositoryFactoryBean.java | 47 +++++++ .../SmartMemberRepositoryImpl.java | 51 +++++++ .../ai/lawyer/global/config/DataDBConfig.java | 6 +- .../qdrant/initializer/QdrantInitializer.java | 2 + .../global/security/SecurityConfig.java | 1 - .../com/ai/lawyer/global/util/AuthUtil.java | 126 ++++++++++-------- .../ai/lawyer/global/util/AuthUtilTest.java | 97 ++++++-------- 8 files changed, 222 insertions(+), 113 deletions(-) create mode 100644 backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java create mode 100644 backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java index b90f852..fd84377 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepository.java @@ -7,6 +7,11 @@ import java.util.List; import java.util.Optional; +/** + * MemberRepository + * findById 호출 시 SmartMemberRepositoryImpl을 통해 AuthUtil로 자동 리다이렉트됩니다. + * 이를 통해 loginType에 따라 Member 또는 OAuth2Member 테이블에서 조회합니다. + */ @Repository public interface MemberRepository extends JpaRepository { diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java new file mode 100644 index 0000000..f5c08db --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/MemberRepositoryFactoryBean.java @@ -0,0 +1,47 @@ +package com.ai.lawyer.domain.member.repositories; + +import com.ai.lawyer.domain.member.entity.Member; +import org.jetbrains.annotations.NotNull; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactory; +import org.springframework.data.jpa.repository.support.JpaRepositoryFactoryBean; +import org.springframework.data.repository.core.RepositoryMetadata; +import org.springframework.data.repository.core.support.RepositoryFactorySupport; + +import jakarta.persistence.EntityManager; +import java.io.Serializable; + +/** + * MemberRepository의 커스텀 팩토리 빈 + * findById 호출을 가로채서 AuthUtil을 통해 처리하도록 합니다. + */ +public class MemberRepositoryFactoryBean, T, I extends Serializable> + extends JpaRepositoryFactoryBean { + + public MemberRepositoryFactoryBean(Class repositoryInterface) { + super(repositoryInterface); + } + + @NotNull + @Override + protected RepositoryFactorySupport createRepositoryFactory(@NotNull EntityManager entityManager) { + return new MemberRepositoryFactory(entityManager); + } + + private static class MemberRepositoryFactory extends JpaRepositoryFactory { + + public MemberRepositoryFactory(EntityManager entityManager) { + super(entityManager); + } + + @NotNull + @Override + protected Class getRepositoryBaseClass(RepositoryMetadata metadata) { + // Member 엔티티인 경우 커스텀 베이스 클래스 사용 + if (Member.class.isAssignableFrom(metadata.getDomainType())) { + return SmartMemberRepositoryImpl.class; + } + return super.getRepositoryBaseClass(metadata); + } + } +} diff --git a/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java new file mode 100644 index 0000000..3caa99c --- /dev/null +++ b/backend/src/main/java/com/ai/lawyer/domain/member/repositories/SmartMemberRepositoryImpl.java @@ -0,0 +1,51 @@ +package com.ai.lawyer.domain.member.repositories; + +import com.ai.lawyer.domain.member.entity.Member; +import com.ai.lawyer.global.util.AuthUtil; +import org.jetbrains.annotations.NotNull; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.data.jpa.repository.support.JpaEntityInformation; +import org.springframework.data.jpa.repository.support.SimpleJpaRepository; +import org.springframework.web.server.ResponseStatusException; + +import jakarta.persistence.EntityManager; +import java.util.Optional; + +/** + * MemberRepository의 커스텀 베이스 구현체 + * findById 호출 시 AuthUtil을 통해 적절한 테이블에서 조회합니다. + */ +public class SmartMemberRepositoryImpl extends SimpleJpaRepository { + + private static final Logger log = LoggerFactory.getLogger(SmartMemberRepositoryImpl.class); + + private final JpaEntityInformation entityInformation; + + public SmartMemberRepositoryImpl(JpaEntityInformation entityInformation, + EntityManager entityManager) { + super(entityInformation, entityManager); + this.entityInformation = entityInformation; + } + + @NotNull + @Override + public Optional findById(@NotNull ID id) { + // Member 엔티티이고 ID가 Long인 경우에만 AuthUtil 사용 + if (entityInformation.getJavaType().equals(Member.class) && id instanceof Long) { + try { + log.debug("SmartMemberRepositoryImpl.findById 호출: memberId={}", id); + Member member = AuthUtil.getMemberOrThrow((Long) id); + @SuppressWarnings("unchecked") + T result = (T) member; + return Optional.of(result); + } catch (ResponseStatusException e) { + log.debug("회원을 찾을 수 없음: memberId={}", id); + return Optional.empty(); + } + } + + // 다른 엔티티는 기본 동작 수행 + return super.findById(id); + } +} diff --git a/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java b/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java index dec3fcc..de24466 100644 --- a/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/config/DataDBConfig.java @@ -1,5 +1,6 @@ package com.ai.lawyer.global.config; +import com.ai.lawyer.domain.member.repositories.MemberRepositoryFactoryBean; import org.springframework.beans.factory.annotation.Value; import org.springframework.boot.jdbc.DataSourceBuilder; import org.springframework.context.annotation.Bean; @@ -18,7 +19,8 @@ @EnableJpaRepositories( basePackages = "com.ai.lawyer.domain.*", entityManagerFactoryRef = "dataEntityManager", - transactionManagerRef = "dataTransactionManager" + transactionManagerRef = "dataTransactionManager", + repositoryFactoryBeanClass = MemberRepositoryFactoryBean.class ) public class DataDBConfig { @@ -49,7 +51,7 @@ public LocalContainerEntityManagerFactoryBean dataEntityManager() { LocalContainerEntityManagerFactoryBean em = new LocalContainerEntityManagerFactoryBean(); em.setDataSource(dataDBSource()); - em.setPackagesToScan(new String[]{"com.ai.lawyer.domain.*"}); + em.setPackagesToScan("com.ai.lawyer.domain.*"); em.setJpaVendorAdapter(new HibernateJpaVendorAdapter()); HashMap properties = new HashMap<>(); diff --git a/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java b/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java index 9330f5c..61f8f24 100644 --- a/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java +++ b/backend/src/main/java/com/ai/lawyer/global/qdrant/initializer/QdrantInitializer.java @@ -5,6 +5,7 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Profile; import org.springframework.stereotype.Component; import java.util.concurrent.ExecutionException; @@ -12,6 +13,7 @@ @Slf4j @Component @RequiredArgsConstructor +@Profile("!test") // test 프로파일에서는 비활성화 public class QdrantInitializer { private final QdrantClient qdrantClient; diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 896624f..8c7067a 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -56,7 +56,6 @@ public class SecurityConfig { "/api/precedent/**", // 판례 (공개) "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개) - "/api/chat/**", // 챗봇 (공개) "/api/home/**", // 홈 (공개) "/h2-console/**", // H2 콘솔 (개발용) "/actuator/health", "/actuator/health/**", "/actuator/info", // Spring Actuator diff --git a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java index 8c0fbc8..bbdebe9 100644 --- a/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java +++ b/backend/src/main/java/com/ai/lawyer/global/util/AuthUtil.java @@ -1,5 +1,6 @@ package com.ai.lawyer.global.util; +import jakarta.persistence.EntityManager; import org.springframework.security.core.Authentication; import org.springframework.security.core.GrantedAuthority; import org.springframework.security.core.context.SecurityContextHolder; @@ -7,23 +8,15 @@ import org.springframework.http.HttpStatus; import org.springframework.stereotype.Component; import org.springframework.beans.factory.annotation.Autowired; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; import com.ai.lawyer.domain.member.entity.Member; @Component public class AuthUtil { - private static MemberRepository memberRepository; - private static OAuth2MemberRepository oauth2MemberRepository; + private static EntityManager entityManager; @Autowired - public AuthUtil(MemberRepository memberRepository) { - AuthUtil.memberRepository = memberRepository; - } - - @Autowired(required = false) - public void setOauth2MemberRepository(OAuth2MemberRepository oauth2MemberRepository) { - AuthUtil.oauth2MemberRepository = oauth2MemberRepository; + public AuthUtil(EntityManager entityManager) { + AuthUtil.entityManager = entityManager; } public static Long getCurrentMemberId() { @@ -67,37 +60,65 @@ public static String getCurrentMemberRole() { .orElse(null); } + /** + * 현재 인증된 사용자의 로그인 타입을 가져옵니다. + * @return "LOCAL" 또는 "OAUTH2", 없으면 null + */ + public static String getCurrentLoginType() { + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); + if (authentication == null || !authentication.isAuthenticated()) { + return null; + } + + Object details = authentication.getDetails(); + if (details instanceof java.util.Map) { + @SuppressWarnings("unchecked") + java.util.Map detailsMap = (java.util.Map) details; + return detailsMap.get("loginType"); + } + + return null; + } + /** * memberId로 회원을 조회합니다. (Member 또는 OAuth2Member) + * SecurityContext에서 현재 인증된 사용자의 loginType을 자동으로 확인하여 적절한 테이블에서 조회합니다. * OAuth2Member인 경우 Member 객체로 변환하여 반환합니다. + * EntityManager를 직접 사용하여 무한 루프를 방지합니다. * @param memberId 회원 ID * @return Member 객체 * @throws ResponseStatusException 회원을 찾을 수 없는 경우 */ public static Member getMemberOrThrow(Long memberId) { - // 먼저 Member 테이블에서 조회 - java.util.Optional member = memberRepository.findById(memberId); - if (member.isPresent()) { - return member.get(); + // SecurityContext에서 loginType 자동 추출 + String loginType = getCurrentLoginType(); + + // loginType이 있으면 해당 테이블에서만 조회 (성능 최적화) + if (loginType != null) { + return getMemberOrThrow(memberId, loginType); } - // Member 테이블에 없으면 OAuth2Member 테이블에서 조회 - if (oauth2MemberRepository != null) { - java.util.Optional oauth2Member = - oauth2MemberRepository.findById(memberId); - if (oauth2Member.isPresent()) { - // OAuth2Member를 Member로 변환 (엔티티 호환성을 위해) - com.ai.lawyer.domain.member.entity.OAuth2Member oauth = oauth2Member.get(); - return Member.builder() - .memberId(oauth.getMemberId()) - .loginId(oauth.getLoginId()) - .name(oauth.getName()) - .age(oauth.getAge()) - .gender(oauth.getGender()) - .role(oauth.getRole()) - .password("") // OAuth2는 비밀번호 없음 - .build(); - } + // loginType이 없으면 하위 호환성을 위해 두 테이블 모두 조회 + // 먼저 Member 테이블에서 조회 (EntityManager 직접 사용) + Member member = entityManager.find(Member.class, memberId); + if (member != null) { + return member; + } + + // Member 테이블에 없으면 OAuth2Member 테이블에서 조회 (EntityManager 직접 사용) + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + entityManager.find(com.ai.lawyer.domain.member.entity.OAuth2Member.class, memberId); + if (oauth2Member != null) { + // OAuth2Member를 Member로 변환 (엔티티 호환성을 위해) + return Member.builder() + .memberId(oauth2Member.getMemberId()) + .loginId(oauth2Member.getLoginId()) + .name(oauth2Member.getName()) + .age(oauth2Member.getAge()) + .gender(oauth2Member.getGender()) + .role(oauth2Member.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); } // 둘 다 없으면 예외 발생 @@ -107,6 +128,7 @@ public static Member getMemberOrThrow(Long memberId) { /** * memberId와 loginType으로 회원을 조회합니다. * loginType이 "LOCAL"이면 Member 테이블에서, "OAUTH2"이면 OAuth2Member 테이블에서 조회합니다. + * EntityManager를 직접 사용하여 무한 루프를 방지합니다. * @param memberId 회원 ID * @param loginType 로그인 타입 ("LOCAL" 또는 "OAUTH2") * @return Member 객체 @@ -114,29 +136,27 @@ public static Member getMemberOrThrow(Long memberId) { */ public static Member getMemberOrThrow(Long memberId, String loginType) { if ("OAUTH2".equals(loginType)) { - // OAuth2 회원 조회 - if (oauth2MemberRepository != null) { - java.util.Optional oauth2Member = - oauth2MemberRepository.findById(memberId); - if (oauth2Member.isPresent()) { - // OAuth2Member를 Member로 변환 - com.ai.lawyer.domain.member.entity.OAuth2Member oauth = oauth2Member.get(); - return Member.builder() - .memberId(oauth.getMemberId()) - .loginId(oauth.getLoginId()) - .name(oauth.getName()) - .age(oauth.getAge()) - .gender(oauth.getGender()) - .role(oauth.getRole()) - .password("") // OAuth2는 비밀번호 없음 - .build(); - } + // OAuth2 회원 조회 (EntityManager 직접 사용) + com.ai.lawyer.domain.member.entity.OAuth2Member oauth2Member = + entityManager.find(com.ai.lawyer.domain.member.entity.OAuth2Member.class, memberId); + + if (oauth2Member != null) { + // OAuth2Member를 Member로 변환 + return Member.builder() + .memberId(oauth2Member.getMemberId()) + .loginId(oauth2Member.getLoginId()) + .name(oauth2Member.getName()) + .age(oauth2Member.getAge()) + .gender(oauth2Member.getGender()) + .role(oauth2Member.getRole()) + .password("") // OAuth2는 비밀번호 없음 + .build(); } } else { - // LOCAL 회원 조회 (기본값) - java.util.Optional member = memberRepository.findById(memberId); - if (member.isPresent()) { - return member.get(); + // LOCAL 회원 조회 (EntityManager 직접 사용) + Member member = entityManager.find(Member.class, memberId); + if (member != null) { + return member; } } diff --git a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java index c85441b..da84eeb 100644 --- a/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java +++ b/backend/src/test/java/com/ai/lawyer/global/util/AuthUtilTest.java @@ -2,8 +2,7 @@ import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.entity.OAuth2Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import com.ai.lawyer.domain.member.repositories.OAuth2MemberRepository; +import jakarta.persistence.EntityManager; import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.DisplayName; import org.junit.jupiter.api.Test; @@ -12,8 +11,6 @@ import org.mockito.junit.jupiter.MockitoExtension; import org.springframework.web.server.ResponseStatusException; -import java.util.Optional; - import static org.assertj.core.api.Assertions.assertThat; import static org.assertj.core.api.Assertions.assertThatThrownBy; import static org.mockito.BDDMockito.given; @@ -24,18 +21,17 @@ class AuthUtilTest { @Mock - private MemberRepository memberRepository; - - @Mock - private OAuth2MemberRepository oauth2MemberRepository; + private EntityManager entityManager; private Member localMember; private OAuth2Member oauth2Member; @BeforeEach void setUp() { - AuthUtil authUtil = new AuthUtil(memberRepository); - authUtil.setOauth2MemberRepository(oauth2MemberRepository); + // AuthUtil의 static EntityManager를 초기화 + // 반환값은 사용하지 않지만 static 필드 설정을 위해 생성자 호출 필요 + @SuppressWarnings("unused") + AuthUtil authUtil = new AuthUtil(entityManager); localMember = Member.builder() .memberId(1L) @@ -65,7 +61,7 @@ void setUp() { void getMemberOrThrow_LocalMember_Success() { // given Long memberId = 1L; - given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + given(entityManager.find(Member.class, memberId)).willReturn(localMember); // when Member result = AuthUtil.getMemberOrThrow(memberId); @@ -76,7 +72,7 @@ void getMemberOrThrow_LocalMember_Success() { assertThat(result.getLoginId()).isEqualTo("local@test.com"); assertThat(result.getName()).isEqualTo("로컬사용자"); - verify(memberRepository).findById(memberId); + verify(entityManager).find(Member.class, memberId); } @Test @@ -84,8 +80,8 @@ void getMemberOrThrow_LocalMember_Success() { void getMemberOrThrow_OAuth2Member_Success() { // given Long memberId = 2L; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); - given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); // when Member result = AuthUtil.getMemberOrThrow(memberId); @@ -99,8 +95,8 @@ void getMemberOrThrow_OAuth2Member_Success() { assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); assertThat(result.getRole()).isEqualTo(Member.Role.USER); - verify(memberRepository).findById(memberId); - verify(oauth2MemberRepository).findById(memberId); + verify(entityManager).find(Member.class, memberId); + verify(entityManager).find(OAuth2Member.class, memberId); } @Test @@ -108,8 +104,8 @@ void getMemberOrThrow_OAuth2Member_Success() { void getMemberOrThrow_OAuth2Member_NoPassword() { // given Long memberId = 2L; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); - given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); // when Member result = AuthUtil.getMemberOrThrow(memberId); @@ -123,16 +119,16 @@ void getMemberOrThrow_OAuth2Member_NoPassword() { void getMemberOrThrow_MemberNotFound_ThrowsException() { // given Long memberId = 999L; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); - given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty()); + given(entityManager.find(Member.class, memberId)).willReturn(null); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(null); // when & then assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("회원 정보를 찾을 수 없습니다"); - verify(memberRepository).findById(memberId); - verify(oauth2MemberRepository).findById(memberId); + verify(entityManager).find(Member.class, memberId); + verify(entityManager).find(OAuth2Member.class, memberId); } @Test @@ -140,8 +136,8 @@ void getMemberOrThrow_MemberNotFound_ThrowsException() { void getMemberOrThrow_PrioritizeLocalMember() { // given Long memberId = 1L; - given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); - // OAuth2 repository는 호출되지 않아야 함 + given(entityManager.find(Member.class, memberId)).willReturn(localMember); + // OAuth2 Member는 조회되지 않아야 함 // when Member result = AuthUtil.getMemberOrThrow(memberId); @@ -150,25 +146,10 @@ void getMemberOrThrow_PrioritizeLocalMember() { assertThat(result).isNotNull(); assertThat(result.getLoginId()).isEqualTo("local@test.com"); - verify(memberRepository).findById(memberId); - // OAuth2 repository는 호출되지 않음을 검증 - org.mockito.Mockito.verifyNoInteractions(oauth2MemberRepository); - } - - @Test - @DisplayName("OAuth2MemberRepository가 null일 때도 정상 동작") - void getMemberOrThrow_NullOAuth2Repository() { - // given - Long memberId = 1L; - // OAuth2 repository를 설정하지 않음 - given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); - - // when - Member result = AuthUtil.getMemberOrThrow(memberId); - - // then - assertThat(result).isNotNull(); - assertThat(result.getLoginId()).isEqualTo("local@test.com"); + verify(entityManager).find(Member.class, memberId); + // OAuth2Member는 조회되지 않음을 검증 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(OAuth2Member.class, memberId); } @Test @@ -177,7 +158,7 @@ void getMemberOrThrow_WithLoginType_Local_Success() { // given Long memberId = 1L; String loginType = "LOCAL"; - given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + given(entityManager.find(Member.class, memberId)).willReturn(localMember); // when Member result = AuthUtil.getMemberOrThrow(memberId, loginType); @@ -188,9 +169,10 @@ void getMemberOrThrow_WithLoginType_Local_Success() { assertThat(result.getLoginId()).isEqualTo("local@test.com"); assertThat(result.getName()).isEqualTo("로컬사용자"); - verify(memberRepository).findById(memberId); - // OAuth2 repository는 호출되지 않음 - org.mockito.Mockito.verifyNoInteractions(oauth2MemberRepository); + verify(entityManager).find(Member.class, memberId); + // OAuth2Member는 조회되지 않음 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(OAuth2Member.class, memberId); } @Test @@ -199,7 +181,7 @@ void getMemberOrThrow_WithLoginType_OAuth2_Success() { // given Long memberId = 2L; String loginType = "OAUTH2"; - given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.of(oauth2Member)); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(oauth2Member); // when Member result = AuthUtil.getMemberOrThrow(memberId, loginType); @@ -212,9 +194,10 @@ void getMemberOrThrow_WithLoginType_OAuth2_Success() { assertThat(result.getAge()).isEqualTo(25); assertThat(result.getGender()).isEqualTo(Member.Gender.FEMALE); - verify(oauth2MemberRepository).findById(memberId); - // Member repository는 호출되지 않음 - org.mockito.Mockito.verifyNoInteractions(memberRepository); + verify(entityManager).find(OAuth2Member.class, memberId); + // Member는 조회되지 않음 + org.mockito.Mockito.verify(entityManager, org.mockito.Mockito.never()) + .find(Member.class, memberId); } @Test @@ -223,14 +206,14 @@ void getMemberOrThrow_WithLoginType_Local_NotFound() { // given Long memberId = 999L; String loginType = "LOCAL"; - given(memberRepository.findById(memberId)).willReturn(Optional.empty()); + given(entityManager.find(Member.class, memberId)).willReturn(null); // when & then assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("회원 정보를 찾을 수 없습니다"); - verify(memberRepository).findById(memberId); + verify(entityManager).find(Member.class, memberId); } @Test @@ -239,14 +222,14 @@ void getMemberOrThrow_WithLoginType_OAuth2_NotFound() { // given Long memberId = 999L; String loginType = "OAUTH2"; - given(oauth2MemberRepository.findById(memberId)).willReturn(Optional.empty()); + given(entityManager.find(OAuth2Member.class, memberId)).willReturn(null); // when & then assertThatThrownBy(() -> AuthUtil.getMemberOrThrow(memberId, loginType)) .isInstanceOf(ResponseStatusException.class) .hasMessageContaining("회원 정보를 찾을 수 없습니다"); - verify(oauth2MemberRepository).findById(memberId); + verify(entityManager).find(OAuth2Member.class, memberId); } @Test @@ -255,7 +238,7 @@ void getMemberOrThrow_WithLoginType_Null_DefaultsToLocal() { // given Long memberId = 1L; String loginType = null; - given(memberRepository.findById(memberId)).willReturn(Optional.of(localMember)); + given(entityManager.find(Member.class, memberId)).willReturn(localMember); // when Member result = AuthUtil.getMemberOrThrow(memberId, loginType); @@ -263,6 +246,6 @@ void getMemberOrThrow_WithLoginType_Null_DefaultsToLocal() { // then assertThat(result).isNotNull(); assertThat(result.getLoginId()).isEqualTo("local@test.com"); - verify(memberRepository).findById(memberId); + verify(entityManager).find(Member.class, memberId); } } From bf83541e2dbb7491a57bec06075261b0a7738bdb Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 22:32:33 +0900 Subject: [PATCH 14/28] =?UTF-8?q?fix[post]:=ED=88=AC=ED=91=9C=20=EC=98=88?= =?UTF-8?q?=EC=95=BD=EC=8B=9C=EA=B0=84=20=EA=B0=95=EC=A0=9C=20=EC=A2=85?= =?UTF-8?q?=EB=A3=8C=EC=8B=9C=20=ED=98=84=EC=9E=AC=20=EC=8B=9C=EA=B0=84?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EB=B0=94=EB=80=8C=EA=B2=8C=ED=95=A8?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java | 2 ++ 1 file changed, 2 insertions(+) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 45476e1..a4c34cc 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -233,6 +233,8 @@ public void closePoll(Long pollId) { .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); poll.setStatus(Poll.PollStatus.CLOSED); poll.setClosedAt(java.time.LocalDateTime.now()); + //예약 종료 시간도 현재 종료로 바꿈 추후 삭제 + poll.setReservedCloseAt(java.time.LocalDateTime.now()); pollRepository.save(poll); } From 190b897d1fd0bbd7af5af772d619f4789b4d33e1 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Wed, 15 Oct 2025 23:19:47 +0900 Subject: [PATCH 15/28] =?UTF-8?q?docker:=20kafka=20=EB=B2=84=EC=A0=84=20?= =?UTF-8?q?=EC=97=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/docker-compose.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 73a0400..3dc0af5 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -83,7 +83,7 @@ services: retries: 10 zookeeper: - image: confluentinc/cp-zookeeper:7.4.4 + image: confluentinc/cp-zookeeper:7.8.0 container_name: zookeeper restart: unless-stopped ports: @@ -93,7 +93,7 @@ services: ZOOKEEPER_TICK_TIME: 2000 kafka: - image: confluentinc/cp-kafka:7.4.4 + image: confluentinc/cp-kafka:7.8.0 container_name: kafka restart: unless-stopped depends_on: From 445f7e20e7cc3b99cd9d1edc64901245dab416dc Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 16 Oct 2025 01:59:39 +0900 Subject: [PATCH 16/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../lawyer/domain/chatbot/entity/History.java | 8 +- .../chatbot/repository/ChatLawRepository.java | 3 +- .../repository/ChatPrecedentRepository.java | 3 +- .../chatbot/repository/ChatRepository.java | 3 +- .../chatbot/repository/HistoryRepository.java | 8 +- .../chatbot/service/ChatBotService.java | 82 +++++++++++++------ .../chatbot/service/HistoryService.java | 16 ++-- .../lawyer/domain/poll/entity/PollVote.java | 5 +- .../domain/poll/service/PollServiceImpl.java | 6 +- .../ai/lawyer/domain/post/entity/Post.java | 8 +- .../post/repository/PostRepository.java | 8 +- .../domain/post/service/PostServiceImpl.java | 43 +++++----- .../poll/service/PollAutoCloseTest.java | 4 +- 13 files changed, 118 insertions(+), 79 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java index fb0f1a2..b646cdf 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/entity/History.java @@ -1,6 +1,5 @@ package com.ai.lawyer.domain.chatbot.entity; -import com.ai.lawyer.domain.member.entity.Member; import jakarta.persistence.*; import lombok.AllArgsConstructor; import lombok.Builder; @@ -24,9 +23,10 @@ public class History { @GeneratedValue(strategy = GenerationType.IDENTITY) private Long historyId; - @ManyToOne - @JoinColumn(name = "member_id", foreignKey = @ForeignKey(name = "FK_HISTORY_MEMBER")) - private Member memberId; + // Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 (ConstraintMode.NO_CONSTRAINT) + // member_id를 직접 저장하고, 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장 + @Column(name = "member_id") + private Long memberId; @OneToMany(mappedBy = "historyId", cascade = CascadeType.ALL, orphanRemoval = true) private List chats; diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java index bea2a9a..029c9c9 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatLawRepository.java @@ -12,8 +12,9 @@ public interface ChatLawRepository extends JpaRepository { /** * member_id에 해당하는 모든 ChatLaw 삭제 (회원 탈퇴 시 사용) + * History.memberId가 Long 타입이므로 직접 비교 */ @Modifying - @Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId.memberId = :memberId") + @Query("DELETE FROM ChatLaw cl WHERE cl.chatId.historyId.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java index 820456d..dc80392 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/ChatPrecedentRepository.java @@ -10,8 +10,9 @@ public interface ChatPrecedentRepository extends JpaRepository { /** * member_id에 해당하는 모든 Chat 삭제 (회원 탈퇴 시 사용) + * History.memberId가 Long 타입이므로 직접 비교 */ @Modifying - @Query("DELETE FROM Chat c WHERE c.historyId.memberId.memberId = :memberId") + @Query("DELETE FROM Chat c WHERE c.historyId.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java index ec094d4..2b4f402 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/repository/HistoryRepository.java @@ -1,7 +1,6 @@ package com.ai.lawyer.domain.chatbot.repository; import com.ai.lawyer.domain.chatbot.entity.History; -import com.ai.lawyer.domain.member.entity.Member; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; import org.springframework.data.jpa.repository.Query; @@ -13,16 +12,17 @@ @Repository public interface HistoryRepository extends JpaRepository { - List findAllByMemberId(Member memberId); + // member_id로 직접 조회 (Member, OAuth2Member 모두 지원) + List findAllByMemberId(Long memberId); - History findByHistoryIdAndMemberId(Long roomId, Member memberId); + History findByHistoryIdAndMemberId(Long roomId, Long memberId); /** * member_id로 채팅 히스토리 삭제 (회원 탈퇴 시 사용) * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 */ @Modifying - @Query("DELETE FROM History h WHERE h.memberId.memberId = :memberId") + @Query("DELETE FROM History h WHERE h.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index f1bb75e..fec7196 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -28,6 +28,8 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; +import reactor.core.publisher.Mono; +import reactor.core.scheduler.Schedulers; import java.util.HashMap; import java.util.List; @@ -60,27 +62,36 @@ public class ChatBotService { @Transactional public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); + // Mono.fromCallable()과 subscribeOn()을 사용하여 블로킹 작업을 별도 스레드에서 실행 + // boundedElastic 스케줄러는 블로킹 I/O 작업에 최적화된 스레드 풀 사용 + return Mono.fromCallable(() -> { + // 멤버 조회 (블로킹) + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); - // 벡터 검색 (판례, 법령) - List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); - List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); + // 벡터 검색 (판례, 법령) (블로킹) + List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); + List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); - String caseContext = formatting(similarCaseDocuments); - String lawContext = formatting(similarLawDocuments); + String caseContext = formatting(similarCaseDocuments); + String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 또는 생성 - History history = getOrCreateRoom(member, roomId); + // 채팅방 조회 또는 생성 (블로킹) + History history = getOrCreateRoom(member, roomId); - // 메시지 기억 관리 (User 메시지 추가) - ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); + // 메시지 기억 관리 (User 메시지 추가) + ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); - // 프롬프트 생성 - Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); + // 프롬프트 생성 + Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 - return chatClient.prompt(prompt) + // 준비된 데이터를 담은 컨텍스트 객체 반환 + return new PreparedChatContext(prompt, history, similarCaseDocuments, similarLawDocuments); + }) + .subscribeOn(Schedulers.boundedElastic()) // 블로킹 작업을 별도 스레드에서 실행 + .flatMapMany(context -> { + // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 + return chatClient.prompt(context.prompt) .stream() .content() .collectList() @@ -88,40 +99,41 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, .doOnNext(fullResponse -> { // Document를 DTO로 변환 - List caseDtos = similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - List lawDtos = similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); + List caseDtos = context.similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); + List lawDtos = context.similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); // Kafka로 보낼 이벤트 객체 ChatPostProcessEvent event = new ChatPostProcessEvent( - history.getHistoryId(), + context.history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, caseDtos, lawDtos ); - + // Kafka 이벤트 발행 kafkaTemplate.send(POST_PROCESSING_TOPIC, event); }) - .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) + .map(fullResponse -> createChatResponse(context.history, fullResponse, context.similarCaseDocuments, context.similarLawDocuments)) .flux() .onErrorResume(throwable -> { - log.error("스트리밍 처리 중 에러 발생 (historyId: {})", history.getHistoryId(), throwable); - return Flux.just(handleError(history)); + log.error("스트리밍 처리 중 에러 발생 (historyId: {})", context.history.getHistoryId(), throwable); + return Flux.just(handleError(context.history)); }); + }); } private ChatResponse createChatResponse(History history, String fullResponse, List cases, List laws) { ChatPrecedentDto precedentDto = null; if (cases != null && !cases.isEmpty()) { - Document firstCase = cases.get(0); + Document firstCase = cases.getFirst(); precedentDto = ChatPrecedentDto.from(firstCase); } ChatLawDto lawDto = null; if (laws != null && !laws.isEmpty()) { - Document firstLaw = laws.get(0); + Document firstLaw = laws.getFirst(); lawDto = ChatLawDto.from(firstLaw); } @@ -159,7 +171,7 @@ private History getOrCreateRoom(Member member, Long roomId) { if (roomId != null) { return historyService.getHistory(roomId); } else { - return historyRepository.save(History.builder().memberId(member).build()); + return historyRepository.save(History.builder().memberId(member.getMemberId()).build()); } } @@ -178,4 +190,24 @@ private ChatResponse handleError(History history) { .message("죄송합니다. 서비스 처리 중 오류가 발생했습니다. 요청을 다시 전송해 주세요.") .build(); } + + /** + * 블로킹 작업에서 준비된 데이터를 담는 컨텍스트 클래스 + * 리액티브 체인에서 데이터를 전달하기 위한 내부 클래스 + */ + private static class PreparedChatContext { + final Prompt prompt; + final History history; + final List similarCaseDocuments; + final List similarLawDocuments; + + PreparedChatContext(Prompt prompt, History history, + List similarCaseDocuments, + List similarLawDocuments) { + this.prompt = prompt; + this.history = history; + this.similarCaseDocuments = similarCaseDocuments; + this.similarLawDocuments = similarLawDocuments; + } + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java index e1d9e1b..db14eb2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/HistoryService.java @@ -6,7 +6,6 @@ import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.exception.HistoryNotFoundException; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.member.repositories.MemberRepository; import com.ai.lawyer.infrastructure.redis.service.ChatCacheService; import lombok.RequiredArgsConstructor; @@ -28,11 +27,12 @@ public class HistoryService { public List getHistoryTitle(Long memberId) { - Member member = memberRepository.findById(memberId).orElseThrow( + // 회원 존재 여부 확인 + memberRepository.findById(memberId).orElseThrow( () -> new IllegalArgumentException("존재하지 않는 회원입니다.") ); - List rooms = historyRepository.findAllByMemberId(member); + List rooms = historyRepository.findAllByMemberId(memberId); List roomDtos = new ArrayList<>(); for (History room : rooms) @@ -45,11 +45,12 @@ public String deleteHistory(Long memberId, Long roomId) { getHistory(roomId); - Member member = memberRepository.findById(memberId).orElseThrow( + // 회원 존재 여부 확인 + memberRepository.findById(memberId).orElseThrow( () -> new IllegalArgumentException("존재하지 않는 회원입니다.") ); - History room = historyRepository.findByHistoryIdAndMemberId(roomId, member); + History room = historyRepository.findByHistoryIdAndMemberId(roomId, memberId); historyRepository.delete(room); chatCacheService.clearChatHistory(roomId); @@ -61,7 +62,8 @@ public String deleteHistory(Long memberId, Long roomId) { @Transactional(readOnly = true) public ResponseEntity> getChatHistory(Long memberId, Long roomId) { - Member member = memberRepository.findById(memberId).orElseThrow( + // 회원 존재 여부 확인 + memberRepository.findById(memberId).orElseThrow( () -> new IllegalArgumentException("존재하지 않는 회원입니다.") ); @@ -72,7 +74,7 @@ public ResponseEntity> getChatHistory(Long memberId, Long r } // 2. DB에서 조회 후 캐시에 저장 - History history = historyRepository.findByHistoryIdAndMemberId(roomId, member); + History history = historyRepository.findByHistoryIdAndMemberId(roomId, memberId); List chats = history.getChats(); // 엔티티 -> DTO 변환 diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java index a7c7e75..82bd620 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java @@ -21,8 +21,11 @@ public class PollVote { @JoinColumn(name = "poll_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_POLL")) private Poll poll; + // Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 + // 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장 + // foreignKey 제약조건 비활성화 (ConstraintMode.NO_CONSTRAINT) @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_MEMBER")) + @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) private Member member; @ManyToOne(fetch = FetchType.LAZY) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 45476e1..b2fea07 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -4,12 +4,10 @@ import com.ai.lawyer.domain.poll.entity.*; import com.ai.lawyer.domain.poll.repository.*; import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.post.dto.PostDto; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.post.repository.PostRepository; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.data.domain.Page; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -240,7 +238,7 @@ public void closePoll(Long pollId) { public void deletePoll(Long pollId, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "투표를 찾을 수 없습니다.")); - if (poll.getPost() == null || !poll.getPost().getMember().getMemberId().equals(memberId)) { + if (poll.getPost() == null || !poll.getPost().getMemberId().equals(memberId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 삭제할 수 있습니다."); } // 1. 이 Poll을 참조하는 Post가 있으면 연결 해제 @@ -310,7 +308,7 @@ public Long getVoteCountByPostId(Long postId) { public PollDto updatePoll(Long pollId, PollUpdateDto pollUpdateDto, Long memberId) { Poll poll = pollRepository.findById(pollId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "수정할 투표를 찾을 수 없습니다.")); - if (!poll.getPost().getMember().getMemberId().equals(memberId)) { + if (!poll.getPost().getMemberId().equals(memberId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인만 투표를 수정할 수 있습니다."); } if (getVoteCountByPollId(pollId) > 0) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java b/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java index 74b2f23..7469f4c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/entity/Post.java @@ -1,6 +1,5 @@ package com.ai.lawyer.domain.post.entity; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.poll.entity.Poll; import jakarta.persistence.*; import lombok.*; @@ -21,9 +20,10 @@ public class Post { @Column(name = "post_id") private Long postId; - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = true, foreignKey = @ForeignKey(name = "FK_POST_MEMBER")) - private Member member; + // Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 (ConstraintMode.NO_CONSTRAINT) + // member_id를 직접 저장하고, 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장 + @Column(name = "member_id") + private Long memberId; @Column(name = "post_name", length = 100, nullable = false) private String postName; diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java index db085a2..afe741b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/repository/PostRepository.java @@ -1,6 +1,5 @@ package com.ai.lawyer.domain.post.repository; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.post.entity.Post; import com.ai.lawyer.domain.poll.entity.Poll.PollStatus; import org.springframework.data.domain.Page; @@ -15,17 +14,18 @@ @Repository public interface PostRepository extends JpaRepository { - List findByMember(Member member); + // member_id로 직접 조회 (Member, OAuth2Member 모두 지원) + List findByMemberId(Long memberId); /** * member_id로 게시글 삭제 (회원 탈퇴 시 사용) * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 */ @Modifying - @Query("DELETE FROM Post p WHERE p.member.memberId = :memberId") + @Query("DELETE FROM Post p WHERE p.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); - Page findByMember(Member member, Pageable pageable); + Page findByMemberId(Long memberId, Pageable pageable); Page findByPoll_Status(PollStatus status, Pageable pageable); Page findByPoll_StatusAndPoll_PollIdIn(PollStatus status, List pollIds, Pageable pageable); Page findByPoll_PollIdIn(List pollIds, Pageable pageable); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 2b007cb..5aaa32c 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -1,6 +1,5 @@ package com.ai.lawyer.domain.post.service; -import com.ai.lawyer.domain.member.entity.Member; import com.ai.lawyer.domain.poll.dto.PollDto; import com.ai.lawyer.domain.poll.dto.PollDto.PollStatus; import com.ai.lawyer.domain.post.dto.PostDto; @@ -21,7 +20,6 @@ import com.ai.lawyer.global.util.AuthUtil; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; -import org.springframework.data.domain.PageImpl; import org.springframework.http.HttpStatus; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -64,9 +62,11 @@ public PostDto createPost(PostRequestDto postRequestDto, Long memberId) { postRequestDto.getPostContent() == null || postRequestDto.getPostContent().trim().isEmpty()) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "게시글 제목과 내용은 필수입니다."); } - Member member = AuthUtil.getMemberOrThrow(memberId); + // 회원 존재 여부 확인 (Member 또는 OAuth2Member) + AuthUtil.getMemberOrThrow(memberId); + Post post = Post.builder() - .member(member) + .memberId(memberId) .postName(postRequestDto.getPostName()) .postContent(postRequestDto.getPostContent()) .category(postRequestDto.getCategory()) @@ -90,7 +90,7 @@ public PostDetailDto getPostDetailById(Long postId, Long memberId) { public PostDetailDto getPostById(Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - PostDto postDto = convertToDto(post, post.getMember().getMemberId()); + PostDto postDto = convertToDto(post, post.getMemberId()); return PostDetailDto.builder() .post(postDto) .build(); @@ -98,8 +98,9 @@ public PostDetailDto getPostById(Long postId) { @Override public List getPostsByMemberId(Long memberId) { - Member member = AuthUtil.getMemberOrThrow(memberId); - List posts = postRepository.findByMember(member); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(memberId); + List posts = postRepository.findByMemberId(memberId); // if (posts.isEmpty()) { // throw new ResponseStatusException(HttpStatus.NOT_FOUND, "해당 회원의 게시글이 없습니다."); // } @@ -127,7 +128,7 @@ public PostDto updatePost(Long postId, PostUpdateDto postUpdateDto) { if (post.getPoll() == null) { throw new ResponseStatusException(HttpStatus.NOT_FOUND, "이 게시글에는 투표가 없어 투표 수정이 불가능합니다."); } - pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll(), post.getMember().getMemberId()); + pollService.updatePoll(post.getPoll().getPollId(), postUpdateDto.getPoll(), post.getMemberId()); } if (postUpdateDto.getPostName() != null) post.setPostName(postUpdateDto.getPostName()); @@ -135,7 +136,7 @@ public PostDto updatePost(Long postId, PostUpdateDto postUpdateDto) { if (postUpdateDto.getCategory() != null) post.setCategory(postUpdateDto.getCategory()); post.setUpdatedAt(LocalDateTime.now()); // 추가 postRepository.save(post); - return convertToDto(post, post.getMember().getMemberId()); + return convertToDto(post, post.getMemberId()); } @Override @@ -165,15 +166,16 @@ public List getAllPosts(Long memberId) { public PostDto getMyPostById(Long postId, Long requesterMemberId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new ResponseStatusException(HttpStatus.NOT_FOUND, "게시글을 찾을 수 없습니다.")); - if (!post.getMember().getMemberId().equals(requesterMemberId)) { + if (!post.getMemberId().equals(requesterMemberId)) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "본인 게시글만 조회할 수 있습니다."); } return convertToDto(post, requesterMemberId); } public List getMyPosts(Long requesterMemberId) { - Member member = AuthUtil.getMemberOrThrow(requesterMemberId); - List posts = postRepository.findByMember(member); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(requesterMemberId); + List posts = postRepository.findByMemberId(requesterMemberId); return posts.stream() .sorted(Comparator.comparing(Post::getUpdatedAt, Comparator.nullsLast(Comparator.naturalOrder())).reversed()) .map(post -> convertToDto(post, requesterMemberId)) @@ -182,8 +184,9 @@ public List getMyPosts(Long requesterMemberId) { @Override public Page getMyPostspaged(Pageable pageable, Long requesterMemberId) { - Member member = AuthUtil.getMemberOrThrow(requesterMemberId); - Page posts = postRepository.findByMember(member, pageable); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(requesterMemberId); + Page posts = postRepository.findByMemberId(requesterMemberId, pageable); return posts.map(post -> convertToDto(post, requesterMemberId)); } @@ -218,9 +221,10 @@ public PostDetailDto createPostWithPoll(PostWithPollCreateDto dto, Long memberId } var pollDto = dto.getPoll(); pollService.validatePollCreate(pollDto); - Member member = AuthUtil.getMemberOrThrow(memberId); + // 회원 존재 여부 확인 + AuthUtil.getMemberOrThrow(memberId); Post post = Post.builder() - .member(member) + .memberId(memberId) .postName(postDto.getPostName()) .postContent(postDto.getPostContent()) .category(postDto.getCategory()) @@ -263,7 +267,7 @@ public List getAllSimplePosts() { } return PostSimpleDto.builder() .postId(post.getPostId()) - .memberId(post.getMember().getMemberId()) + .memberId(post.getMemberId()) .poll(pollInfo) .build(); }) @@ -339,10 +343,7 @@ public List getTopNPollsByStatus(PollStatus status, int n, Long memberI } private PostDto convertToDto(Post entity, Long memberId) { - Long postMemberId = null; - if (entity.getMember() != null) { - postMemberId = entity.getMember().getMemberId(); - } + Long postMemberId = entity.getMemberId(); PollDto pollDto = null; if (entity.getPoll() != null) { if (entity.getPoll().getStatus() == Poll.PollStatus.CLOSED) { diff --git a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java index 7d6b8f9..79c2d58 100644 --- a/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java +++ b/backend/src/test/java/com/ai/lawyer/domain/poll/service/PollAutoCloseTest.java @@ -63,7 +63,7 @@ void autoCloseTest() { post.setPostContent("테스트 내용"); post.setCategory("테스트"); post.setCreatedAt(LocalDateTime.now()); - post.setMember(member); + post.setMemberId(member.getMemberId()); post.setPoll(null); lenient().when(postRepository.save(any(Post.class))).thenReturn(post); @@ -80,7 +80,7 @@ void autoCloseTest() { postWithPoll.setPostContent("테스트 내용"); postWithPoll.setCategory("테스트"); postWithPoll.setCreatedAt(post.getCreatedAt()); - postWithPoll.setMember(member); + postWithPoll.setMemberId(member.getMemberId()); postWithPoll.setPoll(poll); lenient().when(postRepository.save(argThat(p -> p.getPoll() != null))).thenReturn(postWithPoll); From fc4e6a9e88859539304aff4ba2db7fab2cfbaaaf Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Wed, 15 Oct 2025 23:25:40 +0900 Subject: [PATCH 17/28] work --- .../post/controller/PostDummyController.java | 16 +++++ .../domain/post/service/PostDummyService.java | 64 +++++++++++++++++++ 2 files changed, 80 insertions(+) diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java index 170c485..7e1a1b2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/controller/PostDummyController.java @@ -38,4 +38,20 @@ public ResponseEntity deleteDummyMembers() { int deleted = dummyService.deleteDummyMembers(); return ResponseEntity.ok("더미 멤버 " + deleted + "명 삭제 완료"); } + + //모든 더미 유저가 1번 옵션에 투표 + @Operation(summary = "더미 멤버 1번 투표") + @PostMapping("/vote1") + public ResponseEntity dummyVote1Option(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote1Option(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } + + //모든 더미 유저가 2번 옵션에 투표 + @Operation(summary = "더미 멤버 2번 투표") + @PostMapping("/vote2") + public ResponseEntity dummyVote2Option(@RequestParam Long postId) { + int voteCount = dummyService.dummyVote2Option(postId); + return ResponseEntity.ok("더미 멤버 " + voteCount + "명 투표 완료"); + } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java index c0348ec..e944640 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java @@ -114,6 +114,70 @@ public int dummyVote(Long postId) { return voteCount; } + /** + * 모든 더미 유저가 1번 옵션에 투표 + */ + @Transactional + public int dummyVote1Option(Long postId) { + Optional postOpt = postRepository.findById(postId); + if (postOpt.isEmpty()) return 0; + Post post = postOpt.get(); + if (post.getPoll() == null) return 0; + List pollOptionsList = pollOptionsRepository.findByPoll_PollId(post.getPoll().getPollId()); + if (pollOptionsList.size() < 1) return 0; + PollOptions firstOption = pollOptionsList.get(0); + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + List votedMemberIds = pollVoteRepository.findMemberIdsByPoll(post.getPoll()); + Set votedMemberIdSet = new HashSet<>(votedMemberIds); + int voteCount = 0; + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .member(member) + .pollOptions(firstOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + + /** + * 모든 더미 유저가 2번 옵션에 투표 + */ + @Transactional + public int dummyVote2Option(Long postId) { + Optional postOpt = postRepository.findById(postId); + if (postOpt.isEmpty()) return 0; + Post post = postOpt.get(); + if (post.getPoll() == null) return 0; + List pollOptionsList = pollOptionsRepository.findByPoll_PollId(post.getPoll().getPollId()); + if (pollOptionsList.size() < 2) return 0; + PollOptions secondOption = pollOptionsList.get(1); + List dummyMembers = memberRepository.findAll().stream() + .filter(m -> m.getLoginId().startsWith("dummy") && m.getLoginId().endsWith("@test.com")) + .toList(); + List votedMemberIds = pollVoteRepository.findMemberIdsByPoll(post.getPoll()); + Set votedMemberIdSet = new HashSet<>(votedMemberIds); + int voteCount = 0; + for (Member member : dummyMembers) { + if (!votedMemberIdSet.contains(member.getMemberId())) { + PollVote pollVote = PollVote.builder() + .poll(post.getPoll()) + .member(member) + .pollOptions(secondOption) + .build(); + pollVoteRepository.save(pollVote); + voteCount++; + } + } + return voteCount; + } + @Transactional public int deleteDummyMembers() { List dummyMembers = memberRepository.findAll().stream() From 2e04a63d59db7655e8bdcfa924bddece99f21b43 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Thu, 16 Oct 2025 02:57:08 +0900 Subject: [PATCH 18/28] Fix[poll]:Long memberId --- .../ai/lawyer/domain/poll/entity/PollVote.java | 5 ++--- .../poll/repository/PollVoteRepository.java | 15 +++++++-------- .../poll/repository/PollVoteRepositoryImpl.java | 6 +++--- .../domain/poll/service/PollServiceImpl.java | 12 ++++++------ .../domain/post/service/PostDummyService.java | 6 +++--- .../domain/post/service/PostServiceImpl.java | 2 +- 6 files changed, 22 insertions(+), 24 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java index 82bd620..4b52b93 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java @@ -24,9 +24,8 @@ public class PollVote { // Member와 OAuth2Member 모두 지원하기 위해 FK 제약 조건 제거 // 애플리케이션 레벨에서 AuthUtil로 참조 무결성 보장 // foreignKey 제약조건 비활성화 (ConstraintMode.NO_CONSTRAINT) - @ManyToOne(fetch = FetchType.LAZY) - @JoinColumn(name = "member_id", nullable = false, foreignKey = @ForeignKey(ConstraintMode.NO_CONSTRAINT)) - private Member member; + @Column(name = "member_id", nullable = false) + private Long memberId; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "poll_items_id", nullable = false, foreignKey = @ForeignKey(name = "FK_POLLVOTE_POLLOPTIONS")) diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java index 1f07ca4..4a89320 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepository.java @@ -12,21 +12,20 @@ import java.util.Optional; public interface PollVoteRepository extends JpaRepository, PollVoteRepositoryCustom { - Optional findByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); - void deleteByMember_MemberIdAndPoll_PollId(Long memberId, Long pollId); - List findByMember_MemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); - List findByMember_MemberId(Long memberId); + Optional findByMemberIdAndPoll_PollId(Long memberId, Long pollId); + void deleteByMemberIdAndPoll_PollId(Long memberId, Long pollId); + List findByMemberIdAndPollOptions_PollItemsId(Long memberId, Long pollItemsId); + List findByMemberId(Long memberId); /** * member_id로 투표 내역 삭제 (회원 탈퇴 시 사용) * Member와 OAuth2Member 모두 같은 member_id 공간을 사용하므로 Long 타입으로 삭제 */ @Modifying - @Query("DELETE FROM PollVote pv WHERE pv.member.memberId = :memberId") + @Query("DELETE FROM PollVote pv WHERE pv.memberId = :memberId") void deleteByMemberIdValue(@Param("memberId") Long memberId); - boolean existsByPollAndMember(Poll poll, Member member); - - @Query("SELECT v.member.memberId FROM PollVote v WHERE v.poll = :poll") + boolean existsByPollAndMemberId(Poll poll, Long memberId); + @Query("SELECT v.memberId FROM PollVote v WHERE v.poll = :poll") List findMemberIdsByPoll(@Param("poll") Poll poll); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java index b241faf..0a2f79a 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/repository/PollVoteRepositoryImpl.java @@ -94,7 +94,7 @@ public List countStaticsByPollOptionIds(List pollOptionIds pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPollItemsId().in(pollOptionIds)) .groupBy(pollOptions.getPollItemsId(), member.getGender(), member.getAge()) .fetch(); @@ -147,7 +147,7 @@ public List getOptionAgeStatics(Long pollId) { pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPoll().getPollId().eq(pollId)) .groupBy(pollOptions.getOption(), new com.querydsl.core.types.dsl.CaseBuilder() @@ -177,7 +177,7 @@ public List getOptionGenderStatics(Long pollId) { pollVote.count()) .from(pollVote) .join(pollVote.getPollOptions(), pollOptions) - .join(pollVote.getMember(), member) + .join(member).on(pollVote.getMemberId().eq(member.getMemberId())) .where(pollOptions.getPoll().getPollId().eq(pollId)) .groupBy(pollOptions.getOption(), member.getGender()) .fetch(); diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 7965743..4d46fd2 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -112,17 +112,17 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } // 기존 투표 내역 조회 - var existingVoteOpt = pollVoteRepository.findByMember_MemberIdAndPoll_PollId(memberId, pollId); + var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); if (existingVoteOpt.isPresent()) { PollVote existingVote = existingVoteOpt.get(); if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); } else { - pollVoteRepository.deleteByMember_MemberIdAndPoll_PollId(memberId, pollId); + pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); PollVote pollVote = PollVote.builder() .poll(poll) .pollOptions(pollOptions) - .member(member) + .memberId(memberId) .build(); PollVote savedVote = pollVoteRepository.save(pollVote); Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); @@ -140,7 +140,7 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { PollVote pollVote = PollVote.builder() .poll(poll) .pollOptions(pollOptions) - .member(member) + .memberId(memberId) .build(); PollVote savedVote = pollVoteRepository.save(pollVote); Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); @@ -441,7 +441,7 @@ private PollDto convertToDto(Poll poll, Long memberId, boolean withStatistics) { Long voteCount = pollVoteRepository.countByPollOptionId(option.getPollItemsId()); boolean voted = false; if (memberId != null) { - voted = !pollVoteRepository.findByMember_MemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); + voted = !pollVoteRepository.findByMemberIdAndPollOptions_PollItemsId(memberId, option.getPollItemsId()).isEmpty(); } List statics = null; if (withStatistics && poll.getStatus() == Poll.PollStatus.CLOSED) { @@ -539,7 +539,7 @@ public void validatePollCreate(PollCreateDto dto) { @Override public void cancelVote(Long pollId, Long memberId) { - pollVoteRepository.findByMember_MemberIdAndPoll_PollId(memberId, pollId) + pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId) .ifPresent(pollVoteRepository::delete); } } diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java index e944640..9e83c5e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostDummyService.java @@ -104,7 +104,7 @@ public int dummyVote(Long postId) { PollOptions selectedOption = pollOptionsList.get(random.nextInt(pollOptionsList.size())); PollVote pollVote = PollVote.builder() .poll(post.getPoll()) - .member(member) + .memberId(member.getMemberId()) .pollOptions(selectedOption) .build(); pollVoteRepository.save(pollVote); @@ -136,7 +136,7 @@ public int dummyVote1Option(Long postId) { if (!votedMemberIdSet.contains(member.getMemberId())) { PollVote pollVote = PollVote.builder() .poll(post.getPoll()) - .member(member) + .memberId(member.getMemberId()) .pollOptions(firstOption) .build(); pollVoteRepository.save(pollVote); @@ -168,7 +168,7 @@ public int dummyVote2Option(Long postId) { if (!votedMemberIdSet.contains(member.getMemberId())) { PollVote pollVote = PollVote.builder() .poll(post.getPoll()) - .member(member) + .memberId(member.getMemberId()) .pollOptions(secondOption) .build(); pollVoteRepository.save(pollVote); diff --git a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java index 5aaa32c..7c0dc5e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/post/service/PostServiceImpl.java @@ -300,7 +300,7 @@ public Page getClosedPostsPaged(Pageable pageable, Long memberId) { } private Page getMyVotedPostsPagedByStatus(Pageable pageable, Long memberId, Poll.PollStatus status) { - List votes = pollVoteRepository.findByMember_MemberId(memberId); + List votes = pollVoteRepository.findByMemberId(memberId); List pollIds = votes.stream().map(v -> v.getPoll().getPollId()).distinct().toList(); Page posts = (status == null) ? postRepository.findByPoll_PollIdIn(pollIds, pageable) From a909249cad18f006e58e59293b38b779a0bb9711 Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 16 Oct 2025 03:03:05 +0900 Subject: [PATCH 19/28] =?UTF-8?q?fix[member]:=ED=86=A0=ED=81=B0=20?= =?UTF-8?q?=EC=83=9D=EC=84=B1=20=EB=A1=9C=EC=A7=81=20=EC=88=98=EC=A0=95?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=EC=9D=B8=ED=95=9C=20=ED=85=8C=EC=8A=A4?= =?UTF-8?q?=ED=8A=B8=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatbot/controller/ChatBotController.java | 14 +++++++++----- 1 file changed, 9 insertions(+), 5 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index 0a4a6d6..61cd792 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -3,12 +3,12 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatRequest; import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; import com.ai.lawyer.domain.chatbot.service.ChatBotService; +import com.ai.lawyer.global.util.AuthUtil; import io.swagger.v3.oas.annotations.Operation; import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.http.ResponseEntity; -import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.stereotype.Controller; import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PostMapping; @@ -27,15 +27,19 @@ public class ChatBotController { @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") @PostMapping("/message") - public ResponseEntity> postNewMessage( - @AuthenticationPrincipal Long memberId, - @RequestBody ChatRequest chatRequest) { + public ResponseEntity> postNewMessage(@RequestBody ChatRequest chatRequest) { + Long memberId = AuthUtil.getAuthenticatedMemberId(); + log.debug("새로운 채팅 요청: memberId={}", memberId); return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, null)); } @Operation(summary = "02. 기존 채팅", description = "기존 채팅방에 메시지를 보내고 챗봇과 대화를 이어감") @PostMapping("{roomId}/message") - public ResponseEntity> postMessage(@AuthenticationPrincipal Long memberId, @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { + public ResponseEntity> postMessage( + @RequestBody ChatRequest chatRequest, + @PathVariable(value = "roomId", required = false) Long roomId) { + Long memberId = AuthUtil.getAuthenticatedMemberId(); + log.debug("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, roomId)); } From c6c291dd858fe0f84e40eea07dbe3e6b9d49b7b3 Mon Sep 17 00:00:00 2001 From: GarakChoi Date: Thu, 16 Oct 2025 03:53:30 +0900 Subject: [PATCH 20/28] work --- .../domain/chatbot/service/ChatBotService.java | 12 +++--------- 1 file changed, 3 insertions(+), 9 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index fec7196..40d7b8e 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -61,13 +61,7 @@ public class ChatBotService { // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> Kafka 이벤트 발행 -> 응답 반환 @Transactional public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { - - // Mono.fromCallable()과 subscribeOn()을 사용하여 블로킹 작업을 별도 스레드에서 실행 - // boundedElastic 스케줄러는 블로킹 I/O 작업에 최적화된 스레드 풀 사용 return Mono.fromCallable(() -> { - // 멤버 조회 (블로킹) - Member member = memberRepository.findById(memberId) - .orElseThrow(() -> new IllegalArgumentException("존재하지 않는 회원입니다.")); // 벡터 검색 (판례, 법령) (블로킹) List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); @@ -77,7 +71,7 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, String lawContext = formatting(similarLawDocuments); // 채팅방 조회 또는 생성 (블로킹) - History history = getOrCreateRoom(member, roomId); + History history = getOrCreateRoom(memberId, roomId); // 메시지 기억 관리 (User 메시지 추가) ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); @@ -167,11 +161,11 @@ private Prompt getPrompt(String caseContext, String lawContext, ChatMemory chatM return new Prompt(List.of(systemMessage, userMessage)); } - private History getOrCreateRoom(Member member, Long roomId) { + private History getOrCreateRoom(Long memberId, Long roomId) { if (roomId != null) { return historyService.getHistory(roomId); } else { - return historyRepository.save(History.builder().memberId(member.getMemberId()).build()); + return historyRepository.save(History.builder().memberId(memberId).build()); } } From f4d27492c9f3aa0770ce57fce42ced61edb3558b Mon Sep 17 00:00:00 2001 From: asowjdan Date: Thu, 16 Oct 2025 05:32:38 +0900 Subject: [PATCH 21/28] =?UTF-8?q?fix[bug]:=EC=B1=97=EB=B4=87=20=EC=98=A4?= =?UTF-8?q?=EB=A5=98=20=EC=88=98=EC=A0=95=20=EB=B0=8F=20=EC=A4=91=EB=B3=B5?= =?UTF-8?q?=20=ED=88=AC=ED=91=9C=20=EB=B0=A9=EC=A7=80=20=EB=A1=9C=EC=A7=81?= =?UTF-8?q?=20=EC=98=A4=EB=A5=98=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatbot/controller/ChatBotController.java | 31 +++---- .../lawyer/domain/poll/entity/PollVote.java | 8 +- .../domain/poll/service/PollServiceImpl.java | 85 ++++++++++--------- 3 files changed, 68 insertions(+), 56 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index 61cd792..957f1cc 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -8,17 +8,12 @@ import io.swagger.v3.oas.annotations.tags.Tag; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.http.ResponseEntity; -import org.springframework.stereotype.Controller; -import org.springframework.web.bind.annotation.PathVariable; -import org.springframework.web.bind.annotation.PostMapping; -import org.springframework.web.bind.annotation.RequestBody; -import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.*; import reactor.core.publisher.Flux; @Slf4j @Tag(name = "ChatBot API", description = "챗봇 관련 API") -@Controller +@RestController @RequiredArgsConstructor @RequestMapping("/api/chat") public class ChatBotController { @@ -26,21 +21,27 @@ public class ChatBotController { private final ChatBotService chatBotService; @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") - @PostMapping("/message") - public ResponseEntity> postNewMessage(@RequestBody ChatRequest chatRequest) { + @PostMapping(value = "/message", produces = "application/stream+json") + public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { + // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) Long memberId = AuthUtil.getAuthenticatedMemberId(); - log.debug("새로운 채팅 요청: memberId={}", memberId); - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, null)); + log.info("새로운 채팅 요청: memberId={}", memberId); + + // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) + return chatBotService.sendMessage(memberId, chatRequest, null); } @Operation(summary = "02. 기존 채팅", description = "기존 채팅방에 메시지를 보내고 챗봇과 대화를 이어감") - @PostMapping("{roomId}/message") - public ResponseEntity> postMessage( + @PostMapping(value = "{roomId}/message", produces = "application/stream+json") + public Flux postMessage( @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { + // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) Long memberId = AuthUtil.getAuthenticatedMemberId(); - log.debug("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); - return ResponseEntity.ok(chatBotService.sendMessage(memberId, chatRequest, roomId)); + log.info("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); + + // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) + return chatBotService.sendMessage(memberId, chatRequest, roomId); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java index 4b52b93..558e472 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/entity/PollVote.java @@ -1,11 +1,15 @@ package com.ai.lawyer.domain.poll.entity; -import com.ai.lawyer.domain.member.entity.Member; import jakarta.persistence.*; import lombok.*; @Entity -@Table(name = "poll_vote") +@Table(name = "poll_vote", + uniqueConstraints = @UniqueConstraint( + name = "uk_poll_vote_member_poll", + columnNames = {"member_id", "poll_id"} + ) +) @Data @NoArgsConstructor @AllArgsConstructor diff --git a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java index 4d46fd2..fe468f0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java +++ b/backend/src/main/java/com/ai/lawyer/domain/poll/service/PollServiceImpl.java @@ -111,47 +111,54 @@ public PollVoteDto vote(Long pollId, Long pollItemsId, Long memberId) { if (!(member.getRole().name().equals("USER") || member.getRole().name().equals("ADMIN"))) { throw new ResponseStatusException(HttpStatus.FORBIDDEN, "투표 권한이 없습니다."); } - // 기존 투표 내역 조회 - var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); - if (existingVoteOpt.isPresent()) { - PollVote existingVote = existingVoteOpt.get(); - if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { - throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); - } else { - pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .memberId(memberId) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표 항목을 변경하였습니다.") - .build(); + + try { + // 기존 투표 내역 조회 + var existingVoteOpt = pollVoteRepository.findByMemberIdAndPoll_PollId(memberId, pollId); + if (existingVoteOpt.isPresent()) { + PollVote existingVote = existingVoteOpt.get(); + if (existingVote.getPollOptions().getPollItemsId().equals(pollItemsId)) { + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다."); + } else { + pollVoteRepository.deleteByMemberIdAndPoll_PollId(memberId, pollId); + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표 항목을 변경하였습니다.") + .build(); + } } + // 기존 투표 내역이 없으면 정상 투표 + PollVote pollVote = PollVote.builder() + .poll(poll) + .pollOptions(pollOptions) + .memberId(memberId) + .build(); + PollVote savedVote = pollVoteRepository.save(pollVote); + Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); + return PollVoteDto.builder() + .pollVoteId(savedVote.getPollVoteId()) + .pollId(pollId) + .pollItemsId(pollItemsId) + .memberId(memberId) + .voteCount(voteCount) + .message("투표가 완료되었습니다.") + .build(); + } catch (org.springframework.dao.DataIntegrityViolationException e) { + // 동시성 문제로 인한 중복 투표 시도 (unique constraint violation) + log.warn("중복 투표 시도 감지 - memberId: {}, pollId: {}", memberId, pollId, e); + throw new ResponseStatusException(HttpStatus.BAD_REQUEST, "이미 투표하셨습니다. 중복 투표는 불가능합니다."); } - // 기존 투표 내역이 없으면 정상 투표 - PollVote pollVote = PollVote.builder() - .poll(poll) - .pollOptions(pollOptions) - .memberId(memberId) - .build(); - PollVote savedVote = pollVoteRepository.save(pollVote); - Long voteCount = pollVoteRepository.countByPollOptionId(pollItemsId); - return PollVoteDto.builder() - .pollVoteId(savedVote.getPollVoteId()) - .pollId(pollId) - .pollItemsId(pollItemsId) - .memberId(memberId) - .voteCount(voteCount) - .message("투표가 완료되었습니다.") - .build(); } @Override From f23c7f9a4545a416cef9530a1939410a581b0ce0 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 16 Oct 2025 10:05:48 +0900 Subject: [PATCH 22/28] =?UTF-8?q?fix:=20=EA=B6=8C=ED=95=9C=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../main/java/com/ai/lawyer/global/security/SecurityConfig.java | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java index 8c7067a..12a1e05 100644 --- a/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java +++ b/backend/src/main/java/com/ai/lawyer/global/security/SecurityConfig.java @@ -57,6 +57,7 @@ public class SecurityConfig { "/api/law/**", // 법령 (공개) "/api/law-word/**", // 법률 용어 (공개) "/api/home/**", // 홈 (공개) + "/api/chat/**", // 챗봇 "/h2-console/**", // H2 콘솔 (개발용) "/actuator/health", "/actuator/health/**", "/actuator/info", // Spring Actuator "/api/actuator/health", "/api/actuator/health/**", "/api/actuator/info", From c91a161caf7c1b92bd454037a993fda3280b0fcf Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 16 Oct 2025 10:59:23 +0900 Subject: [PATCH 23/28] =?UTF-8?q?fix:=20=EC=B1=84=ED=8C=85=20=EC=97=90?= =?UTF-8?q?=EB=9F=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatbot/controller/ChatBotController.java | 7 ++- .../chatbot/service/ChatBotService.java | 59 ++++++++----------- 2 files changed, 30 insertions(+), 36 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index 957f1cc..d456726 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -21,10 +21,13 @@ public class ChatBotController { private final ChatBotService chatBotService; @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") - @PostMapping(value = "/message", produces = "application/stream+json") + @PostMapping(value = "/message") public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) Long memberId = AuthUtil.getAuthenticatedMemberId(); + if (memberId == null) { + throw new IllegalStateException("인증된 사용자가 아닙니다."); + } log.info("새로운 채팅 요청: memberId={}", memberId); // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) @@ -32,7 +35,7 @@ public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { } @Operation(summary = "02. 기존 채팅", description = "기존 채팅방에 메시지를 보내고 챗봇과 대화를 이어감") - @PostMapping(value = "{roomId}/message", produces = "application/stream+json") + @PostMapping(value = "{roomId}/message") public Flux postMessage( @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index 40d7b8e..d319345 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -6,11 +6,9 @@ import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatResponse; import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; +import com.ai.lawyer.global.qdrant.service.QdrantService; import com.ai.lawyer.infrastructure.kafka.dto.ChatPostProcessEvent; import com.ai.lawyer.infrastructure.kafka.dto.DocumentDto; -import com.ai.lawyer.domain.member.entity.Member; -import com.ai.lawyer.domain.member.repositories.MemberRepository; -import com.ai.lawyer.global.qdrant.service.QdrantService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; @@ -28,8 +26,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; -import reactor.core.publisher.Mono; -import reactor.core.scheduler.Schedulers; import java.util.HashMap; import java.util.List; @@ -44,7 +40,6 @@ public class ChatBotService { private final ChatClient chatClient; private final QdrantService qdrantService; private final HistoryService historyService; - private final MemberRepository memberRepository; private final HistoryRepository historyRepository; private final ChatMemoryRepository chatMemoryRepository; @@ -61,31 +56,27 @@ public class ChatBotService { // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> Kafka 이벤트 발행 -> 응답 반환 @Transactional public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { - return Mono.fromCallable(() -> { - // 벡터 검색 (판례, 법령) (블로킹) - List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); - List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); + // 벡터 검색 (판례, 법령) (블로킹) + List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); + List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); - String caseContext = formatting(similarCaseDocuments); - String lawContext = formatting(similarLawDocuments); + String caseContext = formatting(similarCaseDocuments); + String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 또는 생성 (블로킹) - History history = getOrCreateRoom(memberId, roomId); + // 채팅방 조회 또는 생성 (블로킹) + History history = getOrCreateRoom(memberId, roomId); - // 메시지 기억 관리 (User 메시지 추가) - ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); + // 메시지 기억 관리 (User 메시지 추가) + ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); - // 프롬프트 생성 - Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); + // 프롬프트 생성 + Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // 준비된 데이터를 담은 컨텍스트 객체 반환 - return new PreparedChatContext(prompt, history, similarCaseDocuments, similarLawDocuments); - }) - .subscribeOn(Schedulers.boundedElastic()) // 블로킹 작업을 별도 스레드에서 실행 - .flatMapMany(context -> { - // LLM 스트리밍 호출 및 클라이언트에게 즉시 응답 - return chatClient.prompt(context.prompt) + // 준비된 데이터를 담은 컨텍스트 객체 반환 + //return new PreparedChatContext(prompt, history, similarCaseDocuments, similarLawDocuments); + + return chatClient.prompt(prompt) .stream() .content() .collectList() @@ -93,12 +84,12 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, .doOnNext(fullResponse -> { // Document를 DTO로 변환 - List caseDtos = context.similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - List lawDtos = context.similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); + List caseDtos = similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); + List lawDtos = similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); // Kafka로 보낼 이벤트 객체 ChatPostProcessEvent event = new ChatPostProcessEvent( - context.history.getHistoryId(), + history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, caseDtos, @@ -109,13 +100,13 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, kafkaTemplate.send(POST_PROCESSING_TOPIC, event); }) - .map(fullResponse -> createChatResponse(context.history, fullResponse, context.similarCaseDocuments, context.similarLawDocuments)) + .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) .flux() .onErrorResume(throwable -> { - log.error("스트리밍 처리 중 에러 발생 (historyId: {})", context.history.getHistoryId(), throwable); - return Flux.just(handleError(context.history)); + log.error("스트리밍 처리 중 에러 발생 (historyId: {})", history.getHistoryId(), throwable); + return Flux.just(handleError(history)); }); - }); + } private ChatResponse createChatResponse(History history, String fullResponse, List cases, List laws) { @@ -196,8 +187,8 @@ private static class PreparedChatContext { final List similarLawDocuments; PreparedChatContext(Prompt prompt, History history, - List similarCaseDocuments, - List similarLawDocuments) { + List similarCaseDocuments, + List similarLawDocuments) { this.prompt = prompt; this.history = history; this.similarCaseDocuments = similarCaseDocuments; From 73ee202a0198448caf633661645c5e1f01f83934 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 16 Oct 2025 12:01:03 +0900 Subject: [PATCH 24/28] =?UTF-8?q?fix[chat]:=20=EC=A7=80=EC=A0=80=EB=B6=84?= =?UTF-8?q?=ED=95=9C=20=EC=BD=94=EB=93=9C=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../chatbot/controller/ChatBotController.java | 11 +++----- .../chatbot/service/ChatBotService.java | 28 ++----------------- 2 files changed, 7 insertions(+), 32 deletions(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java index d456726..554d36b 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/controller/ChatBotController.java @@ -23,14 +23,11 @@ public class ChatBotController { @Operation(summary = "01. 새로운 채팅", description = "첫 메시지 전송으로 새로운 채팅방을 생성하고 챗봇과 대화를 시작") @PostMapping(value = "/message") public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { - // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) + Long memberId = AuthUtil.getAuthenticatedMemberId(); - if (memberId == null) { - throw new IllegalStateException("인증된 사용자가 아닙니다."); - } + log.info("새로운 채팅 요청: memberId={}", memberId); - // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) return chatBotService.sendMessage(memberId, chatRequest, null); } @@ -39,11 +36,11 @@ public Flux postNewMessage(@RequestBody ChatRequest chatRequest) { public Flux postMessage( @RequestBody ChatRequest chatRequest, @PathVariable(value = "roomId", required = false) Long roomId) { - // SecurityContext에서 memberId를 미리 추출 (컨트롤러 진입 시점) + Long memberId = AuthUtil.getAuthenticatedMemberId(); + log.info("기존 채팅 요청: memberId={}, roomId={}", memberId, roomId); - // memberId를 Flux에 전달 (SecurityContext 전파 문제 방지) return chatBotService.sendMessage(memberId, chatRequest, roomId); } diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index d319345..35ebad0 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -57,25 +57,22 @@ public class ChatBotService { @Transactional public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, Long roomId) { - // 벡터 검색 (판례, 법령) (블로킹) + // 벡터 검색 (판례, 법령) List similarCaseDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "판례"); List similarLawDocuments = qdrantService.searchDocument(chatRequestDto.getMessage(), "type", "법령"); String caseContext = formatting(similarCaseDocuments); String lawContext = formatting(similarLawDocuments); - // 채팅방 조회 또는 생성 (블로킹) + // 채팅방 조회 또는 생성 History history = getOrCreateRoom(memberId, roomId); - // 메시지 기억 관리 (User 메시지 추가) + // 메시지 기억 관리 ChatMemory chatMemory = saveChatMemory(chatRequestDto, history); // 프롬프트 생성 Prompt prompt = getPrompt(caseContext, lawContext, chatMemory, history); - // 준비된 데이터를 담은 컨텍스트 객체 반환 - //return new PreparedChatContext(prompt, history, similarCaseDocuments, similarLawDocuments); - return chatClient.prompt(prompt) .stream() .content() @@ -176,23 +173,4 @@ private ChatResponse handleError(History history) { .build(); } - /** - * 블로킹 작업에서 준비된 데이터를 담는 컨텍스트 클래스 - * 리액티브 체인에서 데이터를 전달하기 위한 내부 클래스 - */ - private static class PreparedChatContext { - final Prompt prompt; - final History history; - final List similarCaseDocuments; - final List similarLawDocuments; - - PreparedChatContext(Prompt prompt, History history, - List similarCaseDocuments, - List similarLawDocuments) { - this.prompt = prompt; - this.history = history; - this.similarCaseDocuments = similarCaseDocuments; - this.similarLawDocuments = similarLawDocuments; - } - } } \ No newline at end of file From d353f6c0f0f497a1cda2a8be1c2d77c5781b9cac Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 16 Oct 2025 13:12:05 +0900 Subject: [PATCH 25/28] =?UTF-8?q?fix[kafka]:=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- backend/build.gradle | 1 - backend/docker-compose.yml | 34 +------------------ .../AsyncPostChatProcessingService.java | 25 ++++++++++++-- .../chatbot/service/ChatBotService.java | 31 ++--------------- .../consumer/ChatPostProcessingConsumer.java | 3 +- .../src/main/resources/application-dev.yml | 12 ------- 6 files changed, 28 insertions(+), 78 deletions(-) diff --git a/backend/build.gradle b/backend/build.gradle index 008907e..615add1 100644 --- a/backend/build.gradle +++ b/backend/build.gradle @@ -41,7 +41,6 @@ dependencies { implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' implementation group: 'org.springframework.boot', name: 'spring-boot-starter-mail', version: '3.0.5' implementation 'org.springframework.boot:spring-boot-starter-batch' - implementation 'org.springframework.kafka:spring-kafka' testImplementation 'org.springframework.kafka:spring-kafka-test' // API Documentation (문서화) diff --git a/backend/docker-compose.yml b/backend/docker-compose.yml index 3dc0af5..d0204b3 100644 --- a/backend/docker-compose.yml +++ b/backend/docker-compose.yml @@ -82,40 +82,8 @@ services: timeout: 5s retries: 10 - zookeeper: - image: confluentinc/cp-zookeeper:7.8.0 - container_name: zookeeper - restart: unless-stopped - ports: - - "2181:2181" - environment: - ZOOKEEPER_CLIENT_PORT: 2181 - ZOOKEEPER_TICK_TIME: 2000 - - kafka: - image: confluentinc/cp-kafka:7.8.0 - container_name: kafka - restart: unless-stopped - depends_on: - - zookeeper - ports: - - "9092:9092" - environment: - KAFKA_BROKER_ID: 1 - KAFKA_ZOOKEEPER_CONNECT: zookeeper:2181 - KAFKA_LISTENER_SECURITY_PROTOCOL_MAP: INTERNAL:PLAINTEXT,EXTERNAL:PLAINTEXT - KAFKA_LISTENERS: INTERNAL://0.0.0.0:29092,EXTERNAL://0.0.0.0:9092 - KAFKA_ADVERTISED_LISTENERS: INTERNAL://kafka:29092,EXTERNAL://localhost:9092 - KAFKA_INTER_BROKER_LISTENER_NAME: INTERNAL - KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR: 1 - KAFKA_TRANSACTION_STATE_LOG_MIN_ISR: 1 - KAFKA_TRANSACTION_STATE_LOG_REPLICATION_FACTOR: 1 - volumes: - - kafka-data:/var/lib/kafka/data - volumes: mysql-data: redis-data: qdrant-data: - ollama-data: - kafka-data: \ No newline at end of file + ollama-data: \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java index df6efef..4cf9f06 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java @@ -1,9 +1,13 @@ package com.ai.lawyer.domain.chatbot.service; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatHistoryDto; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatLawDto; +import com.ai.lawyer.domain.chatbot.dto.ChatDto.ChatPrecedentDto; import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.KeywordExtractionDto; import com.ai.lawyer.domain.chatbot.dto.ExtractionDto.TitleExtractionDto; import com.ai.lawyer.domain.chatbot.entity.*; import com.ai.lawyer.domain.chatbot.repository.*; +import com.ai.lawyer.infrastructure.redis.service.ChatCacheService; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.memory.ChatMemory; @@ -16,6 +20,7 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; +import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -27,6 +32,8 @@ public class AsyncPostChatProcessingService { private final KeywordService keywordService; + private final ChatCacheService chatCacheService; + private final HistoryRepository historyRepository; private final ChatRepository chatRepository; private final KeywordRankRepository keywordRankRepository; @@ -98,6 +105,9 @@ private void extractAndUpdateKeywordRanks(String message) { } private void saveChatWithDocuments(History history, MessageType type, String message, List similarCaseDocuments, List similarLawDocuments) { + List chatPrecedents = new ArrayList<>(); + List chatLaws = new ArrayList<>(); + Chat chat = chatRepository.save(Chat.builder() .historyId(history) .type(type) @@ -107,7 +117,7 @@ private void saveChatWithDocuments(History history, MessageType type, String mes // Ai 메시지가 저장될 때 관련 문서 저장 if (type == MessageType.ASSISTANT) { if (similarCaseDocuments != null && !similarCaseDocuments.isEmpty()) { - List chatPrecedents = similarCaseDocuments.stream() + chatPrecedents = similarCaseDocuments.stream() .map(doc -> ChatPrecedent.builder() .chatId(chat) .precedentContent(doc.getText()) @@ -119,7 +129,7 @@ private void saveChatWithDocuments(History history, MessageType type, String mes } if (similarLawDocuments != null && !similarLawDocuments.isEmpty()) { - List chatLaws = similarLawDocuments.stream() + chatLaws = similarLawDocuments.stream() .map(doc -> ChatLaw.builder() .chatId(chat) .content(doc.getText()) @@ -129,5 +139,16 @@ private void saveChatWithDocuments(History history, MessageType type, String mes chatLawRepository.saveAll(chatLaws); } } + + // Redis 캐시에 DTO 저장 + ChatHistoryDto dto = ChatHistoryDto.builder() + .type(type.toString()) + .message(message) + .createdAt(chat.getCreatedAt()) + .precedent(chatPrecedents.isEmpty() ? null : ChatPrecedentDto.from(chatPrecedents.get(0))) + .law(chatLaws.isEmpty() ? null : ChatLawDto.from(chatLaws.get(0))) + .build(); + + chatCacheService.cacheChatMessage(history.getHistoryId(), dto); } } \ No newline at end of file diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java index 35ebad0..97481bd 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/ChatBotService.java @@ -7,8 +7,6 @@ import com.ai.lawyer.domain.chatbot.entity.History; import com.ai.lawyer.domain.chatbot.repository.HistoryRepository; import com.ai.lawyer.global.qdrant.service.QdrantService; -import com.ai.lawyer.infrastructure.kafka.dto.ChatPostProcessEvent; -import com.ai.lawyer.infrastructure.kafka.dto.DocumentDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.ai.chat.client.ChatClient; @@ -22,7 +20,6 @@ import org.springframework.ai.chat.prompt.PromptTemplate; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; -import org.springframework.kafka.core.KafkaTemplate; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import reactor.core.publisher.Flux; @@ -40,18 +37,14 @@ public class ChatBotService { private final ChatClient chatClient; private final QdrantService qdrantService; private final HistoryService historyService; + private final AsyncPostChatProcessingService asyncPostChatProcessingService; + private final HistoryRepository historyRepository; private final ChatMemoryRepository chatMemoryRepository; - // KafkaTemplate 주입 - private final KafkaTemplate kafkaTemplate; - @Value("${custom.ai.system-message}") private String systemMessageTemplate; - // Kafka 토픽 이름 -> 추후 application.yml로 이동 고려 - private static final String POST_PROCESSING_TOPIC = "chat-post-processing"; - // 핵심 로직 // 멤버 조회 -> 벡터 검색 -> 프롬프트 생성 -> LLM 호출 (스트림) -> Kafka 이벤트 발행 -> 응답 반환 @Transactional @@ -78,25 +71,7 @@ public Flux sendMessage(Long memberId, ChatRequest chatRequestDto, .content() .collectList() .map(fullResponseList -> String.join("", fullResponseList)) - .doOnNext(fullResponse -> { - - // Document를 DTO로 변환 - List caseDtos = similarCaseDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - List lawDtos = similarLawDocuments.stream().map(DocumentDto::from).collect(Collectors.toList()); - - // Kafka로 보낼 이벤트 객체 - ChatPostProcessEvent event = new ChatPostProcessEvent( - history.getHistoryId(), - chatRequestDto.getMessage(), - fullResponse, - caseDtos, - lawDtos - ); - - // Kafka 이벤트 발행 - kafkaTemplate.send(POST_PROCESSING_TOPIC, event); - - }) + .doOnNext(fullResponse -> asyncPostChatProcessingService.processHandlerTasks(history.getHistoryId(), chatRequestDto.getMessage(), fullResponse, similarCaseDocuments, similarLawDocuments)) .map(fullResponse -> createChatResponse(history, fullResponse, similarCaseDocuments, similarLawDocuments)) .flux() .onErrorResume(throwable -> { diff --git a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java index e5b53c4..ca54806 100644 --- a/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java +++ b/backend/src/main/java/com/ai/lawyer/infrastructure/kafka/consumer/ChatPostProcessingConsumer.java @@ -19,7 +19,6 @@ import org.springframework.ai.chat.messages.AssistantMessage; import org.springframework.ai.chat.messages.MessageType; import org.springframework.beans.factory.annotation.Value; -import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -47,7 +46,7 @@ public class ChatPostProcessingConsumer { @Value("${custom.ai.keyword-extraction}") private String keywordExtraction; - @KafkaListener(topics = "chat-post-processing", groupId = "chat-processing-group") + //@KafkaListener(topics = "chat-post-processing", groupId = "chat-processing-group") @Transactional public void consume(ChatPostProcessEvent event) { try { diff --git a/backend/src/main/resources/application-dev.yml b/backend/src/main/resources/application-dev.yml index 7a69790..fb41d61 100644 --- a/backend/src/main/resources/application-dev.yml +++ b/backend/src/main/resources/application-dev.yml @@ -17,18 +17,6 @@ spring: password: ${DEV_REDIS_PASSWORD} embedded: false - kafka: - bootstrap-servers: localhost:9092 - producer: - key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.springframework.kafka.support.serializer.JsonSerializer - consumer: - group-id: chat-processing-group # 컨슈머 그룹 ID - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.springframework.kafka.support.serializer.JsonDeserializer - properties: - spring.json.trusted.packages: "*" - batch: job: enabled: false # 최소 한번 시작 From cca63212b6ed86aaeaac91760681f11204be0e75 Mon Sep 17 00:00:00 2001 From: yongho9064 Date: Thu, 16 Oct 2025 21:16:30 +0900 Subject: [PATCH 26/28] =?UTF-8?q?chore[chat]:=20@Async=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D=20=ED=95=B4=EC=A0=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../domain/chatbot/service/AsyncPostChatProcessingService.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java index 4cf9f06..e478026 100644 --- a/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java +++ b/backend/src/main/java/com/ai/lawyer/domain/chatbot/service/AsyncPostChatProcessingService.java @@ -17,6 +17,7 @@ import org.springframework.ai.chat.messages.MessageType; import org.springframework.ai.document.Document; import org.springframework.beans.factory.annotation.Value; +import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -46,7 +47,7 @@ public class AsyncPostChatProcessingService { @Value("{$custom.ai.keyword-extraction}") private String keywordExtraction; - //@Async + @Async @Transactional public void processHandlerTasks(Long historyId, String userMessage, String fullResponse, List similarCaseDocuments, List similarLawDocuments) { try { From a307f6bccee05ff6eeb29f5da0d8401867f62817 Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Fri, 17 Oct 2025 11:23:53 +0900 Subject: [PATCH 27/28] =?UTF-8?q?chore[infra]=20:=20kafka=20=EC=A3=BC?= =?UTF-8?q?=EC=84=9D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- infra/main.tf | 43 ++++++++++++++++++++++++------------------- 1 file changed, 24 insertions(+), 19 deletions(-) diff --git a/infra/main.tf b/infra/main.tf index 8d9fb65..02a5197 100644 --- a/infra/main.tf +++ b/infra/main.tf @@ -419,25 +419,30 @@ docker run -d \ -c 'ollama serve & sleep 5 && ollama pull daynice/kure-v1:567m && wait' echo "${var.github_access_token_1}" | docker login ghcr.io -u ${var.github_access_token_1_owner} --password-stdin -# zookeeper 설치 -docker run -d \ - --name zookeeper \ - --network common \ - -p 2181:2181 \ - -e ALLOW_ANONYMOUS_LOGIN=yes \ - bitnami/zookeeper:latest - -# kafka -docker run -d \ - --name kafka \ - --network common \ - -p 9092:9092 \ - -e KAFKA_BROKER_ID=1 \ - -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ - -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ - -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ - -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ - confluentinc/cp-kafka:7.6.0 +# # zookeeper 설치 (카프카용, 필요시 주석 해제) +# docker run -d \ +# --name zookeeper \ +# --restart unless-stopped \ +# --network common \ +# -p 2181:2181 \ +# -e ZOOKEEPER_CLIENT_PORT=2181 \ +# -e ZOOKEEPER_TICK_TIME=2000 \ +# confluentinc/cp-zookeeper:7.8.0 +# +# # kafka +# docker run -d \ +# --name kafka \ +# --restart unless-stopped \ +# --network common \ +# -p 9092:9092 \ +# -v kafka-data:/var/lib/kafka/data \ +# -e KAFKA_BROKER_ID=1 \ +# -e KAFKA_ZOOKEEPER_CONNECT=zookeeper:2181 \ +# -e KAFKA_LISTENER_SECURITY_PROTOCOL_MAP=PLAINTEXT:PLAINTEXT \ +# -e KAFKA_LISTENERS=PLAINTEXT://0.0.0.0:9092 \ +# -e KAFKA_ADVERTISED_LISTENERS=PLAINTEXT://kafka:9092 \ +# -e KAFKA_OFFSETS_TOPIC_REPLICATION_FACTOR=1 \ +# confluentinc/cp-kafka:7.8.0 From 082e4e8984ec6e76407dc03463bc4c4bed3b6ade Mon Sep 17 00:00:00 2001 From: DooHyoJeong Date: Fri, 17 Oct 2025 11:24:10 +0900 Subject: [PATCH 28/28] =?UTF-8?q?chore[infra]=20:=20.test=EC=9A=A9=20env?= =?UTF-8?q?=20=ED=8C=8C=EC=9D=BC=20=EC=84=A4=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .github/workflows/CI-CD_Pipeline.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/CI-CD_Pipeline.yml b/.github/workflows/CI-CD_Pipeline.yml index 3c89809..4999811 100644 --- a/.github/workflows/CI-CD_Pipeline.yml +++ b/.github/workflows/CI-CD_Pipeline.yml @@ -463,7 +463,7 @@ jobs: # prod.env 복원 install -d -m 700 /home/ec2-user/configs - printf "%s" "${{ secrets.PROD_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env + printf "%s" "${{ secrets.PROD_TEST_ENV_BASE64 }}" | base64 -d > /home/ec2-user/configs/prod.env chmod 600 /home/ec2-user/configs/prod.env test -s /home/ec2-user/configs/prod.env || { echo "prod.env empty"; exit 1; }