diff --git a/build.gradle b/build.gradle index d0cfbd2..e7e9fc7 100644 --- a/build.gradle +++ b/build.gradle @@ -54,6 +54,9 @@ dependencies { // OAuth 2.0 implementation 'org.springframework.boot:spring-boot-starter-oauth2-client' + // Actuator/Health + implementation("org.springframework.boot:spring-boot-starter-actuator") + // lombok compileOnly 'org.projectlombok:lombok' annotationProcessor 'org.projectlombok:lombok' diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index f1a80d0..74d9d3f 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -16,11 +16,11 @@ services: volumes: - ./logs:/app/logs healthcheck: - test: ["CMD-SHELL", "wget -qO- http://localhost:8080/actuator/health | grep -q 'UP' || exit 1"] + test: [ "CMD-SHELL", "wget -qO- http://127.0.0.1:8080/actuator/health | grep -q '\"status\":\"UP\"'" ] interval: 15s timeout: 5s - retries: 10 - start_period: 30s + retries: 20 + start_period: 60s redis: image: redis:7-alpine diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java b/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java index d0d978c..92febb4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/repository/CodeReferenceRepository.java @@ -14,5 +14,4 @@ public interface CodeReferenceRepository extends JpaRepository findByMindmapIdAndId(Long mindmapId, Long id); - boolean existsByMindmapIdAndId(Long mindmapId, Long id); } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java index f664f66..db02871 100644 --- a/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java +++ b/src/main/java/com/teamEWSN/gitdeun/codereference/service/CodeReferenceService.java @@ -127,18 +127,16 @@ public ReferenceResponse updateReference(Long mapId, Long refId, Long userId, Cr @Transactional public void deleteReference(Long mapId, Long refId, Long userId) { - // 1. 권한 확인 + // 권한 확인 if (!mindmapAuthService.hasEdit(mapId, userId)) { throw new GlobalException(ErrorCode.FORBIDDEN_ACCESS); } - // 2. 해당 마인드맵에 코드 참조가 존재하는지 확인 - if (!codeReferenceRepository.existsByMindmapIdAndId(mapId, refId)) { - throw new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND); - } + CodeReference codeReference = codeReferenceRepository.findByMindmapIdAndId(mapId, refId) + .orElseThrow(() -> new GlobalException(ErrorCode.CODE_REFERENCE_NOT_FOUND)); - // 3. 코드 참조 삭제 - codeReferenceRepository.deleteById(refId); + // 코드 참조 삭제 + codeReferenceRepository.delete(codeReference); } private String extractLines(String fullContent, Integer startLine, Integer endLine) { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java index f7db26b..a63c5a4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityConfig.java @@ -7,6 +7,7 @@ import com.teamEWSN.gitdeun.common.oauth.handler.CustomOAuth2SuccessHandler; import com.teamEWSN.gitdeun.common.oauth.service.CustomOAuth2UserService; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; @@ -35,6 +36,9 @@ public class SecurityConfig { private final CustomAuthenticationEntryPoint customAuthenticationEntryPoint; private final CustomAccessDeniedHandler customAccessDeniedHandler; + @Value("${security.require-auth-for-unknown:true}") + private boolean requireAuthForUnknown; + @Bean public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { @@ -53,9 +57,7 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { .oauth2Login((oauth2) -> oauth2 .userInfoEndpoint(userInfoEndpointConfig -> userInfoEndpointConfig .userService(customOAuth2UserService)) -// .defaultSuccessUrl("/oauth/success") // 로그인 성공시 이동할 URL .successHandler(customOAuth2SuccessHandler) -// .failureUrl("/oauth/fail") // 로그인 실패시 이동할 URL .failureHandler(customOAuthFailureHandler)) .logout(logout -> logout .logoutUrl("/logout") @@ -66,16 +68,18 @@ public SecurityFilterChain filterChain(HttpSecurity http) throws Exception { // 경로별 인가 작업 http - .authorizeHttpRequests((auth) -> auth - // 내부 webhook 통신 API - .requestMatchers("/api/webhook/**").permitAll() - // 외부 공개 API(클라이언트 - JWT) - .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") - .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") - .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll() - .anyRequest().permitAll() - // .anyRequest().authenticated() - ); + .authorizeHttpRequests((auth) -> { + auth + .requestMatchers(SecurityPath.ADMIN_ENDPOINTS).hasRole("ADMIN") + .requestMatchers(SecurityPath.USER_ENDPOINTS).hasAnyRole("USER", "ADMIN") + .requestMatchers(SecurityPath.PUBLIC_ENDPOINTS).permitAll(); + + if (requireAuthForUnknown) { + auth.anyRequest().authenticated(); + } else { + auth.anyRequest().permitAll(); + } + }); // 예외 처리 http diff --git a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java index 24d0f9d..820ba05 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/config/SecurityPath.java @@ -7,8 +7,13 @@ public class SecurityPath { public static final String[] PUBLIC_ENDPOINTS = { "/api/auth/token/refresh", "/api/auth/oauth/refresh/*", + "/oauth/logout", "/", + "/api/webhook/**", + "/actuator/health", + "/actuator/health/**", + "/actuator/info" }; // hasRole("USER") @@ -27,8 +32,10 @@ public class SecurityPath { "/api/skills/**", "/api/recruitments/**", "/api/applications/**", + "/api/comments/**", + "/api/code-reviews/**", + "/api/references/**", "/api/s3/bucket/**", - "/api/webhook/**" }; // hasRole("ADMIN") diff --git a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java index b9d9390..6e9b704 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/fastapi/FastApiClient.java @@ -313,14 +313,13 @@ public String getFileRaw(String repoUrl, String filePath, Integer startLine, Int }*/ public String getCodeFromNode(String nodeKey, String filePath, String authHeader) { - String fileName = extractFileName(filePath); try { - log.debug("FastAPI 노드 기반 코드 조회 시작 - nodeKey: {}, filePath: {}", nodeKey, fileName); + log.debug("FastAPI 노드 기반 코드 조회 시작 - nodeKey: {}, filePath: {}", nodeKey, filePath); UriComponentsBuilder uriBuilder = UriComponentsBuilder .fromPath("/content/file/by-node") // FastAPI의 해당 엔드포인트 .queryParam("node_key", nodeKey) - .queryParam("file_path", fileName); + .queryParam("file_path", filePath); // 'prefer' 파라미터는 생략하여 FastAPI의 기본값(auto)을 따르도록 함 String uri = uriBuilder.build().toUriString(); @@ -336,8 +335,8 @@ public String getCodeFromNode(String nodeKey, String filePath, String authHeader .onStatus(HttpStatusCode::is4xxClientError, clientResponse -> clientResponse.bodyToMono(String.class) .map(errorBody -> { - log.warn("FastAPI 노드 코드 조회 4xx 오류 - nodeKey: {}, fileName: {}, status: {}, body: {}", - nodeKey, fileName, clientResponse.statusCode(), errorBody); + log.warn("FastAPI 노드 코드 조회 4xx 오류 - nodeKey: {}, filePath: {}, status: {}, body: {}", + nodeKey, filePath, clientResponse.statusCode(), errorBody); return new RuntimeException("FastAPI 클라이언트 오류: " + errorBody); }) ) @@ -346,13 +345,13 @@ public String getCodeFromNode(String nodeKey, String filePath, String authHeader .block(); String codeContent = (response != null) ? response.getCode() : ""; - log.debug("FastAPI 노드 기반 코드 조회 성공 - nodeKey: {}, fileName: {}, 길이: {}", - nodeKey, fileName, codeContent != null ? codeContent.length() : 0); + log.debug("FastAPI 노드 기반 코드 조회 성공 - nodeKey: {}, filePath: {}, 길이: {}", + nodeKey, filePath, codeContent != null ? codeContent.length() : 0); return codeContent; } catch (Exception e) { - log.error("FastAPI 노드 기반 코드 조회 실패 - nodeKey: {}, fileName: {}", nodeKey, fileName, e); + log.error("FastAPI 노드 기반 코드 조회 실패 - nodeKey: {}, filePath: {}", nodeKey, filePath, e); return ""; // 예외 발생 시 빈 문자열 반환 } } @@ -365,11 +364,6 @@ private String extractMapId(String repoUrl) { return segments[segments.length - 1].replaceAll("\\.git$", ""); } - // 파일명 추출 - public String extractFileName(String filePath) { - return filePath.substring(filePath.lastIndexOf('/') + 1); - } - private AnalysisResultDto buildAnalysisResultDto( RepoInfoResponse repoInfo ) { diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java index d934856..3ebbd91 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/CustomOAuth2UserService.java @@ -34,6 +34,7 @@ public class CustomOAuth2UserService extends DefaultOAuth2UserService { private final GitHubApiHelper gitHubApiHelper; + private final GoogleApiHelper googleApiHelper; private final SocialTokenRefreshService socialTokenRefreshService; private final UserRepository userRepository; private final SocialConnectionRepository socialConnectionRepository; @@ -67,7 +68,11 @@ public User processUser(OAuth2User oAuth2User, OAuth2UserRequest userRequest) { .map(conn -> { // provider 별 refresh 정책 socialTokenRefreshService.refreshSocialToken(conn, accessToken, refreshToken); - return conn.getUser(); + + // 사용자 정보 갱신 + User user = conn.getUser(); + user.updateProfile(dto.getName(), dto.getProfileImageUrl()); + return user; }) .orElseGet(() -> createOrConnect(dto, provider, providerId, accessToken, refreshToken)); } @@ -79,8 +84,24 @@ private OAuth2ResponseDto getOAuth2ResponseDto(OAuth2User oAuth2User, OAuth2User Map attr = new HashMap<>(oAuth2User.getAttributes()); if (registrationId.equalsIgnoreCase("google")) { + String accessToken = userRequest.getAccessToken().getTokenValue(); + + // 만료 시 토큰 리프레시 후 최신 userinfo 재조회 + if (googleApiHelper.isExpired(accessToken)) { + String refreshToken = (String) userRequest.getAdditionalParameters().get("refresh_token"); + if (refreshToken != null) { + var tokenResp = googleApiHelper.refreshToken(refreshToken); + accessToken = tokenResp.accessToken(); + } + } + + // 항상 최신 userinfo로 덮어쓰기 (이름 변경 즉시 반영) + Map latestInfo = googleApiHelper.fetchLatestUserInfo(accessToken); + attr.putAll(latestInfo); + return new GoogleResponseDto(attr); } + if (registrationId.equalsIgnoreCase("github")) { /* ① 기본 프로필에 e-mail 없으면 /user/emails 호출 */ if (attr.get("email") == null) { @@ -105,6 +126,7 @@ private User createOrConnect(OAuth2ResponseDto response, OauthProvider provider, return userRepository.findByEmailAndDeletedAtIsNull(response.getEmail()) .map(user -> { // 사용자가 존재하면, 새 소셜 계정을 연결 + user.updateProfile(response.getName(), response.getProfileImageUrl()); connectSocialAccount(user, provider, providerId, accessToken, refreshToken); return user; }) @@ -150,6 +172,7 @@ private void connectSocialAccount(User user, OauthProvider provider, String prov .accessToken(accessToken) .refreshToken(refreshToken) .build(); + socialConnectionRepository.save(connection); } diff --git a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java index 753a0cd..d7dcfd4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java +++ b/src/main/java/com/teamEWSN/gitdeun/common/oauth/service/GoogleApiHelper.java @@ -15,6 +15,8 @@ import org.springframework.web.reactive.function.client.WebClientResponseException; import reactor.core.publisher.Mono; +import java.util.Map; + @Slf4j @Component @@ -96,6 +98,26 @@ protected GoogleTokenResponse refreshToken(String refreshToken) { } } + /** + * access token 으로 최신 프로필(userinfo) 조회 + * (name, picture, email 등) + */ + public Map fetchLatestUserInfo(String accessToken) { + try { + return webClient.get() + .uri("https://www.googleapis.com/oauth2/v2/userinfo") + .header("Authorization", "Bearer " + accessToken) + .retrieve() + .bodyToMono(Map.class) + .block(); + } catch (WebClientResponseException e) { + log.error("Google userinfo 조회 실패: {}", e.getResponseBodyAsString()); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } catch (Exception e) { + log.error("Google userinfo 조회 중 오류: {}", e.getMessage()); + throw new GlobalException(ErrorCode.OAUTH_COMMUNICATION_FAILED); + } + } public Mono revokeToken(String accessToken) { String revokeUrl = "https://accounts.google.com/o/oauth2/revoke"; diff --git a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java index fdcad7a..a226046 100644 --- a/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java +++ b/src/main/java/com/teamEWSN/gitdeun/mindmap/service/MindmapSseService.java @@ -1,19 +1,21 @@ package com.teamEWSN.gitdeun.mindmap.service; +import com.fasterxml.jackson.core.JsonProcessingException; import com.fasterxml.jackson.databind.ObjectMapper; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; import com.teamEWSN.gitdeun.mindmap.dto.MindmapDetailResponseDto; import com.teamEWSN.gitdeun.mindmap.dto.prompt.PromptPreviewResponseDto; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; +import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; import java.io.IOException; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.CopyOnWriteArrayList; +import java.util.concurrent.TimeUnit; import java.util.stream.Collectors; @Slf4j @@ -22,7 +24,7 @@ public class MindmapSseService { private final ObjectMapper objectMapper; - + private final RedisTemplate redisTemplate; private record SseConnection(Long userId, String nickname, String profileImage, SseEmitter emitter) {} private final Map> connectionsByMapId = new ConcurrentHashMap<>(); @@ -45,19 +47,27 @@ public SseEmitter createConnection(Long mapId, CustomUserDetails userDetails) { connectionsByMapId.computeIfAbsent(mapId, k -> new CopyOnWriteArrayList<>()).add(connection); + try { + // Redis Set에 현재 접속자 정보 추가 + String redisKey = "mindmap:" + mapId + ":users"; + String userData = objectMapper.writeValueAsString( + new ConnectedUserDto(userDetails.getId(), userDetails.getNickname(), userDetails.getProfileImage()) + ); + redisTemplate.opsForSet().add(redisKey, userData); + + // Redis Set의 만료 시간을 설정하여 비정상 종료된 연결 처리 + redisTemplate.expire(redisKey, 1, TimeUnit.HOURS); + + } catch (JsonProcessingException e) { + log.error("사용자 정보 직렬화 실패", e); + } + // 연결 종료 시 정리 (기존 로직 개선) - emitter.onCompletion(() -> { - removeConnection(mapId, connection); - broadcastUserListUpdate(mapId); - }); - emitter.onTimeout(() -> { - removeConnection(mapId, connection); - broadcastUserListUpdate(mapId); - }); + emitter.onCompletion(() -> removeConnection(mapId, connection)); + emitter.onTimeout(() -> removeConnection(mapId, connection)); emitter.onError(throwable -> { log.error("SSE 연결 오류 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, userDetails.getId(), throwable); removeConnection(mapId, connection); - broadcastUserListUpdate(mapId); }); // 연결 확인용 초기 메시지 @@ -158,14 +168,20 @@ private void sendToEmitter(SseEmitter emitter, Object data) { // 연결 종료 시 SseConnection 객체를 찾아 제거 private void removeConnection(Long mapId, SseConnection connection) { List connections = connectionsByMapId.get(mapId); - if (connections != null) { - connections.remove(connection); - log.info("SSE 연결 해제 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, connection.userId()); - if (connections.isEmpty()) { - connectionsByMapId.remove(mapId); - log.info("마인드맵 ID {} 의 모든 구독자 연결 종료", mapId); + if (connections != null && connections.remove(connection)) { + try { + // Redis Set에서 접속 종료한 사용자 정보 제거 + String redisKey = "mindmap:" + mapId + ":users"; + String userData = objectMapper.writeValueAsString( + new ConnectedUserDto(connection.userId(), connection.nickname(), connection.profileImage()) + ); + redisTemplate.opsForSet().remove(redisKey, userData); + + } catch (JsonProcessingException e) { + log.error("사용자 정보 직렬화 실패 (연결 종료)", e); } broadcastUserListUpdate(mapId); + log.info("SSE 연결 해제 및 Redis 업데이트 - 마인드맵 ID: {}, 사용자 ID: {}", mapId, connection.userId()); } } @@ -188,13 +204,23 @@ public record ConnectedUserDto(Long userId, String nickname, String profileImage // 현재 접속 중인 사용자 목록을 반환하는 서비스 메서드 public List getConnectedUsers(Long mapId) { - List connections = connectionsByMapId.getOrDefault(mapId, new CopyOnWriteArrayList<>()); + String redisKey = "mindmap:" + mapId + ":users"; + Set usersJson = redisTemplate.opsForSet().members(redisKey); - // 중복된 userId를 제거하고 DTO로 변환하여 반환 (여러 탭 접속 시 중복 방지) - return connections.stream() - .map(conn -> new ConnectedUserDto(conn.userId(), conn.nickname(), conn.profileImage())) - .distinct() // userId 기준으로 중복 제거 + if (usersJson == null) { + return Collections.emptyList(); + } + + return usersJson.stream() + .map(json -> { + try { + return objectMapper.readValue(json, ConnectedUserDto.class); + } catch (JsonProcessingException e) { + log.error("사용자 정보 역직렬화 실패", e); + return null; + } + }) + .filter(Objects::nonNull) .collect(Collectors.toList()); } - } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/recruitment/controller/RecruitmentController.java b/src/main/java/com/teamEWSN/gitdeun/recruitment/controller/RecruitmentController.java index 6c4ad01..935d173 100644 --- a/src/main/java/com/teamEWSN/gitdeun/recruitment/controller/RecruitmentController.java +++ b/src/main/java/com/teamEWSN/gitdeun/recruitment/controller/RecruitmentController.java @@ -5,6 +5,7 @@ import com.teamEWSN.gitdeun.recruitment.entity.RecruitmentStatus; import com.teamEWSN.gitdeun.recruitment.service.RecruitmentService; import com.teamEWSN.gitdeun.common.jwt.CustomUserDetails; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import jakarta.validation.Valid; import lombok.RequiredArgsConstructor; import org.springframework.data.domain.Page; @@ -85,9 +86,10 @@ public ResponseEntity> searchRecruitments( @RequestParam(required = false) String keyword, @RequestParam(required = false) RecruitmentStatus status, @RequestParam(required = false) List field, + @RequestParam(required = false) List languages, @PageableDefault(size = 10) Pageable pageable ) { - Page response = recruitmentService.searchRecruitments(keyword, status, field, pageable); + Page response = recruitmentService.searchRecruitments(keyword, status, field, languages, pageable); return ResponseEntity.ok(response); } diff --git a/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentCustomRepository.java b/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentCustomRepository.java index f17c3e4..27bf2de 100644 --- a/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentCustomRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentCustomRepository.java @@ -3,6 +3,7 @@ import com.teamEWSN.gitdeun.recruitment.entity.Recruitment; import com.teamEWSN.gitdeun.recruitment.entity.RecruitmentField; import com.teamEWSN.gitdeun.recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import org.springframework.data.domain.Page; import org.springframework.data.domain.Pageable; @@ -13,6 +14,7 @@ Page searchRecruitments( String keyword, RecruitmentStatus status, List fields, + List languages, Pageable pageable ); } diff --git a/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentRepositoryImpl.java b/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentRepositoryImpl.java index 06bf211..9b51bc4 100644 --- a/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentRepositoryImpl.java +++ b/src/main/java/com/teamEWSN/gitdeun/recruitment/repository/RecruitmentRepositoryImpl.java @@ -7,6 +7,7 @@ import com.teamEWSN.gitdeun.recruitment.entity.Recruitment; import com.teamEWSN.gitdeun.recruitment.entity.RecruitmentField; import com.teamEWSN.gitdeun.recruitment.entity.RecruitmentStatus; +import com.teamEWSN.gitdeun.userskill.entity.DeveloperSkill; import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; import org.springframework.data.domain.Page; @@ -33,26 +34,32 @@ public class RecruitmentRepositoryImpl implements RecruitmentCustomRepository { @Override public Page searchRecruitments( - String keyword, RecruitmentStatus status, List fields, Pageable pageable + String keyword, RecruitmentStatus status, List fields, List languages, Pageable pageable ) { // 키워드 전처리 후 Full-Text Search 활용 여부 확인 String processed = preprocessKeyword(keyword); - // 키워드 없으면: 키워드 조건 없이 status/fields만으로 페이지 조회 + // 키워드 없으면: 키워드 조건 없이 status/fields/languages 으로 페이지 조회 boolean hasKeyword = (processed != null); - boolean useFullTextSearch = hasKeyword && isFullTextSearchAvailable(processed); - BooleanExpression keywordExpr = null; + boolean useFullTextSearch = false; + if (hasKeyword) { - keywordExpr = useFullTextSearch ? titleFullTextSearch(processed) - : fallbackContains(processed); + if (processed.length() == 1) { + keywordExpr = titleExactMatch(processed); // 한 글자: 완전 일치 검색 + } else { + useFullTextSearch = isFullTextSearchAvailable(processed); + keywordExpr = useFullTextSearch ? titleFullTextSearch(processed) // 두 글자 이상: FTS 또는 Contains + : fallbackContains(processed); + } } + // 엔티티 자체 데이터 조회 // id 페이지닝 List ids = queryFactory.select(recruitment.id).distinct() .from(recruitment) - .where(keywordExpr, statusEq(status), fieldOrFilter(fields)) + .where(keywordExpr, statusEq(status), fieldOrFilter(fields), languageOrFilter(languages)) .orderBy(useFullTextSearch ? scoreOrder(processed) : recruitment.id.desc()) .offset(pageable.getOffset()) .limit(pageable.getPageSize()) @@ -60,7 +67,7 @@ public Page searchRecruitments( if (ids.isEmpty()) return Page.empty(pageable); - // 내용 로딩 + 순서 복원 + // ID 목록으로 전체 데이터 조회 String idCsv = ids.stream().map(String::valueOf).collect(Collectors.joining(",")); List content = queryFactory.selectFrom(recruitment) .where(recruitment.id.in(ids)) @@ -68,10 +75,10 @@ public Page searchRecruitments( "FIELD({0}, " + idCsv + ")", recruitment.id).asc()) .fetch(); - // 카운팅 + // 전체 카운트 조회 Long total = queryFactory.select(recruitment.id.countDistinct()) .from(recruitment) - .where(keywordExpr, statusEq(status), fieldOrFilter(fields)) + .where(keywordExpr, statusEq(status), fieldOrFilter(fields), languageOrFilter(languages)) .fetchOne(); return new PageImpl<>(content, pageable, total != null ? total : 0L); @@ -102,12 +109,17 @@ private String preprocessKeyword(String keyword) { } // 길이 제한 - if (s.length() < 2) return null; + if (s.isEmpty()) return null; if (s.length() > 30) s = s.substring(0, 30); return s; } + // 제목 완전 일치 검색(한글자용) + private BooleanExpression titleExactMatch(String keyword) { + return recruitment.title.eq(keyword); + } + // Full Text Search 조건 (전처리 이후 동작) private boolean isFullTextSearchAvailable(String keyword) { if (!StringUtils.hasText(keyword)) { @@ -190,4 +202,10 @@ private BooleanExpression fieldOrFilter(List fields) { ? null : recruitment.fieldTags.any().in(fields); } + + private BooleanExpression languageOrFilter(List languages) { + return CollectionUtils.isEmpty(languages) + ? null + : recruitment.languageTags.any().in(languages); + } } \ No newline at end of file diff --git a/src/main/java/com/teamEWSN/gitdeun/recruitment/service/RecruitmentService.java b/src/main/java/com/teamEWSN/gitdeun/recruitment/service/RecruitmentService.java index bae53ce..7edb626 100644 --- a/src/main/java/com/teamEWSN/gitdeun/recruitment/service/RecruitmentService.java +++ b/src/main/java/com/teamEWSN/gitdeun/recruitment/service/RecruitmentService.java @@ -116,8 +116,8 @@ public RecruitmentDetailResponseDto getRecruitment(Long recruitmentId) { * @return 페이징 처리된 검색 결과 목록 */ @Transactional(readOnly = true) - public Page searchRecruitments(String keyword, RecruitmentStatus status, List fields, Pageable pageable) { - return recruitmentRepository.searchRecruitments(keyword, status, fields, pageable) + public Page searchRecruitments(String keyword, RecruitmentStatus status, List fields, List languages, Pageable pageable) { + return recruitmentRepository.searchRecruitments(keyword, status, fields, languages, pageable) .map(recruitmentMapper::toListResponseDto); } diff --git a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java index 7ce92f3..6e5b186 100644 --- a/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java +++ b/src/main/java/com/teamEWSN/gitdeun/user/repository/UserRepository.java @@ -18,7 +18,4 @@ public interface UserRepository extends JpaRepository { // user email로 검색 Optional findByEmailAndDeletedAtIsNull(String email); - - // 닉네임으로 사용자 찾기 - Optional findByNicknameAndDeletedAtIsNull(String nickname); } \ No newline at end of file diff --git a/src/main/resources/application-dev.yml b/src/main/resources/application-dev.yml index 37d7e1b..44336ec 100644 --- a/src/main/resources/application-dev.yml +++ b/src/main/resources/application-dev.yml @@ -21,7 +21,8 @@ spring: # user-info-uri: https://www.googleapis.com/oauth2/v3/userinfo # user-name-attribute: sub - +security: + require-auth-for-unknown: false jwt: access-expired: 28800 # 8시간 refresh-expired: 86400 # 1일 diff --git a/src/main/resources/application-prod.yml b/src/main/resources/application-prod.yml index cf65f9a..b9f0ae7 100644 --- a/src/main/resources/application-prod.yml +++ b/src/main/resources/application-prod.yml @@ -31,4 +31,16 @@ app: same-site: None fastapi: - base-url: http://fastapi-server:8000 # Docker 네트워크 내부에서 사용할 주소 \ No newline at end of file + base-url: http://172.31.47.81:8000 # Docker 네트워크 내부에서 사용할 주소 + +security: + require-auth-for-unknown: false +management: + endpoints: + web: + exposure: + include: health,info + endpoint: + health: + probes: + enabled: true \ No newline at end of file