diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml index 8a18acd..39c0a17 100644 --- a/.github/workflows/deploy.yml +++ b/.github/workflows/deploy.yml @@ -78,3 +78,4 @@ jobs: service: howru-backend-service cluster: howr-final-cluster wait-for-service-stability: true + force-new-deployment: true # ✅ 강제 배포 옵션 추가 diff --git a/build.gradle b/build.gradle index 7d3177d..d48dcd7 100644 --- a/build.gradle +++ b/build.gradle @@ -30,9 +30,8 @@ dependencies { // 🍃 NoSQL - MongoDB implementation 'org.springframework.boot:spring-boot-starter-data-mongodb' - // 🚀 Redis (동기식 Pub/Sub + 세션) + // 🚀 Redis (동기식 Pub/Sub + 캐싱) implementation 'org.springframework.boot:spring-boot-starter-data-redis' - implementation 'org.springframework.session:spring-session-data-redis' //🔐 Security + OAuth2 implementation 'org.springframework.boot:spring-boot-starter-security' diff --git a/docker-compose.yml b/docker-compose.yml index ad9a04d..47aeb9a 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -34,13 +34,22 @@ services: libretranslate: image: libretranslate/libretranslate:latest restart: unless-stopped + user: "0:0" # ← root 로 실행(볼륨 쓰기 권한 문제 해결) ports: - - "5001:5000" # 호스트 5001 → 컨테이너 5000 + - "5001:5000" # host 5001 -> container 5000 + environment: + LT_LOAD_ONLY: "en,ko" # en/ko만 사용 + LT_PRELOAD: "true" # 기동 시 en<->ko 모델 미리 로드 + LT_UPDATE_MODELS: "true" + command: ["--host", "0.0.0.0", "--port", "5000"] + volumes: + - lt_models:/home/libretranslate/.local/share/argos-translate healthcheck: - test: ["CMD-SHELL", "curl -f http://localhost:5001/ || exit 1"] - interval: 10s + test: ["CMD-SHELL", "curl -fsS http://localhost:5000/languages | grep -q '\"code\":\"ko\"' && grep -q '\"code\":\"en\"'"] + interval: 20s timeout: 5s - retries: 3 + retries: 10 + spacy-api: build: @@ -110,6 +119,8 @@ services: volumes: pgdata: redisdata: + lt_models: + networks: kafka-net: \ No newline at end of file diff --git a/infra/taskdef.json b/infra/taskdef.json index b9a2b94..0b28ea4 100644 --- a/infra/taskdef.json +++ b/infra/taskdef.json @@ -2,8 +2,8 @@ "family": "howru-backend", "networkMode": "awsvpc", "requiresCompatibilities": ["EC2"], - "cpu": "512", - "memory": "1024", + "cpu": "1024", + "memory": "4096", "executionRoleArn": "arn:aws:iam::257976284139:role/ecsTaskExecutionRole", "containerDefinitions": [ { diff --git a/src/main/java/org/example/howareyou/domain/auth/controller/AuthController.java b/src/main/java/org/example/howareyou/domain/auth/controller/AuthController.java index dd7e97e..6d2342a 100644 --- a/src/main/java/org/example/howareyou/domain/auth/controller/AuthController.java +++ b/src/main/java/org/example/howareyou/domain/auth/controller/AuthController.java @@ -47,13 +47,15 @@ public ResponseEntity login( @Operation( summary = "토큰 갱신", - description = "membername과 리프레시 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. " + + description = "리프레시 토큰과 만료된 액세스 토큰을 사용하여 새로운 액세스 토큰을 발급받습니다. " + "리프레시 토큰은 쿠키에서 자동으로 읽어집니다." ) @PostMapping("/refresh") public ResponseEntity refresh( @Parameter(description = "리프레시 토큰 (쿠키에서 자동 읽기)", hidden = true) - @CookieValue(value = "Refresh", required = false) String refreshToken, + @CookieValue(value = "Refresh", required = false) String refreshToken, + @Parameter(description = "만료된 액세스 토큰", required = true) + @RequestHeader("X-Expired-Access-Token") String expiredAccessToken, HttpServletRequest request, HttpServletResponse response ) { @@ -61,7 +63,7 @@ public ResponseEntity refresh( throw new CustomException(ErrorCode.AUTH_REFRESH_TOKEN_NOT_FOUND); } - TokenBundle tokenBundle = authService.refreshToken(refreshToken); + TokenBundle tokenBundle = authService.refreshToken(refreshToken, expiredAccessToken); response.setHeader("Authorization", "Bearer " + tokenBundle.access()); return ResponseEntity.ok(tokenBundle); diff --git a/src/main/java/org/example/howareyou/domain/auth/dto/TokenBundle.java b/src/main/java/org/example/howareyou/domain/auth/dto/TokenBundle.java index 2f90735..05c0aed 100644 --- a/src/main/java/org/example/howareyou/domain/auth/dto/TokenBundle.java +++ b/src/main/java/org/example/howareyou/domain/auth/dto/TokenBundle.java @@ -1,6 +1,11 @@ package org.example.howareyou.domain.auth.dto; -/** access / refresh / 프로필완료 플래그 */ +/** + * 인증 토큰 번들 + * @param access 액세스 토큰 + * @param refresh 리프레시 토큰 + * @param completed 프로필 완료 여부 (membername + 필수 프로필 정보 모두 완료) + */ public record TokenBundle( String access, String refresh, diff --git a/src/main/java/org/example/howareyou/domain/auth/entity/Auth.java b/src/main/java/org/example/howareyou/domain/auth/entity/Auth.java index e76f35c..d2c878e 100644 --- a/src/main/java/org/example/howareyou/domain/auth/entity/Auth.java +++ b/src/main/java/org/example/howareyou/domain/auth/entity/Auth.java @@ -42,6 +42,7 @@ public class Auth extends BaseEntity { private String providerUserId; // 리프레시 토큰 + @Column(name = "refresh_token", columnDefinition = "text") private String refreshToken; // 리프레시 토큰 만료 시간 diff --git a/src/main/java/org/example/howareyou/domain/auth/oauth2/handler/OAuth2SuccessHandler.java b/src/main/java/org/example/howareyou/domain/auth/oauth2/handler/OAuth2SuccessHandler.java index 8bbe84f..4070dd1 100644 --- a/src/main/java/org/example/howareyou/domain/auth/oauth2/handler/OAuth2SuccessHandler.java +++ b/src/main/java/org/example/howareyou/domain/auth/oauth2/handler/OAuth2SuccessHandler.java @@ -74,9 +74,17 @@ public void onAuthenticationSuccess(HttpServletRequest req, boolean isSecure = !"dev".equals(activeProfile); res.addCookie(CookieUtils.refresh(t.refresh(), isSecure)); - // 3-2) 목적지 결정 (환경에 따라 다름) + // 3-2) 목적지 결정 (프로필 완료 상태에 따라) String redirectUrl; - String path = t.completed() ? "/login/success" : "/signup/membername"; + String path; + + if (t.completed()) { + // 프로필 완료 → 로그인 성공 페이지 + path = "/login/success"; + } else { + // 프로필 미완료 → membername 설정 페이지 + path = "/signup/membername"; + } redirectUrl = UriComponentsBuilder .fromUriString(frontUrl) @@ -84,6 +92,7 @@ public void onAuthenticationSuccess(HttpServletRequest req, .queryParam("oauth_success", "true") .queryParam("provider", provider.name().toLowerCase()) .queryParam("profile_completed", String.valueOf(t.completed())) + .queryParam("access_token", t.access()) // Access Token 추가 .build().toUriString(); log.info("🔄 OAuth2 리다이렉트: {}", redirectUrl); diff --git a/src/main/java/org/example/howareyou/domain/auth/oauth2/processor/GoogleLoginProcessor.java b/src/main/java/org/example/howareyou/domain/auth/oauth2/processor/GoogleLoginProcessor.java index 6364b3d..4a3e0ca 100644 --- a/src/main/java/org/example/howareyou/domain/auth/oauth2/processor/GoogleLoginProcessor.java +++ b/src/main/java/org/example/howareyou/domain/auth/oauth2/processor/GoogleLoginProcessor.java @@ -57,17 +57,10 @@ public TokenBundle process(OAuth2User oAuth2User, HttpServletRequest request) { // 2. 마지막 로그인 시간 갱신 auth.updateLastLoginInfo(UserAgentUtils.getClientIP(request)); - // 3. 토큰 발급 (membername이 있으면 membername 기반, 없으면 email 기반) - String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername()); - String refreshToken; - - if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) { - // membername이 있으면 membername 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername()); - } else { - // membername이 없으면 email 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail()); - } + // 3. 토큰 발급 + // Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반 + String accessToken = jwtTokenProvider.createAccessToken(auth.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId()); Instant refreshTokenExpiry = Instant.now().plus(Duration.ofMillis(jwtTokenProvider.getRefreshTokenExpirationTime())); diff --git a/src/main/java/org/example/howareyou/domain/auth/repository/AuthRepository.java b/src/main/java/org/example/howareyou/domain/auth/repository/AuthRepository.java index 63abcc7..a92d971 100644 --- a/src/main/java/org/example/howareyou/domain/auth/repository/AuthRepository.java +++ b/src/main/java/org/example/howareyou/domain/auth/repository/AuthRepository.java @@ -32,5 +32,10 @@ public interface AuthRepository extends JpaRepository { // 리프레시 토큰으로 인증 정보 조회 Optional findByRefreshToken(String refreshToken); + /** + * Auth와 Member 정보를 함께 조회 (LazyInitializationException 방지) + */ + @Query("SELECT a FROM Auth a JOIN FETCH a.member WHERE a.id = :id") + Optional findByIdWithMember(@Param("id") Long id); } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/domain/auth/service/AuthService.java b/src/main/java/org/example/howareyou/domain/auth/service/AuthService.java index 45f7655..0bbfc08 100644 --- a/src/main/java/org/example/howareyou/domain/auth/service/AuthService.java +++ b/src/main/java/org/example/howareyou/domain/auth/service/AuthService.java @@ -51,16 +51,9 @@ public TokenBundle login(String email, String password, HttpServletRequest reque } // 3. 토큰 생성 및 사용자 캐싱 - String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername()); - String refreshToken; - - if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) { - // membername이 있으면 membername 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername()); - } else { - // membername이 없으면 email 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail()); - } + // Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반 + String accessToken = jwtTokenProvider.createAccessToken(auth.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId()); // 4. 리프레시 토큰 저장 Instant refreshTokenExpiry = Instant.now() @@ -83,17 +76,10 @@ public TokenBundle socialLogin(Provider provider, String email, String providerU Auth auth = authRepository.findByProviderAndProviderUserId(provider, providerUserId) .orElseThrow(() -> new CustomException(ErrorCode.AUTH_SOCIAL_ACCOUNT_NOT_FOUND)); - // 2. 토큰 생성 (membername이 있으면 membername 기반, 없으면 email 기반) - String accessToken = jwtTokenProvider.createAccessToken(auth.getMember().getMembername()); - String refreshToken; - - if (auth.getMember().getMembername() != null && !auth.getMember().getMembername().isEmpty()) { - // membername이 있으면 membername 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithMembername(auth.getMember().getMembername()); - } else { - // membername이 없으면 email 기반 Refresh Token - refreshToken = jwtTokenProvider.createRefreshTokenWithEmail(auth.getMember().getEmail()); - } + // 2. 토큰 생성 + // Access Token은 Auth ID 기반, Refresh Token은 Member ID 기반 + String accessToken = jwtTokenProvider.createAccessToken(auth.getId()); + String refreshToken = jwtTokenProvider.createRefreshToken(auth.getMember().getId()); // 3. 리프레시 토큰 갱신 Instant refreshTokenExpiry = Instant.now() @@ -125,7 +111,7 @@ public void logout(Long memberId) { * 액세스 토큰 재발급 */ @Transactional - public TokenBundle refreshToken(String refreshToken) { + public TokenBundle refreshToken(String refreshToken, String expiredAccessToken) { // 1. 리프레시 토큰으로 인증 정보 조회 Auth auth = authRepository.findByRefreshToken(refreshToken) .orElseThrow(() -> new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN)); @@ -135,35 +121,24 @@ public TokenBundle refreshToken(String refreshToken) { throw new CustomException(ErrorCode.AUTH_EXPIRED_REFRESH_TOKEN); } - // 3. 토큰에서 사용자 식별자 추출 및 검증 - String tokenIdentifier = jwtTokenProvider.getIdentifierFromRefreshToken(refreshToken); - String tokenIdentifierType = jwtTokenProvider.getIdentifierTypeFromRefreshToken(refreshToken); - - if (tokenIdentifier == null || tokenIdentifierType == null) { - throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN); - } - - // 4. 식별자 타입에 따른 검증 - Member member = auth.getMember(); - boolean isValid = false; - - if ("email".equals(tokenIdentifierType)) { - isValid = tokenIdentifier.equals(member.getEmail()); - } else if ("membername".equals(tokenIdentifierType)) { - isValid = tokenIdentifier.equals(member.getMembername()); - } - - if (!isValid) { - log.warn("Refresh token identifier mismatch. Token: {}, DB: {}", - tokenIdentifier, "email".equals(tokenIdentifierType) ? member.getEmail() : member.getMembername()); + // 3. 만료된 Access Token에서 Auth ID 추출 및 검증 + try { + Long tokenAuthId = jwtTokenProvider.getAuthIdFromAccessToken(expiredAccessToken); + if (!tokenAuthId.equals(auth.getId())) { + log.warn("Access token auth ID mismatch. Token: {}, DB: {}", + tokenAuthId, auth.getId()); + throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN); + } + } catch (Exception e) { + log.warn("Failed to extract auth ID from expired access token: {}", e.getMessage()); throw new CustomException(ErrorCode.AUTH_INVALID_REFRESH_TOKEN); } - // 5. 새로운 액세스 토큰 발급 (membername 사용) - String newAccessToken = jwtTokenProvider.createAccessToken(member.getMembername()); + // 4. 새로운 액세스 토큰 발급 (Auth ID 기반) + String newAccessToken = jwtTokenProvider.createAccessToken(auth.getId()); + log.info("Access token refreshed for auth ID: {}", auth.getId()); - log.info("Access token refreshed for user: {}", member.getMembername()); - return new TokenBundle(newAccessToken, refreshToken, member.isProfileCompleted()); + return new TokenBundle(newAccessToken, refreshToken, auth.getMember().isProfileCompleted()); } /** @@ -198,7 +173,7 @@ public String refresh(String refreshToken) { } // 3. 새 AccessToken 발급 - String userId = auth.getMember().getId().toString(); + Long userId = auth.getMember().getId(); return jwtTokenProvider.createAccessToken(userId); } diff --git a/src/main/java/org/example/howareyou/domain/chat/websocket/config/WebSocketConfig.java b/src/main/java/org/example/howareyou/domain/chat/websocket/config/WebSocketConfig.java index c945507..4424359 100644 --- a/src/main/java/org/example/howareyou/domain/chat/websocket/config/WebSocketConfig.java +++ b/src/main/java/org/example/howareyou/domain/chat/websocket/config/WebSocketConfig.java @@ -107,15 +107,15 @@ public Message preSend(Message message, MessageChannel channel) { try { log.info("🔑 JWT 토큰 검증 시작"); // JWT 토큰 검증 및 사용자 정보 추출 - String userId = jwtTokenProvider.validateAndGetSubject(token); - log.info("✅ JWT 토큰 검증 성공: userId={}", userId); + Long authId = jwtTokenProvider.getAuthIdFromAccessToken(token); + log.info("✅ JWT 토큰 검증 성공: authId={}", authId); // CustomMemberDetails 생성 - CustomMemberDetails userDetails = (CustomMemberDetails) customMemberDetailsService.loadUserByUsername(userId); + CustomMemberDetails userDetails = (CustomMemberDetails) customMemberDetailsService.loadUserByUsername(authId.toString()); Authentication auth = new UsernamePasswordAuthenticationToken(userDetails, null, userDetails.getAuthorities()); accessor.setUser(auth); - log.info("✅ WebSocket CONNECT 인증 성공: userId={}, membername={}", userId, userDetails.getMembername()); + log.info("✅ WebSocket CONNECT 인증 성공: authId={}, membername={}", authId, userDetails.getMembername()); } catch (Exception e) { log.error("❌ WebSocket CONNECT 인증 실패: {}", e.getMessage(), e); // 인증 실패 시 연결 거부 diff --git a/src/main/java/org/example/howareyou/domain/chat/websocket/controller/ChatController.java b/src/main/java/org/example/howareyou/domain/chat/websocket/controller/ChatController.java index 16cef36..84c529b 100644 --- a/src/main/java/org/example/howareyou/domain/chat/websocket/controller/ChatController.java +++ b/src/main/java/org/example/howareyou/domain/chat/websocket/controller/ChatController.java @@ -10,6 +10,7 @@ import org.example.howareyou.domain.chat.entity.ChatRoom; import org.example.howareyou.domain.chat.repository.ChatRoomRepository; +import org.example.howareyou.domain.chat.repository.ChatRoomMemberRepository; import org.example.howareyou.domain.chat.websocket.dto.ChatEnterDTO; import org.example.howareyou.domain.chat.websocket.dto.ChatMessageResponse; import org.example.howareyou.domain.chat.websocket.dto.CreateChatMessageRequest; @@ -28,10 +29,12 @@ import org.springframework.security.core.Authentication; import org.springframework.security.core.annotation.AuthenticationPrincipal; import org.springframework.web.bind.annotation.RestController; +import org.springframework.transaction.annotation.Transactional; @RestController @RequiredArgsConstructor @Slf4j +@Transactional @Tag(name = "WebSocket 채팅", description = "STOMP 기반 실시간 채팅 메시지 처리") public class ChatController { @@ -39,6 +42,7 @@ public class ChatController { private final ChatMessageService chatMessageService; private final ChatRedisService chatRedisService; private final ChatRoomRepository chatRoomRepository; + private final ChatRoomMemberRepository chatRoomMemberRepository; private final ChatMemberTracker chatMemberTracker; /** @@ -140,8 +144,8 @@ public void enterRoom( String chatRoomId = dto.getChatRoomId(); - // 입장 제한 로직 - ChatRoom chatRoom = chatRoomRepository.findByUuid(chatRoomId) + // 입장 제한 로직 - members를 eager fetch로 로드 + ChatRoom chatRoom = chatRoomMemberRepository.findByUuidWithMembers(chatRoomId) .orElseThrow(() -> new CustomException(ErrorCode.CHAT_ROOM_NOT_FOUND)); Long memberId = Long.parseLong(userId); diff --git a/src/main/java/org/example/howareyou/domain/member/entity/Member.java b/src/main/java/org/example/howareyou/domain/member/entity/Member.java index 9e92d38..3b1b1e0 100644 --- a/src/main/java/org/example/howareyou/domain/member/entity/Member.java +++ b/src/main/java/org/example/howareyou/domain/member/entity/Member.java @@ -126,6 +126,16 @@ public boolean isProfileCompleted() { return this.profile != null && this.profile.isCompleted(); } + /** membername 설정 여부 */ + public boolean hasMembername() { + return this.membername != null && !this.membername.trim().isEmpty() && !this.membername.startsWith("temp_"); + } + + /** 프로필 설정 가능 여부 (membername이 설정되어야 프로필 설정 가능) */ + public boolean canSetupProfile() { + return hasMembername(); + } + /** 계정 삭제(soft-delete) */ public void deleteAccount() { this.active = false; diff --git a/src/main/java/org/example/howareyou/domain/member/entity/MemberProfile.java b/src/main/java/org/example/howareyou/domain/member/entity/MemberProfile.java index fb0b846..a724719 100644 --- a/src/main/java/org/example/howareyou/domain/member/entity/MemberProfile.java +++ b/src/main/java/org/example/howareyou/domain/member/entity/MemberProfile.java @@ -24,6 +24,7 @@ @Entity @Table(name = "member_profiles") @Getter +@Setter @NoArgsConstructor(access = AccessLevel.PROTECTED) @AllArgsConstructor(access = AccessLevel.PRIVATE) @Builder diff --git a/src/main/java/org/example/howareyou/domain/member/repository/MemberRepository.java b/src/main/java/org/example/howareyou/domain/member/repository/MemberRepository.java index bdc29f4..73c0347 100644 --- a/src/main/java/org/example/howareyou/domain/member/repository/MemberRepository.java +++ b/src/main/java/org/example/howareyou/domain/member/repository/MemberRepository.java @@ -18,7 +18,33 @@ public interface MemberRepository extends JpaRepository{ Optional findById(Long id); Optional findByMembername(String membername); + Optional findByEmail(String email); + + /** + * Member 기본 정보를 fetch join으로 조회 (LazyInitializationException 방지) + */ + @Query("SELECT m FROM Member m WHERE m.id = :id") + Optional findByIdForAuth(@Param("id") Long id); + + /** + * Member 기본 정보를 fetch join으로 조회 (LazyInitializationException 방지) + */ + @Query("SELECT m FROM Member m WHERE m.email = :email") + Optional findByEmailForAuth(@Param("email") String email); + + /** + * Member 기본 정보를 fetch join으로 조회 (LazyInitializationException 방지) + */ + @Query("SELECT m FROM Member m WHERE m.membername = :membername") + Optional findByMembernameForAuth(@Param("membername") String membername); + boolean existsByMembername(String Membername); + + /** + * 이메일에 특정 문자열이 포함된 사용자들을 조회 + */ + List findByEmailContaining(String email); + List findDistinctByProfileInterestsInAndIdNot(Set interests, Long excludeId); Long getIdByMembername(String membername); @@ -92,4 +118,5 @@ List findByInterestsContainingAll( where m.active = true """) Page findAllActiveProfilesForVoca(Pageable pageable); + } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/domain/member/service/MemberServiceImpl.java b/src/main/java/org/example/howareyou/domain/member/service/MemberServiceImpl.java index b8866ec..541fd21 100644 --- a/src/main/java/org/example/howareyou/domain/member/service/MemberServiceImpl.java +++ b/src/main/java/org/example/howareyou/domain/member/service/MemberServiceImpl.java @@ -23,6 +23,7 @@ import org.example.howareyou.global.util.CookieUtils; import org.example.howareyou.global.exception.CustomException; import org.example.howareyou.global.exception.ErrorCode; +import org.example.howareyou.domain.recommendationtag.service.RecommendationTagService; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; @@ -45,6 +46,7 @@ public class MemberServiceImpl implements MemberService { private final MemberCacheService memberCacheService; private final AuthRepository authRepository; private final JwtTokenProvider jwtTokenProvider; + private final RecommendationTagService recommendationTagService; /* ---------- 프로필 ---------- */ @@ -75,6 +77,15 @@ public ProfileResponse updateMyProfile(Long id, @Valid ProfileCreateRequest r) { if (!p.isCompleted()) p.completeProfile(); memberCacheService.cache(m); // 캐시 동기화 + // 프로필 관심사 기반 자동 태깅 생성 + try { + recommendationTagService.createOrUpdateMemberTagScores(id, r.getInterests()); + log.info("프로필 업데이트 시 자동 태깅 생성 완료: memberId={}, interests={}", id, r.getInterests()); + } catch (Exception e) { + log.error("프로필 업데이트 시 자동 태깅 생성 실패: memberId={}", id, e); + // 태깅 실패해도 프로필 업데이트는 계속 진행 + } + return ProfileResponse.from(p); } @@ -98,21 +109,8 @@ public MembernameResponse setMembername(Long id, MembernameRequest req, HttpServ .orElseThrow(() -> new CustomException(ErrorCode.MEMBER_NOT_FOUND)); m.setMembername(req.membername()); - // membername 설정 후 Refresh Token을 membername 기반으로 재발급 - Auth auth = authRepository.findByMemberId(id) - .orElseThrow(() -> new CustomException(ErrorCode.AUTH_BAD_CREDENTIAL)); - - // 새로운 Refresh Token 생성 (membername 기반) - String newRefreshToken = jwtTokenProvider.createRefreshTokenWithMembername(req.membername()); - - // Refresh Token 갱신 - Instant refreshTokenExpiry = Instant.now() - .plus(Duration.ofMillis(jwtTokenProvider.getRefreshTokenExpirationTime())); - auth.setRefreshToken(newRefreshToken, refreshTokenExpiry); - - // 새로운 쿠키 설정 - boolean isSecure = !"dev".equals(System.getProperty("spring.profiles.active", "dev")); - res.addCookie(CookieUtils.refresh(newRefreshToken, isSecure)); + // 멤버네임만 설정하고 토큰은 건드리지 않음 + // Access Token은 Auth ID 기반, Refresh Token은 UUID 기반으로 유지 return MembernameResponse.from(m); // dirty-checking flush } diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/config/MigrationConfig.java b/src/main/java/org/example/howareyou/domain/recommendationtag/config/MigrationConfig.java new file mode 100644 index 0000000..9fe1541 --- /dev/null +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/config/MigrationConfig.java @@ -0,0 +1,37 @@ +package org.example.howareyou.domain.recommendationtag.config; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.howareyou.domain.recommendationtag.service.MemberTagScoreMigrationService; +import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.event.ContextRefreshedEvent; +import org.springframework.context.event.EventListener; + +/** + * 애플리케이션 시작 시 자동 마이그레이션 설정 + * application.yml에서 migration.auto.enabled=true로 설정하면 활성화 + */ +@Slf4j +@Configuration +@RequiredArgsConstructor +@ConditionalOnProperty(name = "migration.auto.enabled", havingValue = "true") +public class MigrationConfig { + + private final MemberTagScoreMigrationService migrationService; + + /** + * 애플리케이션 시작 완료 후 자동 마이그레이션 실행 + */ + @EventListener(ContextRefreshedEvent.class) + public void onApplicationStart() { + log.info("🚀 애플리케이션 시작 완료. 자동 마이그레이션을 시작합니다..."); + + try { + var result = migrationService.migrateAllUsers(); + log.info("✅ 자동 마이그레이션 완료: {}", result); + } catch (Exception e) { + log.error("❌ 자동 마이그레이션 실패", e); + } + } +} diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/controller/MigrationController.java b/src/main/java/org/example/howareyou/domain/recommendationtag/controller/MigrationController.java new file mode 100644 index 0000000..757d0ee --- /dev/null +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/controller/MigrationController.java @@ -0,0 +1,75 @@ +package org.example.howareyou.domain.recommendationtag.controller; + +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.example.howareyou.domain.recommendationtag.service.MemberTagScoreMigrationService; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.web.bind.annotation.*; + +/** + * MemberTagScore 마이그레이션을 위한 컨트롤러 + * 개발/운영 환경에서만 사용 + */ +@Slf4j +@RestController +@RequiredArgsConstructor +@RequestMapping("/api/admin/migration") +@Tag(name = "마이그레이션", description = "MemberTagScore 마이그레이션 관리") +public class MigrationController { + + private final MemberTagScoreMigrationService migrationService; + + @Operation( + summary = "전체 사용자 MemberTagScore 마이그레이션", + description = "모든 기존 사용자에 대해 MemberTagScore를 생성합니다." + ) + @PostMapping("/member-tag-scores") + @PreAuthorize("hasRole('ADMIN')") // 관리자만 접근 가능 + public ResponseEntity migrateAllUsers() { + log.info("🔧 전체 사용자 MemberTagScore 마이그레이션 요청"); + + try { + MemberTagScoreMigrationService.MigrationResult result = migrationService.migrateAllUsers(); + return ResponseEntity.ok(result); + } catch (Exception e) { + log.error("마이그레이션 실행 중 오류 발생", e); + return ResponseEntity.internalServerError().build(); + } + } + + @Operation( + summary = "특정 사용자 MemberTagScore 마이그레이션", + description = "지정된 사용자 ID에 대해 MemberTagScore를 생성합니다." + ) + @PostMapping("/member-tag-scores/{memberId}") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity migrateUser(@PathVariable Long memberId) { + log.info("🔧 사용자 {} MemberTagScore 마이그레이션 요청", memberId); + + try { + boolean success = migrationService.migrateUserById(memberId); + if (success) { + return ResponseEntity.ok("사용자 " + memberId + " 마이그레이션 완료"); + } else { + return ResponseEntity.ok("사용자 " + memberId + "는 이미 마이그레이션되었습니다"); + } + } catch (Exception e) { + log.error("사용자 {} 마이그레이션 중 오류 발생", memberId, e); + return ResponseEntity.internalServerError() + .body("마이그레이션 실패: " + e.getMessage()); + } + } + + @Operation( + summary = "마이그레이션 상태 확인", + description = "현재 마이그레이션 상태를 확인합니다." + ) + @GetMapping("/status") + @PreAuthorize("hasRole('ADMIN')") + public ResponseEntity getMigrationStatus() { + return ResponseEntity.ok("마이그레이션 서비스가 정상적으로 실행 중입니다."); + } +} diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/controller/RecommendationTagController.java b/src/main/java/org/example/howareyou/domain/recommendationtag/controller/RecommendationTagController.java index aef9db4..b41a965 100644 --- a/src/main/java/org/example/howareyou/domain/recommendationtag/controller/RecommendationTagController.java +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/controller/RecommendationTagController.java @@ -45,7 +45,7 @@ public TagScoresResponse classifyMember(@PathVariable @Min(1) long memberId) { } @GetMapping("/recommend") - public List recommendMembers( + public List recommendMembers( @AuthenticationPrincipal CustomMemberDetails memberDetails, @RequestParam(defaultValue = "5") int topN) { diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/service/MemberTagScoreMigrationService.java b/src/main/java/org/example/howareyou/domain/recommendationtag/service/MemberTagScoreMigrationService.java new file mode 100644 index 0000000..d004d0e --- /dev/null +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/service/MemberTagScoreMigrationService.java @@ -0,0 +1,135 @@ +package org.example.howareyou.domain.recommendationtag.service; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.example.howareyou.domain.member.entity.Member; +import org.example.howareyou.domain.member.entity.MemberTag; +import org.example.howareyou.domain.member.repository.MemberRepository; +import org.example.howareyou.domain.recommendationtag.entity.MemberTagScore; +import org.example.howareyou.domain.recommendationtag.repository.MemberTagScoreRepository; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import java.util.List; +import java.util.Set; + +/** + * 기존 사용자들의 MemberTagScore를 생성하는 마이그레이션 서비스 + */ +@Slf4j +@Service +@RequiredArgsConstructor +public class MemberTagScoreMigrationService { + + private final MemberRepository memberRepository; + private final MemberTagScoreRepository memberTagScoreRepository; + private final RecommendationTagService recommendationTagService; + + /** + * 모든 기존 사용자에 대해 MemberTagScore 생성/마이그레이션 + */ + @Transactional + public MigrationResult migrateAllUsers() { + log.info("🚀 전체 사용자 MemberTagScore 마이그레이션 시작"); + + List allMembers = memberRepository.findAll(); + int totalUsers = allMembers.size(); + int successCount = 0; + int skipCount = 0; + int errorCount = 0; + + for (Member member : allMembers) { + try { + if (migrateUser(member)) { + successCount++; + } else { + skipCount++; + } + } catch (Exception e) { + log.error("사용자 마이그레이션 실패: memberId={}, error={}", member.getId(), e.getMessage(), e); + errorCount++; + } + } + + MigrationResult result = new MigrationResult(totalUsers, successCount, skipCount, errorCount); + + log.info("✅ 마이그레이션 완료: {}", result); + return result; + } + + /** + * 특정 사용자에 대해 MemberTagScore 생성/마이그레이션 + */ + @Transactional + public boolean migrateUser(Member member) { + Long memberId = member.getId(); + + // 이미 MemberTagScore가 있는지 확인 + List existingScores = memberTagScoreRepository.findByMemberId(memberId); + if (!existingScores.isEmpty()) { + log.debug("사용자 {}는 이미 MemberTagScore가 존재합니다. 건너뜁니다.", memberId); + return false; + } + + // 프로필에서 관심사 가져오기 + Set interests = member.getProfile() != null ? + member.getProfile().getInterests() : Set.of(); + + if (interests.isEmpty()) { + log.debug("사용자 {}의 관심사가 설정되지 않았습니다. 기본 태그로 초기화합니다.", memberId); + // 기본 태그들로 초기화 (가장 일반적인 관심사들) + interests = Set.of( + MemberTag.TECHNOLOGY, + MemberTag.MUSIC, + MemberTag.FOOD, + MemberTag.TRAVEL + ); + } + + // MemberTagScore 생성 + recommendationTagService.createOrUpdateMemberTagScores(memberId, interests); + + log.info("사용자 {} 마이그레이션 완료: {}개 관심사", memberId, interests.size()); + return true; + } + + /** + * 특정 사용자 ID로 마이그레이션 + */ + @Transactional + public boolean migrateUserById(Long memberId) { + Member member = memberRepository.findById(memberId) + .orElseThrow(() -> new IllegalArgumentException("사용자를 찾을 수 없습니다: " + memberId)); + + return migrateUser(member); + } + + /** + * 마이그레이션 결과 DTO + */ + public static class MigrationResult { + private final int totalUsers; + private final int successCount; + private final int skipCount; + private final int errorCount; + + public MigrationResult(int totalUsers, int successCount, int skipCount, int errorCount) { + this.totalUsers = totalUsers; + this.successCount = successCount; + this.skipCount = skipCount; + this.errorCount = errorCount; + } + + // Getters + public int getTotalUsers() { return totalUsers; } + public int getSuccessCount() { return successCount; } + public int getSkipCount() { return skipCount; } + public int getErrorCount() { return errorCount; } + + @Override + public String toString() { + return String.format("전체: %d, 성공: %d, 건너뜀: %d, 실패: %d", + totalUsers, successCount, skipCount, errorCount); + } + } +} diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationMemberService.java b/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationMemberService.java index 29c6d8a..c83eeb8 100644 --- a/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationMemberService.java +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationMemberService.java @@ -6,9 +6,12 @@ import java.util.List; import java.util.Map; import java.util.Set; +import java.util.stream.Collectors; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.example.howareyou.domain.member.service.MemberService; import org.example.howareyou.domain.recommendationtag.dto.SimilarityResult; +import org.example.howareyou.domain.recommendationtag.entity.MemberTagScore; import org.example.howareyou.domain.recommendationtag.repository.MemberTagScoreRepository; import org.springframework.stereotype.Service; @@ -20,6 +23,7 @@ public class RecommendationMemberService { private final RecommendationTagService tagService; private final MemberTagScoreRepository tagScoreRepository; private final MemberVectorRedisService redisService; + private final MemberService memberService; /** @@ -48,16 +52,21 @@ private double cosineSimilarity(Map a, Map b) { * @param memberId 현재 사용자 ID * @param topN 추천 인원 수 */ - public List recommendSimilarMembers(Long memberId, int topN) { - // 1. 기준 사용자 벡터 가져오기 (Redis 우선) + public List recommendSimilarMembers(Long memberId, int topN) { + // 1. 기준 사용자 벡터 가져오기 (Redis 우선, 없으면 MemberTagScore 기반) Map baseVector = getOrRefreshMemberVector(memberId); if (baseVector.isEmpty()) { log.warn("태그 점수가 없음: memberId={}", memberId); return List.of(); } - // 2. 전체 사용자 ID (자기 자신 제외) + // 2. 전체 사용자 ID (자기 자신 제외) - MemberTagScore 테이블에서 조회 List allMemberIds = tagScoreRepository.findDistinctMemberIdsExcept(memberId); + + if (allMemberIds.isEmpty()) { + log.warn("추천할 수 있는 다른 사용자가 없음: memberId={}", memberId); + return List.of(); + } List similarityResults = new ArrayList<>(); @@ -70,27 +79,56 @@ public List recommendSimilarMembers(Long memberId, int topN) { } } - // 4. 유사도 내림차순 정렬 후 상위 topN 추출 + // 4. 유사도 내림차순 정렬 후 상위 topN 추출, memberId를 membername으로 변환 return similarityResults.stream() .sorted(Comparator.comparingDouble(SimilarityResult::similarity).reversed()) .limit(topN) - .map(SimilarityResult::memberId) + .map(result -> { + try { + return memberService.findMembernameById(result.memberId()); + } catch (Exception e) { + log.warn("사용자 {}의 membername 조회 실패: {}", result.memberId(), e.getMessage()); + return "unknown_" + result.memberId(); // 폴백값 + } + }) .toList(); } /** * Redis 캐시에서 벡터 조회 → 없으면 새로 계산 후 캐시에 저장 + * MemberTagScore가 있으면 기본 벡터 생성 */ private Map getOrRefreshMemberVector(Long memberId) { + // 1. Redis 캐시에서 벡터 조회 Map cached = redisService.getMemberVector(memberId); if (!cached.isEmpty()) { return cached; } + + // 2. FastAPI AI 태깅 시도 Map fresh = tagService.refreshMemberScores(memberId); if (!fresh.isEmpty()) { redisService.saveMemberVector(memberId, fresh); + return fresh; + } + + // 3. MemberTagScore 기반 기본 벡터 생성 (폴백) + List tagScores = tagService.getMemberTagScores(memberId); + if (!tagScores.isEmpty()) { + Map basicVector = tagScores.stream() + .collect(Collectors.toMap( + score -> score.getMemberTag().name(), + MemberTagScore::getScore + )); + + // Redis에 기본 벡터 저장 + redisService.saveMemberVector(memberId, basicVector); + log.info("MemberTagScore 기반 기본 벡터 생성: memberId={}, vector={}", memberId, basicVector); + return basicVector; } - return fresh; + + log.warn("사용자 {}에 대한 태그 점수를 찾을 수 없습니다", memberId); + return Map.of(); } } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationTagService.java b/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationTagService.java index faa13a2..e8485b6 100644 --- a/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationTagService.java +++ b/src/main/java/org/example/howareyou/domain/recommendationtag/service/RecommendationTagService.java @@ -3,11 +3,15 @@ import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.example.howareyou.domain.member.entity.MemberTag; +import org.example.howareyou.domain.recommendationtag.entity.MemberTagScore; import org.example.howareyou.domain.recommendationtag.repository.MemberTagScoreRepository; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; import java.util.List; import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; @Slf4j @Service @@ -15,6 +19,7 @@ public class RecommendationTagService { private final TaggingNlpClient taggingNlpClient; + private final MemberTagScoreRepository memberTagScoreRepository; private static final Map TAG_TO_MEMBERTAG = Map.ofEntries( Map.entry("언어 학습", MemberTag.LANGUAGE_LEARNING), @@ -59,4 +64,65 @@ public Map refreshMemberScores(long memberId) { return scores; } + /** + * 프로필 관심사 기반 MemberTagScore 생성/업데이트 + * @param memberId 사용자 ID + * @param interests 프로필에서 설정한 관심사들 + */ + @Transactional + public void createOrUpdateMemberTagScores(Long memberId, Set interests) { + if (interests == null || interests.isEmpty()) { + log.warn("관심사가 없어서 태그 점수를 생성할 수 없습니다: memberId={}", memberId); + return; + } + + // 기존 태그 점수 조회 + List existingScores = memberTagScoreRepository.findByMemberId(memberId); + Map existingScoreMap = existingScores.stream() + .collect(Collectors.toMap(MemberTagScore::getMemberTag, score -> score)); + + // 각 관심사별로 점수 업데이트 또는 생성 + for (MemberTag interest : interests) { + MemberTagScore tagScore = existingScoreMap.get(interest); + + if (tagScore != null) { + // 기존 점수가 있으면 가중치 증가 (프로필에서 다시 선택했다는 것은 관심도 증가) + double currentScore = tagScore.getScore(); + double newScore = Math.min(currentScore + 0.5, 5.0); // 최대 5.0점으로 제한 + tagScore.setScore(newScore); + memberTagScoreRepository.save(tagScore); + log.debug("기존 태그 점수 업데이트: memberId={}, tag={}, score: {} -> {}", + memberId, interest, currentScore, newScore); + } else { + // 새로운 관심사면 기본 점수로 생성 + tagScore = new MemberTagScore(); + tagScore.setMemberId(memberId); + tagScore.setMemberTag(interest); + tagScore.setScore(1.0); // 기본 관심사 점수 + memberTagScoreRepository.save(tagScore); + log.debug("새로운 태그 점수 생성: memberId={}, tag={}, score=1.0", memberId, interest); + } + } + + // 더 이상 선택되지 않은 관심사는 점수 감소 (완전 삭제하지 않음) + for (MemberTagScore existingScore : existingScores) { + if (!interests.contains(existingScore.getMemberTag())) { + double currentScore = existingScore.getScore(); + double newScore = Math.max(currentScore - 0.3, 0.1); // 최소 0.1점으로 제한 + existingScore.setScore(newScore); + memberTagScoreRepository.save(existingScore); + log.debug("비선택 관심사 점수 감소: memberId={}, tag={}, score: {} -> {}", + memberId, existingScore.getMemberTag(), currentScore, newScore); + } + } + + log.info("프로필 관심사 기반 태그 점수 업데이트 완료: memberId={}, interests={}", memberId, interests); + } + + /** + * 사용자 ID로 MemberTagScore 조회 + */ + public List getMemberTagScores(Long memberId) { + return memberTagScoreRepository.findByMemberId(memberId); + } } diff --git a/src/main/java/org/example/howareyou/domain/translate/controller/TranslateController.java b/src/main/java/org/example/howareyou/domain/translate/controller/TranslateController.java index e742ba1..6b0c1f4 100644 --- a/src/main/java/org/example/howareyou/domain/translate/controller/TranslateController.java +++ b/src/main/java/org/example/howareyou/domain/translate/controller/TranslateController.java @@ -9,6 +9,7 @@ import lombok.RequiredArgsConstructor; import org.example.howareyou.domain.translate.dto.TranslateRequestDto; import org.example.howareyou.domain.translate.dto.TranslateResponseDto; +import org.example.howareyou.domain.translate.dto.LanguageDetectionResponseDto; import org.example.howareyou.domain.translate.service.GeminiTranslateService; import org.example.howareyou.domain.translate.service.LiberTranslateService; import org.example.howareyou.global.exception.ErrorResponse; @@ -101,4 +102,80 @@ public ResponseEntity translateSpecific( TranslateResponseDto responseDto = geminiTranslateService.translate(requestDto); return ResponseEntity.ok(responseDto); } + + @Operation( + summary = "언어 자동 감지 (LiberTranslate)", + description = "LibreTranslate 엔진을 사용해 텍스트의 언어를 자동으로 감지합니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "언어 감지 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = LanguageDetectionResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PostMapping("/detect") + public ResponseEntity detectLanguage( + @RequestBody + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "언어 감지 요청 DTO", + required = true, + content = @Content(schema = @Schema(implementation = TranslateRequestDto.class)) + ) + TranslateRequestDto requestDto + ){ + LanguageDetectionResponseDto responseDto = liberTranslateService.detectLanguage(requestDto.getQ()); + return ResponseEntity.ok(responseDto); + } + + @Operation( + summary = "자동 언어 감지 및 번역 (LiberTranslate)", + description = "텍스트의 언어를 자동으로 감지하고 반대 언어로 번역합니다. source와 target을 지정하지 않아도 됩니다." + ) + @ApiResponses({ + @ApiResponse( + responseCode = "200", + description = "자동 번역 성공", + content = @Content( + mediaType = "application/json", + schema = @Schema(implementation = TranslateResponseDto.class) + ) + ), + @ApiResponse( + responseCode = "400", + description = "잘못된 요청", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ), + @ApiResponse( + responseCode = "500", + description = "서버 내부 오류", + content = @Content(schema = @Schema(implementation = ErrorResponse.class)) + ) + }) + @PostMapping("/auto") + public ResponseEntity translateAuto( + @RequestBody + @io.swagger.v3.oas.annotations.parameters.RequestBody( + description = "자동 번역 요청 DTO (source, target 생략 가능)", + required = true, + content = @Content(schema = @Schema(implementation = TranslateRequestDto.class)) + ) + TranslateRequestDto requestDto + ){ + TranslateResponseDto responseDto = liberTranslateService.translateAuto(requestDto); + return ResponseEntity.ok(responseDto); + } } diff --git a/src/main/java/org/example/howareyou/domain/translate/dto/LanguageDetectionResponseDto.java b/src/main/java/org/example/howareyou/domain/translate/dto/LanguageDetectionResponseDto.java new file mode 100644 index 0000000..dbc185f --- /dev/null +++ b/src/main/java/org/example/howareyou/domain/translate/dto/LanguageDetectionResponseDto.java @@ -0,0 +1,16 @@ +package org.example.howareyou.domain.translate.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import lombok.Getter; +import lombok.Setter; + +@Getter +@Setter +@Schema(description = "언어 감지 응답 DTO") +public class LanguageDetectionResponseDto { + @Schema(description = "감지된 언어 코드", example = "ko") + private String language; + + @Schema(description = "감지 신뢰도 (0.0 ~ 1.0)", example = "0.95") + private Double confidence; +} diff --git a/src/main/java/org/example/howareyou/domain/translate/dto/TranslateRequestDto.java b/src/main/java/org/example/howareyou/domain/translate/dto/TranslateRequestDto.java index 0d3ca35..e9b3dfe 100644 --- a/src/main/java/org/example/howareyou/domain/translate/dto/TranslateRequestDto.java +++ b/src/main/java/org/example/howareyou/domain/translate/dto/TranslateRequestDto.java @@ -2,8 +2,10 @@ import io.swagger.v3.oas.annotations.media.Schema; import lombok.Getter; +import lombok.Setter; @Getter +@Setter @Schema(description = "번역 요청 DTO") public class TranslateRequestDto { @Schema(description = "번역할 원문", example = "안녕하세요") diff --git a/src/main/java/org/example/howareyou/domain/translate/service/LiberTranslateService.java b/src/main/java/org/example/howareyou/domain/translate/service/LiberTranslateService.java index 868d450..4030288 100644 --- a/src/main/java/org/example/howareyou/domain/translate/service/LiberTranslateService.java +++ b/src/main/java/org/example/howareyou/domain/translate/service/LiberTranslateService.java @@ -3,6 +3,7 @@ import lombok.RequiredArgsConstructor; import org.example.howareyou.domain.translate.dto.TranslateRequestDto; import org.example.howareyou.domain.translate.dto.TranslateResponseDto; +import org.example.howareyou.domain.translate.dto.LanguageDetectionResponseDto; import org.springframework.beans.factory.annotation.Value; import org.springframework.http.HttpEntity; import org.springframework.http.HttpHeaders; @@ -56,4 +57,84 @@ public TranslateResponseDto translate(TranslateRequestDto requestDto){ responseDto.setTranslatedText(translatedText); return responseDto; } + + /** + * LibreTranslate를 사용한 언어 감지 메서드 + * @param text 감지할 텍스트 + * @return LanguageDetectionResponseDto 감지된 언어 정보 + */ + public LanguageDetectionResponseDto detectLanguage(String text) { + // LibreTranslate detect endpoint URL + String url = "http://" + host + ":" + port + "/detect"; + + // body 설정 + Map payload = Map.of( + "q", text + ); + + // header 설정 + HttpHeaders detectHeaders = new HttpHeaders(); + detectHeaders.setContentType(MediaType.APPLICATION_JSON); + + // RestTemplate으로 언어 감지 요청 (배열 응답 처리) + HttpEntity> detectReq = new HttpEntity<>(payload, detectHeaders); + ResponseEntity detectResp = restTemplate.postForEntity(url, detectReq, Object[].class); + + // 응답에서 언어 정보 추출 (배열의 첫 번째 요소) + Object[] body = detectResp.getBody(); + if (body != null && body.length > 0) { + @SuppressWarnings("unchecked") + Map firstResult = (Map) body[0]; + + String language = (String) firstResult.getOrDefault("language", "en"); + Object confidenceObj = firstResult.getOrDefault("confidence", 0.0); + Double confidence = 0.0; + + if (confidenceObj instanceof Number) { + confidence = ((Number) confidenceObj).doubleValue(); + } + + // 응답 DTO 생성 후 반환 + LanguageDetectionResponseDto responseDto = new LanguageDetectionResponseDto(); + responseDto.setLanguage(language); + responseDto.setConfidence(confidence); + return responseDto; + } + + // 기본값 반환 + LanguageDetectionResponseDto responseDto = new LanguageDetectionResponseDto(); + responseDto.setLanguage("en"); + responseDto.setConfidence(0.0); + return responseDto; + } + + /** + * 자동 언어 감지 및 번역 메서드 + * 텍스트의 언어를 자동으로 감지하고 반대 언어로 번역 + * @param requestDto 번역 요청 DTO (source, target 생략 가능) + * @return TranslateResponseDto 번역된 텍스트 + */ + public TranslateResponseDto translateAuto(TranslateRequestDto requestDto) { + String text = requestDto.getQ(); + + // 1단계: 언어 자동 감지 + LanguageDetectionResponseDto detectedLang = detectLanguage(text); + String sourceLang = detectedLang.getLanguage(); + + // 2단계: 타겟 언어 자동 결정 (한국어 ↔ 영어) + String targetLang; + if ("ko".equals(sourceLang)) { + targetLang = "en"; + } else { + targetLang = "ko"; + } + + // 3단계: 번역 실행 + TranslateRequestDto autoRequest = new TranslateRequestDto(); + autoRequest.setQ(text); + autoRequest.setSource(sourceLang); + autoRequest.setTarget(targetLang); + + return translate(autoRequest); + } } diff --git a/src/main/java/org/example/howareyou/global/config/CorsFilter.java b/src/main/java/org/example/howareyou/global/config/CorsFilter.java new file mode 100644 index 0000000..9588bb5 --- /dev/null +++ b/src/main/java/org/example/howareyou/global/config/CorsFilter.java @@ -0,0 +1,59 @@ +package org.example.howareyou.global.config; + +import jakarta.servlet.*; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import lombok.extern.slf4j.Slf4j; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.stereotype.Component; + +import java.io.IOException; + +@Component +@Order(Ordered.HIGHEST_PRECEDENCE) +@Slf4j +public class CorsFilter implements Filter { + + @Override + public void doFilter(ServletRequest req, ServletResponse res, FilterChain chain) + throws IOException, ServletException { + + HttpServletResponse response = (HttpServletResponse) res; + HttpServletRequest request = (HttpServletRequest) req; + + log.info("🔍 CORS Filter 처리: {} {}", request.getMethod(), request.getRequestURI()); + + // credentials가 true일 때는 특정 origin만 허용해야 함 + String origin = request.getHeader("Origin"); + if (origin != null) { + response.setHeader("Access-Control-Allow-Origin", origin); + } else { + response.setHeader("Access-Control-Allow-Origin", "*"); + } + response.setHeader("Access-Control-Allow-Credentials", "true"); + response.setHeader("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS"); + response.setHeader("Access-Control-Max-Age", "3600"); + response.setHeader("Access-Control-Allow-Headers", "Origin, X-Requested-With, Content-Type, Accept, Authorization, Cache-Control"); + response.setHeader("Access-Control-Expose-Headers", "Authorization, Set-Cookie"); + + // OPTIONS 요청에 대한 처리 + if ("OPTIONS".equalsIgnoreCase(request.getMethod())) { + response.setStatus(HttpServletResponse.SC_OK); + log.info("✅ OPTIONS 요청 처리 완료"); + return; + } + + chain.doFilter(req, res); + } + + @Override + public void init(FilterConfig filterConfig) { + log.info("🚀 CORS Filter 초기화 완료"); + } + + @Override + public void destroy() { + log.info("🛑 CORS Filter 종료"); + } +} diff --git a/src/main/java/org/example/howareyou/global/config/DevRedisCleaner.java b/src/main/java/org/example/howareyou/global/config/DevRedisCleaner.java index 6ea2909..d048626 100644 --- a/src/main/java/org/example/howareyou/global/config/DevRedisCleaner.java +++ b/src/main/java/org/example/howareyou/global/config/DevRedisCleaner.java @@ -11,7 +11,7 @@ @Slf4j @Configuration -@Profile("dev") // ⭐ dev 프로필에서만 활성 +//@Profile("dev") // ⭐ dev 프로필에서만 활성 @RequiredArgsConstructor public class DevRedisCleaner { diff --git a/src/main/java/org/example/howareyou/global/config/KafkaConfig.java b/src/main/java/org/example/howareyou/global/config/KafkaConfig.java index 9d8e3ba..4562344 100644 --- a/src/main/java/org/example/howareyou/global/config/KafkaConfig.java +++ b/src/main/java/org/example/howareyou/global/config/KafkaConfig.java @@ -6,6 +6,7 @@ import org.springframework.boot.autoconfigure.kafka.KafkaProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; import org.springframework.core.env.Environment; import org.springframework.kafka.core.DefaultKafkaProducerFactory; import org.springframework.kafka.core.KafkaTemplate; @@ -13,6 +14,7 @@ import java.util.Map; +@Profile("dev") @Slf4j @Configuration @RequiredArgsConstructor diff --git a/src/main/java/org/example/howareyou/global/config/SecurityConfig.java b/src/main/java/org/example/howareyou/global/config/SecurityConfig.java index 01b447a..f5ab802 100644 --- a/src/main/java/org/example/howareyou/global/config/SecurityConfig.java +++ b/src/main/java/org/example/howareyou/global/config/SecurityConfig.java @@ -13,7 +13,6 @@ import org.springframework.http.HttpStatus; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; -import org.springframework.security.config.annotation.web.configurers.AbstractHttpConfigurer; import org.springframework.security.config.http.SessionCreationPolicy; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; @@ -54,16 +53,22 @@ public class SecurityConfig { private List allowedOriginsRaw; private List allowedOrigins() { - return allowedOriginsRaw == null ? List.of() + List origins = allowedOriginsRaw == null ? List.of() : allowedOriginsRaw.stream().map(String::trim) .filter(s -> !s.isBlank()).distinct().collect(Collectors.toList()); + + log.info("🔒 CORS allowed origins: {}", origins); + return origins; } /* ──────────── Security Filter Chain ──────────── */ @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { http - .csrf(AbstractHttpConfigurer::disable) + // REST API라 CSRF 미사용. (필요 시 특정 경로만 ignore) + .csrf(csrf -> csrf.disable()) + + // CORS .cors(cors -> cors.configurationSource(corsConfigurationSource())) // 세션 없이 동작(JWT) @@ -88,48 +93,45 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { "/error" ).permitAll() - // k6 테스트 - .requestMatchers( - "/api/chat-message/**", - "/analyze/**" - - ).permitAll() - - // 인증/회원 관련 공개 API + // 인증/회원 공개 API .requestMatchers("/api/auth/**").permitAll() - .requestMatchers(HttpMethod.POST, "/signup/**").permitAll() - - - - // WebSocket 관련 경로 허용 (SockJS info, sockjs-node 등) - .requestMatchers("/ws-chatroom/**").permitAll() - .requestMatchers("/ws-chatroom/**", "/topic/**", "/app/**").permitAll() + // WebSocket 핸드셰이크 경로 (SockJS info 포함) 공개 + .requestMatchers( + "/ws-chatroom/**", + "/ws/**", + "/sockjs-node/**" + ).permitAll() - // 개발 환경 전용 허용 경로 - .requestMatchers(isDevProfile() ? new String[]{ - "/", - "/index.html", - "/notification-test.html", - "/test-login.html", - "/test-signup.html", - "/test-info.html", - "/js/**", - "/api/test/**", - "/test/**", - "/dev/**", - "/debug/**", - "/h2-console/**", - "/actuator/**", // dev에선 actuator 전체 열어도 됨 - "/upload-csv" - } : new String[]{}).permitAll() + // 개발/프로덕션 공통 허용 경로 (모든 환경에서 적용) + .requestMatchers(new String[]{ + "/", + "/index.html", + "/notification-test.html", + "/test-login.html", + "/test-signup.html", + "/test-info.html", + "/js/**", + "/api/test/**", + "/test/**", + "/dev/**", + "/debug/**", + "/h2-console/**", + "/actuator/**", // 모든 환경에서 actuator 허용 + "/upload-csv" + }).permitAll() // 공개 GET 조회 .requestMatchers(HttpMethod.GET, "/api/members/*", - "/api/members/*/status", - "/api/members/membername/*" + "/api/members/*/status" + ).permitAll() + // 공개 POST 요청 (회원가입/로그인 등) + .requestMatchers(HttpMethod.POST, + "/api/auth/**" ).permitAll() + // membername 관련 모든 요청 허용 + .requestMatchers("/api/members/membername/**").permitAll() //server health 체크 .requestMatchers("/health").permitAll() @@ -154,13 +156,9 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // JWT 필터 위치 조정 .addFilterBefore(jwtAuthFilter, UsernamePasswordAuthenticationFilter.class); - // 개발: H2 console 프레임 허용 - if (isDevProfile()) { - http.headers(headers -> headers.frameOptions(frame -> frame.disable())); - log.info("🔧 dev profile: wide-open CORS + dev routes permitted + H2 frame allowed"); - } else { - log.info("🚀 prod profile: CORS whitelist + auth-by-default"); - } + // 모든 환경에서 동일한 설정 적용 + http.headers(headers -> headers.frameOptions(frame -> frame.disable())); + log.info("🔧 All profiles: wide-open CORS + dev routes permitted + H2 frame allowed"); return http.build(); } @@ -193,17 +191,15 @@ public CorsConfigurationSource corsConfigurationSource() { cfg.setAllowCredentials(true); cfg.setMaxAge(3600L); - if (isDevProfile()) { - // 개발: 어디서든 붙을 수 있게 - cfg.addAllowedOriginPattern("*"); // credentials true + pattern 허용 + // 모든 환경에서 CORS 허용 (dev와 prod 동일하게) + List origins = allowedOrigins(); + if (origins.isEmpty()) { + // CORS 설정이 비어있으면 모든 origin 허용 (개발 편의) + log.warn("CORS allowed-origins is empty. Allowing all origins for development convenience."); + cfg.addAllowedOriginPattern("*"); } else { - // 운영: 화이트리스트만 - List origins = allowedOrigins(); - if (origins.isEmpty()) { - // 운영인데 비어있으면 실수 방지용으로 로그만 남기고 막음(필요시 기본값 추가) - log.warn("CORS allowed-origins is empty on PROD. Check your config/env!"); - } - cfg.setAllowedOrigins(origins); // credentials true + exact origin만 + // 설정된 origins만 허용 + cfg.setAllowedOrigins(origins); } UrlBasedCorsConfigurationSource src = new UrlBasedCorsConfigurationSource(); @@ -221,7 +217,8 @@ private void jsonError(HttpServletResponse res, HttpStatus status, String msg) t private boolean isDevProfile() { String[] profiles = environment.getActiveProfiles(); - // 프로필 미설정 시 dev로 간주(로컬 기본값) - return profiles.length == 0 || Arrays.asList(profiles).contains("dev"); + boolean isDev = profiles.length == 0 || Arrays.asList(profiles).contains("dev"); + log.info("🔧 Active profiles: {}, isDev: {}", Arrays.toString(profiles), isDev); + return isDev; } } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/global/config/WebConfig.java b/src/main/java/org/example/howareyou/global/config/WebConfig.java new file mode 100644 index 0000000..56c682d --- /dev/null +++ b/src/main/java/org/example/howareyou/global/config/WebConfig.java @@ -0,0 +1,26 @@ +package org.example.howareyou.global.config; + +import lombok.extern.slf4j.Slf4j; +import org.springframework.context.annotation.Configuration; +import org.springframework.web.servlet.config.annotation.CorsRegistry; +import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; + +@Configuration +@Slf4j +public class WebConfig implements WebMvcConfigurer { + + @Override + public void addCorsMappings(CorsRegistry registry) { + log.info("🌐 WebMvcConfigurer CORS 설정 추가"); + + registry.addMapping("/**") + .allowedOriginPatterns("*") + .allowedMethods("GET", "POST", "PUT", "PATCH", "DELETE", "OPTIONS") + .allowedHeaders("*") + .exposedHeaders("Authorization", "Set-Cookie") + .allowCredentials(true) + .maxAge(3600); + + log.info("✅ WebMvcConfigurer CORS 설정 완료 - 모든 origin 허용"); + } +} diff --git a/src/main/java/org/example/howareyou/global/exception/GlobalExceptionHandler.java b/src/main/java/org/example/howareyou/global/exception/GlobalExceptionHandler.java index 92398f6..9f8f28a 100644 --- a/src/main/java/org/example/howareyou/global/exception/GlobalExceptionHandler.java +++ b/src/main/java/org/example/howareyou/global/exception/GlobalExceptionHandler.java @@ -7,7 +7,6 @@ import org.springframework.web.bind.annotation.ExceptionHandler; import org.springframework.web.bind.annotation.RestControllerAdvice; import org.springframework.data.redis.RedisSystemException; -import org.springframework.session.data.redis.RedisSessionRepository; import java.time.LocalDateTime; import java.util.stream.Collectors; @@ -58,27 +57,11 @@ protected ResponseEntity handleConstraintViolation( return makeValidationErrorResponse(detail); // 기존 헬퍼 재사용 } - /* ── 4) Redis/Session 관련 예외 ───────────────────────────────────── */ - @ExceptionHandler({RedisSystemException.class, IllegalStateException.class}) - protected ResponseEntity handleRedisSessionException(Exception ex) { + /* ── 4) Redis 연결 관련 예외 ───────────────────────────────────── */ + @ExceptionHandler(RedisSystemException.class) + protected ResponseEntity handleRedisException(RedisSystemException ex) { - // Session was invalidated 에러는 클라이언트에게 세션 만료로 안내 - if (ex.getMessage() != null && ex.getMessage().contains("Session was invalidated")) { - log.warn("[SESSION] Session invalidated: {}", ex.getMessage()); - - ErrorResponse body = ErrorResponse.builder() - .code("SESSION_EXPIRED") - .message("세션이 만료되었습니다. 다시 로그인해주세요.") - .status(401) - .detail("Session was invalidated") - .timestamp(LocalDateTime.now()) - .build(); - - return ResponseEntity.status(401).body(body); - } - - // Redis 연결 문제 - log.error("[REDIS] Redis/Session error: {}", ex.getMessage(), ex); + log.error("[REDIS] Redis connection error: {}", ex.getMessage(), ex); ErrorResponse body = ErrorResponse.builder() .code(ErrorCode.INTERNAL_SERVER_ERROR.getCode()) diff --git a/src/main/java/org/example/howareyou/global/health/ApiHealthCheckRunner.java b/src/main/java/org/example/howareyou/global/health/ApiHealthCheckRunner.java index 9c219cb..6198021 100644 --- a/src/main/java/org/example/howareyou/global/health/ApiHealthCheckRunner.java +++ b/src/main/java/org/example/howareyou/global/health/ApiHealthCheckRunner.java @@ -9,7 +9,8 @@ import org.springframework.web.reactive.function.client.WebClient; import reactor.core.publisher.Mono; -@Profile("dev") +import java.util.Arrays; + @Configuration @RequiredArgsConstructor @Slf4j @@ -19,12 +20,14 @@ public class ApiHealthCheckRunner implements ApplicationRunner { private final WebClient taggingNlpWebClient; private final WebClient translateWebClient; private final WebClient geminiWebClient; + private final org.springframework.core.env.Environment environment; @Override public void run(ApplicationArguments args) { - log.info("🔍 Performing health checks for external APIs. The application will not start if any API is down."); + log.info("🔍 Performing health checks for external APIs."); + + boolean isProd = Arrays.asList(environment.getActiveProfiles()).contains("prod"); - // Define each API check Mono spacyApiHealth = checkHealth(nlpWebClient, "/health", "Spacy API (port 8000)"); Mono fastApiHealth = checkHealth(taggingNlpWebClient, "/health", "FastAPI (port 8001)"); Mono libreTranslateHealth = checkHealth(translateWebClient, "/languages", "LibreTranslate API"); @@ -33,12 +36,18 @@ public void run(ApplicationArguments args) { try { Mono.zip(spacyApiHealth, fastApiHealth, libreTranslateHealth, geminiApiHealth) .doOnSuccess(results -> { - log.info("✅ All external APIs are healthy. Proceeding with application startup."); + log.info("✅ All external APIs are healthy."); }) - .block(); // block until all health checks complete + .block(); } catch (Exception e) { - log.error("❌ One or more external API health checks failed. Aborting application startup.", e); - throw new RuntimeException("External API health check failed", e); + if (isProd) { + // 🚀 운영: 서버는 계속 실행 + log.error("⚠️ External API health check failed, but continuing startup (prod mode).", e); + } else { + // 🛠️ 개발/테스트: 바로 종료 + log.error("❌ External API health check failed. Aborting startup (non-prod).", e); +// throw new RuntimeException("External API health check failed", e); + } } } @@ -47,7 +56,7 @@ private Mono checkHealth(WebClient client, String uri, String apiName) { .uri(uri) .retrieve() .bodyToMono(String.class) - .timeout(java.time.Duration.ofSeconds(10)) + .timeout(java.time.Duration.ofSeconds(5)) .doOnSuccess(res -> log.info("✅ {} health check successful: {}", apiName, res)) .doOnError(err -> log.error("❌ {} health check failed: {}", apiName, err.getMessage())); } diff --git a/src/main/java/org/example/howareyou/global/security/CustomMemberDetailsService.java b/src/main/java/org/example/howareyou/global/security/CustomMemberDetailsService.java index 221d73f..521e336 100644 --- a/src/main/java/org/example/howareyou/global/security/CustomMemberDetailsService.java +++ b/src/main/java/org/example/howareyou/global/security/CustomMemberDetailsService.java @@ -1,23 +1,59 @@ package org.example.howareyou.global.security; import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; import org.example.howareyou.domain.member.repository.MemberRepository; +import org.example.howareyou.domain.auth.repository.AuthRepository; +import org.example.howareyou.domain.member.entity.Member; import org.springframework.security.core.userdetails.UserDetails; import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.core.userdetails.UsernameNotFoundException; import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; /** JwtAuthFilter 가 호출하는 UserDetailsProvider */ @Service @RequiredArgsConstructor +@Slf4j +@Transactional(readOnly = true) public class CustomMemberDetailsService implements UserDetailsService { private final MemberRepository memberRepo; + private final AuthRepository authRepo; @Override public UserDetails loadUserByUsername(String identifier) throws UsernameNotFoundException { - // identifier가 숫자인지 확인 (기존 ID 방식 지원) + log.debug("CustomMemberDetailsService.loadUserByUsername 호출: identifier={}", identifier); + + // identifier가 null이거나 빈 문자열인 경우 처리 + if (identifier == null || identifier.trim().isEmpty()) { + throw new UsernameNotFoundException("Identifier cannot be null or empty"); + } + + // identifier가 숫자인지 확인 (Auth ID인지 확인) if (identifier.matches("\\d+")) { - return memberRepo.findById(Long.valueOf(identifier)) + Long authId = Long.valueOf(identifier); + log.debug("Auth ID 기반 사용자 조회: {}", authId); + + // Auth ID로 Auth 엔티티 조회 후 Member 정보 가져오기 (eager fetch) + return authRepo.findByIdWithMember(authId) + .map(auth -> { + if (auth.getMember() == null) { + throw new UsernameNotFoundException("Auth found but no member associated: " + authId); + } + Member member = auth.getMember(); + return new CustomMemberDetails( + member.getId(), + member.getEmail(), + member.getMembername(), + member.isActive(), + member.getRole() + ); + }) + .orElseThrow(() -> new UsernameNotFoundException("Auth not found by ID: " + authId)); + } else if (identifier.contains("@")) { + // email으로 사용자 찾기 (eager fetch) + log.debug("Email 기반 사용자 조회: {}", identifier); + return memberRepo.findByEmailForAuth(identifier) .map(m -> new CustomMemberDetails( m.getId(), m.getEmail(), @@ -25,10 +61,11 @@ public UserDetails loadUserByUsername(String identifier) throws UsernameNotFound m.isActive(), m.getRole() )) - .orElseThrow(() -> new UsernameNotFoundException("Member not found by ID: " + identifier)); + .orElseThrow(() -> new UsernameNotFoundException("Member not found by email: " + identifier)); } else { - // membername으로 사용자 찾기 - return memberRepo.findByMembername(identifier) + // membername으로 사용자 찾기 (eager fetch) + log.debug("Membername 기반 사용자 조회: {}", identifier); + return memberRepo.findByMembernameForAuth(identifier) .map(m -> new CustomMemberDetails( m.getId(), m.getEmail(), diff --git a/src/main/java/org/example/howareyou/global/security/jwt/JwtAuthFilter.java b/src/main/java/org/example/howareyou/global/security/jwt/JwtAuthFilter.java index fb8d369..b10a58b 100644 --- a/src/main/java/org/example/howareyou/global/security/jwt/JwtAuthFilter.java +++ b/src/main/java/org/example/howareyou/global/security/jwt/JwtAuthFilter.java @@ -83,10 +83,13 @@ protected void doFilterInternal(HttpServletRequest req, } try { - // Access Token에서 사용자 식별자 추출 - String identifier = jwtTokenProvider.getIdentifierFromAccessToken(token); + // Access Token에서 Auth ID 추출 + Long authId = jwtTokenProvider.getAuthIdFromAccessToken(token); + + log.debug("JWT 인증 처리: authId={}", authId); - UserDetails user = userDetailsService.loadUserByUsername(identifier); // ← identifier=membername + // Auth ID로 사용자 조회 (기존 로직 활용) + UserDetails user = userDetailsService.loadUserByUsername(authId.toString()); UsernamePasswordAuthenticationToken auth = new UsernamePasswordAuthenticationToken(user, null, user.getAuthorities()); diff --git a/src/main/java/org/example/howareyou/global/security/jwt/JwtTokenProvider.java b/src/main/java/org/example/howareyou/global/security/jwt/JwtTokenProvider.java index 83128de..c9da77c 100644 --- a/src/main/java/org/example/howareyou/global/security/jwt/JwtTokenProvider.java +++ b/src/main/java/org/example/howareyou/global/security/jwt/JwtTokenProvider.java @@ -75,7 +75,54 @@ public String getMemberIdFromToken(String token) { */ public String getIdentifierFromAccessToken(String token) { Claims claims = parse(token); - return claims.get("identifier", String.class); + String identifier = claims.get("identifier", String.class); + + if (identifier == null || identifier.trim().isEmpty()) { + // identifier가 없으면 subject를 사용 (하위 호환성) + String subject = claims.getSubject(); + if ("access_token".equals(subject)) { + throw new IllegalArgumentException("Access token does not contain valid identifier"); + } + return subject; + } + + return identifier; + } + + /** + * Access Token에서 Auth ID를 추출합니다. + * @param token Access Token + * @return Auth 테이블 ID + */ + public Long getAuthIdFromAccessToken(String token) { + Claims claims = parse(token); + String authId = claims.get("identifier", String.class); + + if (authId == null || authId.trim().isEmpty()) { + throw new IllegalArgumentException("Access token does not contain valid auth ID"); + } + + try { + return Long.valueOf(authId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid auth ID format in access token"); + } + } + + /** + * Refresh Token에서 membername을 추출합니다. + * @param token Refresh Token + * @return 사용자 membername + */ + public String getMembernameFromRefreshToken(String token) { + Claims claims = parse(token); + String membername = claims.get("identifier", String.class); + + if (membername == null || membername.trim().isEmpty()) { + throw new IllegalArgumentException("Refresh token does not contain valid membername"); + } + + return membername; } /** @@ -85,29 +132,26 @@ public String getIdentifierFromAccessToken(String token) { */ /** * Access Token을 생성합니다. - * @param membername 사용자 membername + * @param authId Auth 테이블 ID * @return 생성된 Access Token */ - public String createAccessToken(String membername) { - return buildWithIdentifier(membername, "membername", ACCESS_EXP_MS, true); + public String createAccessToken(Long authId) { + if (authId == null) { + throw new IllegalArgumentException("Auth ID cannot be null for access token creation"); + } + return buildWithIdentifier(authId.toString(), "auth_id", ACCESS_EXP_MS, true); } /** - * Refresh Token을 생성합니다 (email 기반). - * @param email 사용자 email + * Refresh Token을 생성합니다. + * @param userId 사용자 ID (UUID) * @return 생성된 Refresh Token */ - public String createRefreshTokenWithEmail(String email) { - return buildWithIdentifier(email, "email", REFRESH_EXP_MS, false); - } - - /** - * Refresh Token을 생성합니다 (membername 기반). - * @param membername 사용자 membername - * @return 생성된 Refresh Token - */ - public String createRefreshTokenWithMembername(String membername) { - return buildWithIdentifier(membername, "membername", REFRESH_EXP_MS, false); + public String createRefreshToken(Long userId) { + if (userId == null) { + throw new IllegalArgumentException("User ID cannot be null for refresh token creation"); + } + return buildWithIdentifier(userId.toString(), "user_id", REFRESH_EXP_MS, false); } /** @@ -151,22 +195,22 @@ public String validateAndGetSubject(String token) { } /** - * Refresh Token에서 사용자 식별자를 추출합니다. - * @param token Refresh Token - * @return 사용자 식별자 (email 또는 membername) - */ - public String getIdentifierFromRefreshToken(String token) { - Claims claims = parse(token); - return claims.get("identifier", String.class); - } - - /** - * Refresh Token에서 식별자 타입을 추출합니다. + * Refresh Token에서 사용자 ID를 추출합니다. * @param token Refresh Token - * @return 식별자 타입 ("email" 또는 "membername") + * @return 사용자 ID */ - public String getIdentifierTypeFromRefreshToken(String token) { + public Long getUserIdFromRefreshToken(String token) { Claims claims = parse(token); - return claims.get("identifierType", String.class); + String userId = claims.get("identifier", String.class); + + if (userId == null || userId.trim().isEmpty()) { + throw new IllegalArgumentException("Refresh token does not contain valid user ID"); + } + + try { + return Long.valueOf(userId); + } catch (NumberFormatException e) { + throw new IllegalArgumentException("Invalid user ID format in refresh token"); + } } } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/global/test/HealthCheckController.java b/src/main/java/org/example/howareyou/global/test/HealthCheckController.java index 954dfee..0576506 100644 --- a/src/main/java/org/example/howareyou/global/test/HealthCheckController.java +++ b/src/main/java/org/example/howareyou/global/test/HealthCheckController.java @@ -55,28 +55,5 @@ public ResponseEntity> checkRedisHealth() { } } - @GetMapping("/session") - public ResponseEntity> checkSessionHealth() { - Map result = new HashMap<>(); - - try { - // Session 관련 Redis 키 패턴 확인 - String sessionKey = "spring:session:sessions:test:" + System.currentTimeMillis(); - redisTemplate.opsForValue().set(sessionKey, "test-session-data"); - redisTemplate.delete(sessionKey); - - result.put("status", "healthy"); - result.put("message", "Session storage is working properly"); - result.put("timestamp", System.currentTimeMillis()); - log.info("Session health check passed"); - return ResponseEntity.ok(result); - - } catch (Exception e) { - result.put("status", "unhealthy"); - result.put("message", "Session storage failed: " + e.getMessage()); - result.put("timestamp", System.currentTimeMillis()); - log.error("Session health check failed", e); - return ResponseEntity.status(503).body(result); - } - } + } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/global/test/TestConsumer.java b/src/main/java/org/example/howareyou/global/test/TestConsumer.java index 2334e20..3c92f16 100644 --- a/src/main/java/org/example/howareyou/global/test/TestConsumer.java +++ b/src/main/java/org/example/howareyou/global/test/TestConsumer.java @@ -2,12 +2,14 @@ import java.util.concurrent.CountDownLatch; +import org.springframework.context.annotation.Profile; import org.springframework.kafka.annotation.KafkaListener; import org.springframework.stereotype.Component; import lombok.Getter; import lombok.extern.slf4j.Slf4j; +@Profile("dev") @Component @Getter @Slf4j diff --git a/src/main/java/org/example/howareyou/global/test/TestController.java b/src/main/java/org/example/howareyou/global/test/TestController.java index e8e659d..c9f9e1c 100644 --- a/src/main/java/org/example/howareyou/global/test/TestController.java +++ b/src/main/java/org/example/howareyou/global/test/TestController.java @@ -9,6 +9,7 @@ import org.example.howareyou.domain.auth.repository.AuthRepository; import org.example.howareyou.domain.member.entity.Member; import org.example.howareyou.domain.member.entity.MemberProfile; +import org.example.howareyou.domain.member.entity.MemberTag; import org.example.howareyou.domain.member.entity.Role; import org.example.howareyou.domain.member.redis.MemberCacheService; import org.example.howareyou.domain.member.repository.MemberRepository; @@ -17,6 +18,7 @@ import org.springframework.web.bind.annotation.*; import java.util.HashMap; +import java.util.List; import java.util.Map; import java.util.Set; @@ -236,4 +238,54 @@ public ResponseEntity> checkCacheHealth() { return ResponseEntity.status(500).body(response); } } + + /** + * 기존 테스트 유저 프로필에 관심사 추가 + */ + @PostMapping("/update-test-profiles") + public ResponseEntity> updateTestProfiles() { + Map response = new HashMap<>(); + + try { + // 테스트 유저들 찾기 + List testUsers = memberRepository.findByEmailContaining("@example.com"); + int updatedCount = 0; + + for (Member member : testUsers) { + if (member.getProfile() != null && + (member.getProfile().getInterests() == null || member.getProfile().getInterests().isEmpty())) { + + // 관심사 설정 + Set interests = Set.of( + MemberTag.TECHNOLOGY, + MemberTag.MUSIC, + MemberTag.FOOD, + MemberTag.TRAVEL + ); + + member.getProfile().setInterests(interests); + member.getProfile().setLanguage("ko"); + member.getProfile().setTimezone("Asia/Seoul"); + member.getProfile().setCompleted(true); + + memberRepository.save(member); + updatedCount++; + + log.info("테스트 유저 프로필 업데이트: {} (관심사: {})", + member.getEmail(), interests); + } + } + + response.put("success", true); + response.put("updatedCount", updatedCount); + response.put("message", updatedCount + "명의 테스트 유저 프로필이 업데이트되었습니다."); + + } catch (Exception e) { + log.error("테스트 유저 프로필 업데이트 중 오류 발생", e); + response.put("success", false); + response.put("message", "프로필 업데이트 중 오류 발생: " + e.getMessage()); + } + + return ResponseEntity.ok(response); + } } \ No newline at end of file diff --git a/src/main/java/org/example/howareyou/global/test/TestDataInitializer.java b/src/main/java/org/example/howareyou/global/test/TestDataInitializer.java index 05dd72e..d95a5e5 100644 --- a/src/main/java/org/example/howareyou/global/test/TestDataInitializer.java +++ b/src/main/java/org/example/howareyou/global/test/TestDataInitializer.java @@ -16,6 +16,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import java.time.Instant; +import java.util.Set; /** * 개발 환경에서 테스트용 데이터를 자동으로 초기화하는 컴포넌트 @@ -25,7 +26,6 @@ @Slf4j @Configuration @RequiredArgsConstructor -@Profile("dev") public class TestDataInitializer { private final MemberRepository memberRepository; @@ -62,6 +62,15 @@ private void createTestUser(String email, String password, String nickname, bool .nickname(nickname) .completed(profileCompleted) .avatarUrl("https://via.placeholder.com/150") + .language("ko") + .timezone("Asia/Seoul") + .interests(Set.of( + // 테스트용 관심사 설정 + org.example.howareyou.domain.member.entity.MemberTag.TECHNOLOGY, + org.example.howareyou.domain.member.entity.MemberTag.MUSIC, + org.example.howareyou.domain.member.entity.MemberTag.FOOD, + org.example.howareyou.domain.member.entity.MemberTag.TRAVEL + )) .build(); // 회원 생성 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index 3ee0621..239eb49 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -10,39 +10,49 @@ spring: oauth2: client: registration: - google: - # 개발 환경에서는 localhost 사용 - redirect-uri: "https://api.howareu.click/login/oauth2/code/google" + google: + # 프로덕션 환경에서는 https 사용 + redirect-uri: "https://api.howareu.click/login/oauth2/code/google" + authorization-grant-type: authorization_code - kafka: - bootstrap-servers: ${AWS_MSK_BROKERS} - consumer: - group-id: ${KAFKA_CONSUMER_GROUP_ID:howru-prod} - auto-offset-reset: earliest - key-deserializer: org.apache.kafka.common.serialization.StringDeserializer - value-deserializer: org.apache.kafka.common.serialization.StringDeserializer - producer: - key-serializer: org.apache.kafka.common.serialization.StringSerializer - value-serializer: org.apache.kafka.common.serialization.StringSerializer - admin: - auto-create: false # 프로덕션에서는 수동으로 토픽 관리 - properties: - # AWS MSK 보안 설정 - security.protocol: SASL_SSL - sasl.mechanism: AWS_MSK_IAM - sasl.jaas.config: com.amazon.msk.auth.iam.IAMLoginModule required; - sasl.client.callback.handler.class: com.amazon.msk.auth.iam.IAMClientCallbackHandler - # 성능 최적화 - request.timeout.ms: 30000 - retry.backoff.ms: 1000 - max.in.flight.requests.per.connection: 5 - # SSL 설정 - ssl.endpoint.identification.algorithm: https +# CORS 설정 (prod에서도 dev처럼 허용) +front: + cors: + allowed-origins: + - https://howareu.click + - https://www.howareu.click + - http://localhost:3000 # 개발 편의 + - http://localhost:5173 # 개발 편의 + - http://localhost:8080 # 개발 편의 + +# kafka: +# bootstrap-servers: ${AWS_MSK_BROKERS} +# consumer: +# group-id: ${KAFKA_CONSUMER_GROUP_ID:howru-prod} +# auto-offset-reset: earliest +# key-deserializer: org.apache.kafka.common.serialization.StringDeserializer +# value-deserializer: org.apache.kafka.common.serialization.StringDeserializer +# producer: +# key-serializer: org.apache.kafka.common.serialization.StringSerializer +# value-serializer: org.apache.kafka.common.serialization.StringSerializer +# admin: +# auto-create: false # 프로덕션에서는 수동으로 토픽 관리 +# properties: +# # AWS MSK 보안 설정 +# security.protocol: SASL_SSL +# sasl.mechanism: AWS_MSK_IAM +# sasl.jaas.config: com.amazon.msk.auth.iam.IAMLoginModule required; +# sasl.client.callback.handler.class: com.amazon.msk.auth.iam.IAMClientCallbackHandler +# # 성능 최적화 +# request.timeout.ms: 30000 +# retry.backoff.ms: 1000 +# max.in.flight.requests.per.connection: 5 +# # SSL 설정 +# ssl.endpoint.identification.algorithm: https # 보안 헤더 설정 server: - # ALB 뒤에서 실행되므로 실제 클라이언트 IP를 얻기 위한 설정 - forward-headers-strategy: framework + forward-headers-strategy: NATIVE # HTTPS 리다이렉트 설정 (ALB에서 처리하므로 비활성화) ssl: enabled: false diff --git a/src/main/resources/application.yml b/src/main/resources/application.yml index 9c3f9ea..cd83da2 100644 --- a/src/main/resources/application.yml +++ b/src/main/resources/application.yml @@ -3,7 +3,7 @@ spring: import: optional:file:.env[.properties] profiles: - active: dev + active: prod datasource: url: jdbc:postgresql://${DB_HOST}:${DB_PORT}/${DB_NAME} @@ -26,12 +26,18 @@ spring: min-idle: 0 max-wait: -1ms - session: - store-type: redis - redis: - namespace: spring:session - flush-mode: on_save - timeout: 30m + # Spring Session Redis 제거 - JWT stateless 인증 사용 + # session: + # store-type: redis + # redis: + # namespace: spring:session + # flush-mode: on_save + # timeout: 30m + + # 마이그레이션 설정 + migration: + auto: + enabled: false # true로 설정하면 애플리케이션 시작 시 자동 마이그레이션 security: oauth2: @@ -40,7 +46,7 @@ spring: google: client-id: ${GOOGLE_CLIENT_ID} client-secret: ${GOOGLE_CLIENT_SECRET} - redirect-uri: "https://{baseUrl}/login/oauth2/code/{registrationId}" + redirect-uri: "${GOOGLE_REDIRECT_URI:{baseUrl}/login/oauth2/code/{registrationId}}" scope: - email - profile @@ -102,5 +108,6 @@ nlp: tagging-nlp: base-url: http://localhost:8001 # 새 태깅 서비스(FastAPI analysisTag.py) -server: - forward-headers-strategy: framework \ No newline at end of file +migration: + auto: + enabled: true \ No newline at end of file