diff --git a/src/main/java/com/_data/_data/common/util/language/UserLanguageResolver.java b/src/main/java/com/_data/_data/common/util/language/UserLanguageResolver.java new file mode 100644 index 0000000..2298e4d --- /dev/null +++ b/src/main/java/com/_data/_data/common/util/language/UserLanguageResolver.java @@ -0,0 +1,61 @@ +package com._data._data.common.util.language; + +import com._data._data.auth.entity.CustomUserDetails; +import com._data._data.user.entity.Users; +import com._data._data.user.repository.UserRepository; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; +import org.springframework.security.core.Authentication; +import org.springframework.security.core.context.SecurityContextHolder; + +/** + * 사용자에 따른 언어 설정 조회 + */ +@Slf4j +@Component +public class UserLanguageResolver { + private static final String DEFAULT_LANGUAGE = "ko"; + + private final UserRepository userRepository; + + public UserLanguageResolver(UserRepository userRepository) { + this.userRepository = userRepository; + } + + /** + * 언어 설정 반환 + * */ + public String getCurrentUserLanguage() { + Authentication auth = SecurityContextHolder.getContext().getAuthentication(); + + if (isAnonymousUser(auth)) { + log.debug("익명 사용자, 기본 언어 '{}' 사용", DEFAULT_LANGUAGE); + return DEFAULT_LANGUAGE; + } + + return getAuthenticatedUserLanguage(auth); + } + + /** + * 익명 유저면 기본값 반환 + * */ + private boolean isAnonymousUser(Authentication auth) { + return auth == null + || !auth.isAuthenticated() + || !(auth.getPrincipal() instanceof CustomUserDetails); + } + + /** + * 로그인 사용자 설정언어 반환 + * */ + private String getAuthenticatedUserLanguage(Authentication auth) { + CustomUserDetails userDetails = (CustomUserDetails) auth.getPrincipal(); + String email = userDetails.getUsername(); + + Users user = userRepository.findByEmailAndIsDeletedFalse(email); + String language = user.getTranslationLang(); + + log.debug("사용자={} 언어 설정={}", email, language); + return language != null ? language : DEFAULT_LANGUAGE; + } +} diff --git a/src/main/java/com/_data/_data/common/util/translation/MultiLanguageTitleSelector.java b/src/main/java/com/_data/_data/common/util/translation/MultiLanguageTitleSelector.java new file mode 100644 index 0000000..8c4df31 --- /dev/null +++ b/src/main/java/com/_data/_data/common/util/translation/MultiLanguageTitleSelector.java @@ -0,0 +1,42 @@ +package com._data._data.common.util.translation; + +import com._data._data.eduinfo.entity.EduProgram; +import org.springframework.stereotype.Component; + +/** + * 다국어 제목 선택을 위한 전략 패턴 구현 + */ + +@Component + public class MultiLanguageTitleSelector { + /** + * 사용자 언어에 따라 적절한 제목을 선택 + * + * @param program 교육 프로그램 엔티티 + * @param language 사용자 언어 코드 + * @return 선택된 언어의 제목 + */ + public String selectTitle(EduProgram program, String language) { + if (program == null || language == null) { + return program != null ? program.getTitleNm() : null; + } + + return switch (language.toLowerCase()) { + case "en", "en-us", "en-gb" -> getValueOrDefault(program.getTitleEn(), program.getTitleNm()); + case "zh", "zh-cn", "zh-tw" -> getValueOrDefault(program.getTitleZh(), program.getTitleNm()); + case "ja", "jp" -> getValueOrDefault(program.getTitleJa(), program.getTitleNm()); + case "vi", "vn" -> getValueOrDefault(program.getTitleVi(), program.getTitleNm()); + case "id" -> getValueOrDefault(program.getTitleId(), program.getTitleNm()); + default -> program.getTitleNm(); + }; + } + + /** + * 번역된 값이 없으면 기본값을 반환 + */ + private String getValueOrDefault(String translatedValue, String defaultValue) { + return (translatedValue != null && !translatedValue.isBlank()) + ? translatedValue + : defaultValue; + } +} diff --git a/src/main/java/com/_data/_data/community/repository/CommentRepository.java b/src/main/java/com/_data/_data/community/repository/CommentRepository.java index 92e2000..4afa27c 100644 --- a/src/main/java/com/_data/_data/community/repository/CommentRepository.java +++ b/src/main/java/com/_data/_data/community/repository/CommentRepository.java @@ -3,9 +3,11 @@ import com._data._data.community.entity.Post; import com._data._data.community.entity.Comment; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; import org.springframework.stereotype.Repository; import java.util.List; import com._data._data.user.entity.Users; +import org.springframework.data.repository.query.Param; @Repository public interface CommentRepository extends JpaRepository { @@ -14,4 +16,10 @@ public interface CommentRepository extends JpaRepository { List findByPostOrderByCreatedAtAsc(Post post); List findByPostOrderByCreatedAtDesc(Post post); + // 포스트의 댓글들을 댓글 작성자 정보와 함께 조회 + @Query("SELECT c FROM Comment c " + + "JOIN FETCH c.commenter " + + "WHERE c.post = :post " + + "ORDER BY c.createdAt ASC") + List findByPostWithCommenterOrderByCreatedAtAsc(@Param("post") Post post); } diff --git a/src/main/java/com/_data/_data/community/repository/FollowRepository.java b/src/main/java/com/_data/_data/community/repository/FollowRepository.java index 9d2fa74..aeee12d 100644 --- a/src/main/java/com/_data/_data/community/repository/FollowRepository.java +++ b/src/main/java/com/_data/_data/community/repository/FollowRepository.java @@ -6,6 +6,8 @@ import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -26,4 +28,9 @@ public interface FollowRepository extends JpaRepository { @Transactional void deleteByFollowee(Users followee); + // 특정 팔로워가 여러 유저들을 팔로우하는지 한 번에 조회 + @Query("SELECT f FROM Follow f WHERE f.follower = :follower AND f.followee.id IN :followeeIds") + List findByFollowerAndFolloweeIdIn(@Param("follower") Users follower, @Param("followeeIds") List followeeIds); + + } diff --git a/src/main/java/com/_data/_data/community/repository/LikeRepository.java b/src/main/java/com/_data/_data/community/repository/LikeRepository.java index 62f55c5..bde2062 100644 --- a/src/main/java/com/_data/_data/community/repository/LikeRepository.java +++ b/src/main/java/com/_data/_data/community/repository/LikeRepository.java @@ -3,9 +3,12 @@ import com._data._data.community.entity.Like; import com._data._data.community.entity.Post; import com._data._data.user.entity.Users; +import java.util.List; import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; import org.springframework.transaction.annotation.Transactional; @@ -19,4 +22,9 @@ public interface LikeRepository extends JpaRepository { @Modifying @Transactional void deleteByUser(Users user); + + // 여러 포스트에 대한 한 유저의 좋아요 조회 + @Query("SELECT l FROM Like l WHERE l.post.id IN :postIds AND l.user = :user") + List findByPostIdInAndUser(@Param("postIds") List postIds, @Param("user") Users user); + } diff --git a/src/main/java/com/_data/_data/community/repository/PostRepository.java b/src/main/java/com/_data/_data/community/repository/PostRepository.java index acd7d4e..4f556bc 100644 --- a/src/main/java/com/_data/_data/community/repository/PostRepository.java +++ b/src/main/java/com/_data/_data/community/repository/PostRepository.java @@ -3,15 +3,45 @@ import com._data._data.community.entity.Post; import com._data._data.user.entity.Users; import java.util.List; +import java.util.Optional; import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Query; +import org.springframework.data.repository.query.Param; import org.springframework.stereotype.Repository; + @Repository public interface PostRepository extends JpaRepository { List findByAuthor(Users author); - List findAllByOrderByCreatedAtDesc(); - List findByAuthorInOrderByCreatedAtDesc(List authors); - List findByAuthor_NationsOrderByCreatedAtDesc(Long nations); - List findByAuthorOrderByCreatedAtDesc(Users author); + // 모든 포스트를 Author + Nation과 함께 조회 + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.author " + + "ORDER BY p.createdAt DESC") + List findAllWithAuthorAndNation(); + + // 특정 작성자들의 포스트를 Author + Nation과 함께 조회 + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.author a " + + "WHERE a IN :authors " + + "ORDER BY p.createdAt DESC") + List findByAuthorInWithAuthorAndNation(@Param("authors") List authors); + + // 특정 국가의 포스트를 Author + Nation과 함께 조회 + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.author " + + "WHERE p.author.nations = :nationId " + + "ORDER BY p.createdAt DESC") + List findByAuthorNationsWithAuthorAndNation(@Param("nationId") Long nationId); + + // 특정 작성자의 포스트를 Author + Nation과 함께 조회 + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.author " + + "WHERE p.author = :author " + + "ORDER BY p.createdAt DESC") + List findByAuthorWithAuthorAndNation(@Param("author") Users author); + @Query("SELECT p FROM Post p " + + "JOIN FETCH p.author " + + "WHERE p.id = :postId") + Optional findByIdWithAuthor(@Param("postId") Long postId); } diff --git a/src/main/java/com/_data/_data/community/service/PostService.java b/src/main/java/com/_data/_data/community/service/PostService.java index 5f24c8d..063ba8e 100644 --- a/src/main/java/com/_data/_data/community/service/PostService.java +++ b/src/main/java/com/_data/_data/community/service/PostService.java @@ -12,6 +12,9 @@ import com._data._data.user.entity.Nation; import com._data._data.user.service.NationService; import jakarta.persistence.EntityNotFoundException; +import java.util.Map; +import java.util.Set; +import java.util.stream.Collectors; import org.springframework.transaction.annotation.Transactional; import java.io.IOException; import java.util.List; @@ -79,17 +82,6 @@ public void deletePost(Users user, Long postId) { postRepository.delete(post); } - public List getAllPosts(Users currentUser) { - return postRepository.findAllByOrderByCreatedAtDesc() - .stream() - .map(post -> { - boolean isLiked = currentUser != null && - likeRepository.existsByPostAndUser(post, currentUser); - return PostDto.fromWithLiked(post, isLiked); - }) - .toList(); - } - /** * Firebase Storage URL에서 파일 경로 추출 * 예: https://storage.googleapis.com/bucket/post/filename.jpg -> post/filename.jpg @@ -113,13 +105,15 @@ private String extractFileNameFromUrl(String url) { return null; } + @Transactional(readOnly = true) public List getPostsByUser(Users user, Users currentUser) { - return postRepository.findByAuthorOrderByCreatedAtDesc(user).stream() - .map(post -> { - boolean isLiked = currentUser != null && - likeRepository.existsByPostAndUser(post, currentUser); - return PostDto.fromWithLiked(post, isLiked); - }) + List posts = postRepository.findByAuthorWithAuthorAndNation(user); + if (posts.isEmpty()) { + return List.of(); + } + Map likeStatusMap = getLikeStatusMap(posts, currentUser); + return posts.stream() + .map(post -> PostDto.fromWithLiked(post, likeStatusMap.get(post.getId()))) .toList(); } @@ -141,6 +135,29 @@ public CommentDto addComment(Users user, Long postId, String content) { return CommentDto.from(comment); } + // like 배치 조회 + private Map getLikeStatusMap(List posts, Users currentUser) { + if (currentUser == null) { + return posts.stream() + .collect(Collectors.toMap(Post::getId, post -> false)); + } + + List postIds = posts.stream() + .map(Post::getId) + .toList(); + + List likes = likeRepository.findByPostIdInAndUser(postIds, currentUser); + Set likedPostIds = likes.stream() + .map(like -> like.getPost().getId()) + .collect(Collectors.toSet()); + + return posts.stream() + .collect(Collectors.toMap( + Post::getId, + post -> likedPostIds.contains(post.getId()) + )); + } + public void likePost(Users user, Long postId) { Post post = postRepository.findById(postId) .orElseThrow(() -> new EntityNotFoundException("포스트 없음")); @@ -170,35 +187,7 @@ public void unlikePost(Users user, Long postId) { postRepository.save(post); } - @Transactional(readOnly = true) - public List getFollowingTimeline(Users user) { - List followees = followRepository.findByFollower(user).stream() - .map(Follow::getFollowee) - .toList(); - if (followees.isEmpty()) { - return List.of(); - } - return postRepository.findByAuthorInOrderByCreatedAtDesc(followees) - .stream() - .map(post -> { - boolean isLiked = likeRepository.existsByPostAndUser(post, user); - return PostDto.fromWithLiked(post, isLiked); - }) - .toList(); - } - - public List getNationTimeline(Users user) { - return postRepository.findByAuthor_NationsOrderByCreatedAtDesc(user.getNations()) - .stream() - .map(post -> { - boolean isLiked = likeRepository.existsByPostAndUser(post, user); - return PostDto.fromWithLiked(post, isLiked); - }) - .toList(); - } - - - // 🔹 유저 ID를 통해 프로필 조회 (다른 유저의 프로필을 조회할 때 사용) + // 유저 ID를 통해 프로필 조회 (다른 유저의 프로필을 조회할 때 사용) public ProfileDto getProfile(Users currentUser, Long targetUserId) { Users targetUser = userRepository.findById(targetUserId) .orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다")); @@ -206,7 +195,6 @@ public ProfileDto getProfile(Users currentUser, Long targetUserId) { } public ProfileDto getProfile(Users currentUser, Users targetUser) { - // 🔥 null 체크 추가 boolean isFollowing = currentUser != null && !currentUser.equals(targetUser) && followRepository.existsByFollowerAndFollowee(currentUser, targetUser); @@ -224,154 +212,209 @@ public ProfileDto getProfile(Users currentUser, Users targetUser) { @Transactional(readOnly = true) public List getFollowingTimelineDetailed(Users currentUser) { List followees = followRepository.findByFollower(currentUser) - .stream().map(f -> f.getFollowee()).toList(); + .stream().map(Follow::getFollowee).toList(); if (followees.isEmpty()) { return List.of(); } - // Fetch posts by followees - List posts = postRepository.findByAuthorInOrderByCreatedAtDesc(followees); + List posts = postRepository.findByAuthorInWithAuthorAndNation(followees); - return posts.stream().map(post -> { - boolean isFollowing = true; // by definition, author is followed - boolean isLiked = likeRepository.existsByPostAndUser(post, currentUser); + if (posts.isEmpty()) { + return List.of(); + } + + Map likeStatusMap = getLikeStatusMap(posts, currentUser); - // 🔥 수정: PostDto에도 isLiked 적용 - PostDto dto = PostDto.fromWithLiked(post, isLiked); + return posts.stream().map(post -> { + boolean isLiked = likeStatusMap.get(post.getId()); + PostDto postDto = PostDto.fromWithLiked(post, isLiked); - // 🔥 국가 정보 추가 - Nation nation = nationService.getNationById(post.getAuthor().getNations()); + Users author = post.getAuthor(); + Nation nation = nationService.getNationById(author.getNations()); String nationName = nation != null ? nation.getName() : "Unknown"; String nationNameKo = nation != null ? nation.getNameKo() : "알 수 없음"; - var authorProfile = new PostAuthorProfileDto( - post.getAuthor().getId(), - post.getAuthor().getName(), - post.getAuthor().getProfileImage(), - (long) post.getAuthor().getPosts().size(), - (long) post.getAuthor().getFollowers().size(), - (long) post.getAuthor().getFollowing().size(), - isFollowing, - nationName, // 🔥 추가 - nationNameKo // 🔥 추가 + PostAuthorProfileDto authorProfile = new PostAuthorProfileDto( + author.getId(), + author.getName(), + author.getProfileImage(), + (long) author.getPosts().size(), + (long) author.getFollowers().size(), + (long) author.getFollowing().size(), + true, // 팔로잉 타임라인이므로 항상 true + nationName, + nationNameKo ); - return new PostWithAuthorProfileDto(dto, authorProfile); + return new PostWithAuthorProfileDto(postDto, authorProfile); }).toList(); } + /** * 국가별 타임라인: Post + author profile + isLiked */ @Transactional(readOnly = true) public List getNationTimelineDetailed(Users currentUser) { - List posts = postRepository.findByAuthor_NationsOrderByCreatedAtDesc(currentUser.getNations()); + List posts = postRepository.findByAuthorNationsWithAuthorAndNation(currentUser.getNations()); - return posts.stream().map(post -> { - boolean isFollowing = followRepository.existsByFollowerAndFollowee(currentUser, post.getAuthor()); - boolean isLiked = likeRepository.existsByPostAndUser(post, currentUser); + if (posts.isEmpty()) { + return List.of(); + } - PostDto dto = PostDto.fromWithLiked(post, isLiked); + Map likeStatusMap = getLikeStatusMap(posts, currentUser); + List authors = posts.stream() + .map(Post::getAuthor) + .distinct() + .toList(); + Map followStatusMap = getFollowStatusMap(authors, currentUser); + + return posts.stream().map(post -> { + boolean isLiked = likeStatusMap.get(post.getId()); + boolean isFollowing = followStatusMap.get(post.getAuthor().getId()); + PostDto postDto = PostDto.fromWithLiked(post, isLiked); - // 🔥 국가 정보 추가 - Nation nation = nationService.getNationById(post.getAuthor().getNations()); + Users author = post.getAuthor(); + Nation nation = nationService.getNationById(author.getNations()); String nationName = nation != null ? nation.getName() : "Unknown"; String nationNameKo = nation != null ? nation.getNameKo() : "알 수 없음"; - var authorProfile = new PostAuthorProfileDto( - post.getAuthor().getId(), - post.getAuthor().getName(), - post.getAuthor().getProfileImage(), - (long) post.getAuthor().getPosts().size(), - (long) post.getAuthor().getFollowers().size(), - (long) post.getAuthor().getFollowing().size(), + PostAuthorProfileDto authorProfile = new PostAuthorProfileDto( + author.getId(), + author.getName(), + author.getProfileImage(), + (long) author.getPosts().size(), + (long) author.getFollowers().size(), + (long) author.getFollowing().size(), isFollowing, - nationName, // 🔥 추가 - nationNameKo // 🔥 추가 + nationName, + nationNameKo ); - return new PostWithAuthorProfileDto(dto, authorProfile); + return new PostWithAuthorProfileDto(postDto, authorProfile); }).toList(); } + // follow 배치조회 + private Map getFollowStatusMap(List authors, Users currentUser) { + if (currentUser == null) { + return authors.stream() + .collect(Collectors.toMap(Users::getId, user -> false)); + } + + List authorIds = authors.stream() + .map(Users::getId) + .toList(); + + List follows = followRepository.findByFollowerAndFolloweeIdIn(currentUser, authorIds); + Set followedAuthorIds = follows.stream() + .map(follow -> follow.getFollowee().getId()) + .collect(Collectors.toSet()); + + return authors.stream() + .collect(Collectors.toMap( + Users::getId, + user -> followedAuthorIds.contains(user.getId()) + )); + } + /** * 전체 타임라인: Post + author profile + isLiked */ @Transactional(readOnly = true) public List getAllTimelineDetailed(Users currentUser) { - List posts = postRepository.findAllByOrderByCreatedAtDesc(); + List posts = postRepository.findAllWithAuthorAndNation(); - return posts.stream().map(post -> { - boolean isFollowing = currentUser != null && - followRepository.existsByFollowerAndFollowee(currentUser, post.getAuthor()); - boolean isLiked = currentUser != null && - likeRepository.existsByPostAndUser(post, currentUser); + if (posts.isEmpty()) { + return List.of(); + } + + Map likeStatusMap = getLikeStatusMap(posts, currentUser); + List authors = posts.stream() + .map(Post::getAuthor) + .distinct() + .toList(); + Map followStatusMap = getFollowStatusMap(authors, currentUser); - // 🔥 수정: PostDto에도 isLiked 적용 - PostDto dto = PostDto.fromWithLiked(post, isLiked); + return posts.stream().map(post -> { + boolean isLiked = likeStatusMap.get(post.getId()); + boolean isFollowing = followStatusMap.get(post.getAuthor().getId()); + PostDto postDto = PostDto.fromWithLiked(post, isLiked); - Nation nation = nationService.getNationById(post.getAuthor().getNations()); + Users author = post.getAuthor(); + Nation nation = nationService.getNationById(author.getNations()); String nationName = nation != null ? nation.getName() : "Unknown"; String nationNameKo = nation != null ? nation.getNameKo() : "알 수 없음"; - var authorProfile = new PostAuthorProfileDto( - post.getAuthor().getId(), - post.getAuthor().getName(), - post.getAuthor().getProfileImage(), - (long) post.getAuthor().getPosts().size(), - (long) post.getAuthor().getFollowers().size(), - (long) post.getAuthor().getFollowing().size(), + PostAuthorProfileDto authorProfile = new PostAuthorProfileDto( + author.getId(), + author.getName(), + author.getProfileImage(), + (long) author.getPosts().size(), + (long) author.getFollowers().size(), + (long) author.getFollowing().size(), isFollowing, nationName, nationNameKo ); - return new PostWithAuthorProfileDto(dto, authorProfile); + return new PostWithAuthorProfileDto(postDto, authorProfile); }).toList(); } - /** - * 🔥 새로 추가: 포스트 상세 조회 (댓글 포함) + * 포스트 상세 조회 (댓글 포함) */ @Transactional(readOnly = true) public PostDetailDto getPostDetail(Long postId, Users currentUser) { - Post post = postRepository.findById(postId) + // 1. Post + Author를 한 번에 조회 + Post post = postRepository.findByIdWithAuthor(postId) .orElseThrow(() -> new EntityNotFoundException("포스트를 찾을 수 없습니다.")); - // 댓글을 오래된 순으로 조회 (일반적인 댓글 순서) - List comments = commentRepository.findByPostOrderByCreatedAtAsc(post); + // 2. 댓글들을 한 번에 조회 (댓글 작성자 정보 포함) + List comments = commentRepository.findByPostWithCommenterOrderByCreatedAtAsc(post); List commentDtos = comments.stream() .map(CommentDto::from) .toList(); - // 현재 유저가 이 포스트에 좋아요를 눌렀는지 확인 + // 3. Like 상태 확인 (단건이므로 기존 방식 유지) boolean isLiked = currentUser != null && likeRepository.existsByPostAndUser(post, currentUser); - return PostDetailDto.fromWithComments(post, commentDtos, isLiked); } + return PostDetailDto.fromWithComments(post, commentDtos, isLiked); + } + /** - * 🔥 특정 유저의 포스트를 최신순으로 조회 (상세 정보 포함) + * 특정 유저의 포스트를 최신순으로 조회 (상세 정보 포함) */ @Transactional(readOnly = true) public List getUserPostsDetailed(Users currentUser, Long targetUserId) { + // 1. 대상 유저 조회 Users targetUser = userRepository.findById(targetUserId) .orElseThrow(() -> new EntityNotFoundException("유저를 찾을 수 없습니다")); - // 해당 유저의 포스트를 최신순으로 조회 - List posts = postRepository.findByAuthorOrderByCreatedAtDesc(targetUser); + // 2. 해당 유저의 Post + Author + Nation을 한 번에 조회 + List posts = postRepository.findByAuthorWithAuthorAndNation(targetUser); + + if (posts.isEmpty()) { + return List.of(); + } + + // 3. Like 정보를 배치로 조회 + Map likeStatusMap = getLikeStatusMap(posts, currentUser); + + // 4. Follow 상태 확인 (단일 유저이므로 한 번만 조회) + boolean isFollowing = currentUser != null && + followRepository.existsByFollowerAndFollowee(currentUser, targetUser); + // 5. DTO 변환 return posts.stream().map(post -> { - boolean isFollowing = currentUser != null && - followRepository.existsByFollowerAndFollowee(currentUser, targetUser); - boolean isLiked = currentUser != null && - likeRepository.existsByPostAndUser(post, currentUser); - PostDto dto = PostDto.fromWithLiked(post, isLiked); + boolean isLiked = likeStatusMap.get(post.getId()); + PostDto postDto = PostDto.fromWithLiked(post, isLiked); - // 🔥 국가 정보 추가 Nation nation = nationService.getNationById(targetUser.getNations()); String nationName = nation != null ? nation.getName() : "Unknown"; String nationNameKo = nation != null ? nation.getNameKo() : "알 수 없음"; - var authorProfile = new PostAuthorProfileDto( + PostAuthorProfileDto authorProfile = new PostAuthorProfileDto( targetUser.getId(), targetUser.getName(), targetUser.getProfileImage(), @@ -382,7 +425,7 @@ public List getUserPostsDetailed(Users currentUser, Lo nationName, nationNameKo ); - return new PostWithAuthorProfileDto(dto, authorProfile); + return new PostWithAuthorProfileDto(postDto, authorProfile); }).toList(); } } diff --git a/src/main/java/com/_data/_data/eduinfo/EduProgramApiException.java b/src/main/java/com/_data/_data/eduinfo/EduProgramApiException.java new file mode 100644 index 0000000..3f3f3f3 --- /dev/null +++ b/src/main/java/com/_data/_data/eduinfo/EduProgramApiException.java @@ -0,0 +1,16 @@ +package com._data._data.eduinfo; + +public class EduProgramApiException extends RuntimeException { + + public EduProgramApiException(String message) { + super(message); + } + + public EduProgramApiException(String message, Throwable cause) { + super(message, cause); + } + + public EduProgramApiException(Throwable cause) { + super(cause); + } +} diff --git a/src/main/java/com/_data/_data/eduinfo/mapper/EduProgramDtoMapper.java b/src/main/java/com/_data/_data/eduinfo/mapper/EduProgramDtoMapper.java new file mode 100644 index 0000000..879ce29 --- /dev/null +++ b/src/main/java/com/_data/_data/eduinfo/mapper/EduProgramDtoMapper.java @@ -0,0 +1,73 @@ +package com._data._data.eduinfo.mapper; + + +import com._data._data.common.util.translation.MultiLanguageTitleSelector; +import com._data._data.eduinfo.dto.EduProgramSimpleDto; +import com._data._data.eduinfo.entity.EduProgram; +import java.util.Optional; +import org.springframework.stereotype.Component; + +@Component +public class EduProgramDtoMapper { + private static final String NO_SEARCH_RESULT_MESSAGE = "검색 결과가 없습니다."; + + private final MultiLanguageTitleSelector titleSelector; + + public EduProgramDtoMapper(MultiLanguageTitleSelector titleSelector) { + this.titleSelector = titleSelector; + } + + /** + * EduProgram 엔티티를 EduProgramSimpleDto로 변환 + * + * @param program 변환할 엔티티 + * @param language 사용자 언어 코드 + * @return 변환된 DTO + */ + public EduProgramSimpleDto toSimpleDto(EduProgram program, String language) { + if (program == null) { + throw new IllegalArgumentException("변환할 프로그램이 null입니다."); + } + + String localizedTitle = titleSelector.selectTitle(program, language); + String applicationLink = resolveApplicationLink(program.getAppLink()); + String thumbnailUrl = resolveThumbnailUrl(program.getThumbnailUrl()); + boolean isFree = determineIfFree(program.getTuitEtc()); + + return new EduProgramSimpleDto( + program.getId(), + localizedTitle, + program.getAppQual(), + program.getTuitEtc(), + program.getAppEndDate(), + isFree, + applicationLink, + thumbnailUrl + ); + } + + /** + * 신청 링크 처리 - null이나 빈 값인 경우 기본 메시지 반환 + */ + private String resolveApplicationLink(String appLink) { + return Optional.ofNullable(appLink) + .filter(link -> !link.isBlank()) + .orElse(NO_SEARCH_RESULT_MESSAGE); + } + + /** + * 썸네일 URL 처리 - null이나 빈 값인 경우 null 반환 + */ + private String resolveThumbnailUrl(String thumbnailUrl) { + return Optional.ofNullable(thumbnailUrl) + .filter(url -> !url.isBlank()) + .orElse(null); + } + + /** + * 무료 여부 판단 - 수강료 정보가 없거나 빈 값인 경우 무료로 판단 + */ + private boolean determineIfFree(String tuitionInfo) { + return tuitionInfo == null || tuitionInfo.isBlank(); + } +} diff --git a/src/main/java/com/_data/_data/eduinfo/parser/EduProgramDataParser.java b/src/main/java/com/_data/_data/eduinfo/parser/EduProgramDataParser.java new file mode 100644 index 0000000..43db372 --- /dev/null +++ b/src/main/java/com/_data/_data/eduinfo/parser/EduProgramDataParser.java @@ -0,0 +1,133 @@ +package com._data._data.eduinfo.parser; + +import com._data._data.eduinfo.entity.EduProgram; +import com.fasterxml.jackson.databind.JsonNode; +import java.time.LocalDate; +import java.time.LocalDateTime; +import java.time.LocalTime; +import java.time.format.DateTimeFormatter; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Component; + +/** + * 외부 API에서 받은 JSON 데이터를 EduProgram 엔티티로 변환하는 파서 + */ +@Slf4j +@Component + public class EduProgramDataParser { + private static final DateTimeFormatter ISO_DATE = DateTimeFormatter.ISO_LOCAL_DATE; + private static final DateTimeFormatter COMPACT_DATE = DateTimeFormatter.ofPattern("yyyyMMdd"); + private static final DateTimeFormatter COMPACT_DATETIME = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); + private static final DateTimeFormatter ISO_DATETIME = DateTimeFormatter.ISO_LOCAL_DATE_TIME; + + /** + * JsonNode를 EduProgram 엔티티로 변환 + * + * @param item JSON 노드 + * @return 변환된 EduProgram 엔티티 + */ + public EduProgram parseFromJson(JsonNode item) { + if (item == null) { + throw new IllegalArgumentException("JSON 노드가 null입니다."); + } + + return EduProgram.builder() + .titleNm(extractText(item, "TITL_NM")) + .langGb(extractText(item, "LANG_GB")) + .cont(extractText(item, "CONT")) + .appStartDate(parseDate(extractText(item, "APP_ST_DT"))) + .appStartTime(parseTime(item, "APP_ST_HOUR_DT", "APP_ST_MINU_DT")) + .appEndDate(parseDate(extractText(item, "APP_EN_DT"))) + .appEndTime(parseTime(item, "APP_EN_HOUR_DT", "APP_EN_MINU_DT")) + .appEndYn("Y".equals(extractText(item, "APP_END_YN"))) + .eduStartDate(parseDate(extractText(item, "EDU_ST_DT"))) + .eduStartTime(parseTime(item, "EDU_ST_HOUR_DT", "EDU_ST_MINU_DT")) + .eduEndDate(parseDate(extractText(item, "EDU_EN_DT"))) + .eduEndTime(parseTime(item, "EDU_EN_HOUR_DT", "EDU_EN_MINU_DT")) + .appQual(extractText(item, "APP_QUAL")) + .appWayEtc(extractText(item, "APP_WAY_ETC")) + .tuitEtc(extractText(item, "TUIT_ETC")) + .pers(extractInt(item, "PERS")) + .regDt(parseDateTime(extractText(item, "REG_DT"))) + .updDt(parseDateTime(extractText(item, "UPD_DT"))) + .thumbnailUrl(null) // 초기값 + .build(); + } + + private String extractText(JsonNode item, String fieldName) { + return item.path(fieldName).asText(); + } + + private int extractInt(JsonNode item, String fieldName) { + return item.path(fieldName).asInt(); + } + + /** + * 날짜 문자열을 LocalDate로 변환 + * 지원 형식: yyyy-MM-dd, yyyyMMdd + */ + private LocalDate parseDate(String dateString) { + if (dateString == null || dateString.isBlank()) { + return null; + } + + try { + if (dateString.contains("-")) { + return LocalDate.parse(dateString, ISO_DATE); + } + if (dateString.length() >= 8) { + return LocalDate.parse(dateString.substring(0, 8), COMPACT_DATE); + } + } catch (Exception e) { + log.warn("날짜 파싱 실패: {}", dateString, e); + } + return null; + } + + /** + * 시간 정보를 LocalTime으로 변환 + */ + private LocalTime parseTime(JsonNode item, String hourKey, String minuteKey) { + try { + String hourStr = item.path(hourKey).asText(); + String minuteStr = item.path(minuteKey).asText(); + + if (hourStr.isBlank() || minuteStr.isBlank()) { + return null; + } + + int hour = Integer.parseInt(hourStr); + int minute = Integer.parseInt(minuteStr); + return LocalTime.of(hour, minute); + } catch (Exception e) { + log.warn("시간 파싱 실패: {}:{}", hourKey, minuteKey, e); + return null; + } + } + + /** + * 날짜시간 문자열을 LocalDateTime으로 변환 + * 지원 형식: yyyyMMddHHmmss, yyyy-MM-ddTHH:mm:ss, yyyyMMdd + */ + private LocalDateTime parseDateTime(String dateTimeString) { + if (dateTimeString == null || dateTimeString.isBlank()) { + return null; + } + + try { + if (dateTimeString.length() == 14) { + return LocalDateTime.parse(dateTimeString, COMPACT_DATETIME); + } + if (dateTimeString.contains("T")) { + return LocalDateTime.parse(dateTimeString, ISO_DATETIME); + } + if (dateTimeString.length() == 8) { + LocalDate date = LocalDate.parse(dateTimeString, COMPACT_DATE); + return date.atStartOfDay(); + } + } catch (Exception e) { + log.warn("날짜시간 파싱 실패: {}", dateTimeString, e); + } + return null; + } +} diff --git a/src/main/java/com/_data/_data/eduinfo/service/EduProgramApiService.java b/src/main/java/com/_data/_data/eduinfo/service/EduProgramApiService.java new file mode 100644 index 0000000..aad834b --- /dev/null +++ b/src/main/java/com/_data/_data/eduinfo/service/EduProgramApiService.java @@ -0,0 +1,71 @@ +package com._data._data.eduinfo.service; + +import com._data._data.eduinfo.EduProgramApiException; +import com.fasterxml.jackson.databind.JsonNode; +import com.fasterxml.jackson.databind.ObjectMapper; +import lombok.extern.slf4j.Slf4j; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.stereotype.Service; +import org.springframework.web.client.RestClientException; +import org.springframework.web.client.RestTemplate; + +@Slf4j +@Service +public class EduProgramApiService { + private static final String API_URL_TEMPLATE = + "http://openapi.seoul.go.kr:8088/%s/json/TEducProg/1/1000/"; + + private final RestTemplate restTemplate; + private final ObjectMapper objectMapper; + private final String apiKey; + + public EduProgramApiService(RestTemplate restTemplate, + ObjectMapper objectMapper, + @Value("${spring.edu-program.api-key}") String apiKey) { + this.restTemplate = restTemplate; + this.objectMapper = objectMapper; + this.apiKey = apiKey; + } + + /** + * 서울시 교육 프로그램 데이터를 API에서 가져옴 + * + * @return 교육 프로그램 데이터 JSON 노드 + * @throws EduProgramApiException API 호출 실패 시 + */ + public JsonNode fetchProgramsFromApi() { + String apiUrl = String.format(API_URL_TEMPLATE, apiKey); + + try { + log.debug("교육 프로그램 API 호출 시작: {}", apiUrl); + + ResponseEntity response = restTemplate.getForEntity(apiUrl, String.class); + String responseBody = response.getBody(); + + if (responseBody == null) { + throw new EduProgramApiException("API 응답이 비어있습니다."); + } + + JsonNode rootNode = objectMapper.readTree(responseBody); + JsonNode programsNode = rootNode.path("TEducProg").path("row"); + + if (programsNode.isMissingNode() || !programsNode.isArray()) { + log.warn("API 응답에서 교육 프로그램 데이터를 찾을 수 없습니다."); + throw new EduProgramApiException("유효하지 않은 API 응답 구조입니다."); + } + + int programCount = programsNode.size(); + log.info("API에서 {} 개의 교육 프로그램 데이터를 가져왔습니다.", programCount); + + return programsNode; + + } catch (RestClientException e) { + log.error("교육 프로그램 API 호출 실패: {}", apiUrl, e); + throw new EduProgramApiException("API 호출 중 네트워크 오류가 발생했습니다.", e); + } catch (Exception e) { + log.error("교육 프로그램 데이터 처리 중 오류 발생", e); + throw new EduProgramApiException("API 응답 처리 중 오류가 발생했습니다.", e); + } + } +} diff --git a/src/main/java/com/_data/_data/eduinfo/service/EduProgramService.java b/src/main/java/com/_data/_data/eduinfo/service/EduProgramService.java index ed474f9..4d9d509 100644 --- a/src/main/java/com/_data/_data/eduinfo/service/EduProgramService.java +++ b/src/main/java/com/_data/_data/eduinfo/service/EduProgramService.java @@ -1,335 +1,197 @@ package com._data._data.eduinfo.service; -import com._data._data.aichat.service.TranslationService; -import com._data._data.auth.entity.CustomUserDetails; +import com._data._data.common.util.language.UserLanguageResolver; import com._data._data.eduinfo.dto.EduProgramSimpleDto; import com._data._data.eduinfo.entity.EduProgram; +import com._data._data.eduinfo.mapper.EduProgramDtoMapper; +import com._data._data.eduinfo.parser.EduProgramDataParser; import com._data._data.eduinfo.repository.EduProgramRepository; -import com._data._data.user.entity.Users; -import com._data._data.user.repository.UserRepository; import com.fasterxml.jackson.databind.JsonNode; -import com.fasterxml.jackson.databind.ObjectMapper; import java.time.LocalDate; -import java.time.LocalDateTime; -import java.time.LocalTime; -import java.time.format.DateTimeFormatter; import java.util.List; import java.util.Optional; -import lombok.RequiredArgsConstructor; import lombok.extern.slf4j.Slf4j; -import org.springframework.beans.factory.annotation.Value; import org.springframework.data.domain.Page; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Sort; import org.springframework.scheduling.annotation.Async; import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; -import org.springframework.web.client.RestTemplate; -import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContextHolder; + @Slf4j @Service -@RequiredArgsConstructor +@Transactional public class EduProgramService { - private final EduProgramRepository eduProgramRepository; - private final RestTemplate restTemplate; - private final ObjectMapper objectMapper; - private final TranslationService translationService; - private final UserRepository userRepository; - - @Value("${spring.edu-program.api-key}") - private String eduProgramApiKey; - - @Value("${app.upload.base-url}") - private String baseUrl; - -// private static final String DEFAULT_THUMBNAIL = "이미지가 없습니다."; - - /** - * 썸네일 URL을 반환하는 메소드 (null인 경우 기본 이미지 반환) - */ - private String getThumbnailUrl(String thumbnailUrl) { - return (thumbnailUrl != null && !thumbnailUrl.isBlank()) - ? thumbnailUrl - : null; - } - private String getCurrentUserLang() { - Authentication auth = SecurityContextHolder.getContext().getAuthentication(); - if (auth == null || !auth.isAuthenticated() - || !(auth.getPrincipal() instanceof CustomUserDetails)) { - log.debug("getCurrentUserLang: 익명(비로그인) 상태, 기본 언어 'ko' 사용"); - return "ko"; - } - CustomUserDetails ud = (CustomUserDetails) auth.getPrincipal(); - String email = ud.getUsername(); - Users user = userRepository.findByEmailAndIsDeletedFalse(ud.getUsername()); - String lang = user.getTranslationLang(); - log.debug("getCurrentUserLang: 로그인 사용자={} 의 언어 설정={}", email, lang); - return lang; + private final EduProgramRepository eduProgramRepository; + private final UserLanguageResolver userLanguageResolver; + private final EduProgramDtoMapper dtoMapper; + private final EduProgramApiService apiService; + private final EduProgramDataParser dataParser; + private final EduProgramTranslationService translationService; + + public EduProgramService(EduProgramRepository eduProgramRepository, + UserLanguageResolver userLanguageResolver, + EduProgramDtoMapper dtoMapper, + EduProgramApiService apiService, + EduProgramDataParser dataParser, + EduProgramTranslationService translationService) { + this.eduProgramRepository = eduProgramRepository; + this.userLanguageResolver = userLanguageResolver; + this.dtoMapper = dtoMapper; + this.apiService = apiService; + this.dataParser = dataParser; + this.translationService = translationService; } - /** - * 공공데이터 api에서 프로그램 정보를 가져옴 - * - * **/ + * 외부 API에서 교육 프로그램 데이터를 가져와서 저장 + * 비동기로 실행되며 기존 데이터와 비교하여 번역 누락분을 채움 + */ @Async public void fetchAndSavePrograms() { try { - String url = String.format( - "http://openapi.seoul.go.kr:8088/%s/json/TEducProg/1/1000/", - eduProgramApiKey - ); - JsonNode items = objectMapper - .readTree(restTemplate.getForEntity(url, String.class).getBody()) - .path("TEducProg").path("row"); - - for (JsonNode item : items) { - if (!"KO".equals(item.path("LANG_GB").asText())) continue; - - // 1) JSON → 엔티티 변환 (KO 원본만) - EduProgram incoming = convertToEntity(item); - - // 2) titleNm 으로만 기존 레코드 확인 - Optional opt = eduProgramRepository - .findByTitleNm(incoming.getTitleNm()); - - if (opt.isPresent()) { - EduProgram existing = opt.get(); - - // 3) 기존에 번역이 빠진 필드가 있으면 채워넣고 저장 - boolean dirty = false; - String ko = existing.getTitleNm(); // 둘 다 KO 원본은 동일 - - if (existing.getTitleEn() == null || existing.getTitleEn().isBlank()) { - existing.setTitleEn( translationService.translateText(ko, "ko", "en-US") ); - dirty = true; - } - if (existing.getTitleZh() == null || existing.getTitleZh().isBlank()) { - existing.setTitleZh( translationService.translateText(ko, "ko", "zh") ); - dirty = true; - } - if (existing.getTitleJa() == null || existing.getTitleJa().isBlank()) { - existing.setTitleJa( translationService.translateText(ko, "ko", "ja") ); - dirty = true; - } - if (existing.getTitleVi() == null || existing.getTitleVi().isBlank()) { - existing.setTitleVi( translationService.translateText(ko, "ko", "vi") ); - dirty = true; - } - if (existing.getTitleId() == null || existing.getTitleId().isBlank()) { - existing.setTitleId( translationService.translateText(ko, "ko", "id") ); - dirty = true; - } - - if (dirty) { - eduProgramRepository.save(existing); - } - - } else { - // 4) 신규 저장 시에는 KO 원본 + 번역 전부 세팅 - String ko = incoming.getTitleNm(); - incoming.setTitleEn( translationService.translateText(ko, "ko", "en-US") ); - incoming.setTitleZh( translationService.translateText(ko, "ko", "zh") ); - incoming.setTitleJa( translationService.translateText(ko, "ko", "ja") ); - incoming.setTitleVi( translationService.translateText(ko, "ko", "vi") ); - incoming.setTitleId( translationService.translateText(ko, "ko", "id") ); - - eduProgramRepository.save(incoming); + log.info("교육 프로그램 데이터 동기화 시작"); + + JsonNode programItems = apiService.fetchProgramsFromApi(); + int processedCount = 0; + int totalCount = programItems.size(); + + for (JsonNode item : programItems) { + if (!isKoreanProgram(item)) { + continue; } + + processProgram(item); + processedCount++; } + + log.info("교육 프로그램 데이터 동기화 완료: {}/{} 처리됨", processedCount, totalCount); + } catch (Exception e) { - e.printStackTrace(); + log.error("교육 프로그램 데이터 동기화 중 오류 발생", e); + throw new RuntimeException("교육 프로그램 데이터 동기화에 실패했습니다.", e); } } - /** - * 곧 마감되는 프로그램 - * - * **/ + * 곧 마감되는 프로그램 조회 (7일 이내) + */ @Transactional(readOnly = true) public List findClosingSoonPrograms() { - String lang = getCurrentUserLang(); - return eduProgramRepository - .findByAppEndYnFalseAndAppEndDateBetweenOrderByAppEndDateAsc(LocalDate.now(), LocalDate.now().plusDays(7)) - .stream() - .map(ep -> toSimpleDto(ep, lang)) + String userLanguage = userLanguageResolver.getCurrentUserLanguage(); + LocalDate today = LocalDate.now(); + LocalDate weekLater = today.plusDays(7); + + log.debug("마감 임박 프로그램 조회: {} ~ {}, 언어: {}", today, weekLater, userLanguage); + + List programs = eduProgramRepository + .findByAppEndYnFalseAndAppEndDateBetweenOrderByAppEndDateAsc(today, weekLater); + + return programs.stream() + .map(program -> dtoMapper.toSimpleDto(program, userLanguage)) .toList(); } + /** - * 모든 정보 - * - * **/ + * 전체 교육 프로그램 조회 (페이징, 필터링, 정렬 지원) + */ @Transactional(readOnly = true) - public Page findAllPrograms( - Boolean isFree, - String sort, - int page, - int size - ) { - // 1) 로그인된 사용자의 설정 언어 가져오기 - String lang = getCurrentUserLang(); - - // 2) 페이징·정렬 - PageRequest pageRequest = PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sort)); - Page result; - - // 3) 무료/유료 필터 - if (isFree != null) { - result = isFree - ? eduProgramRepository.findByTuitEtcIsNullOrTuitEtc("", pageRequest) - : eduProgramRepository.findByTuitEtcIsNotNullAndTuitEtcNot("", pageRequest); - } else { - result = eduProgramRepository.findAll(pageRequest); - } + public Page findAllPrograms(Boolean isFree, String sortBy, + int page, int size) { + String userLanguage = userLanguageResolver.getCurrentUserLanguage(); + PageRequest pageRequest = createPageRequest(page, size, sortBy); - // 4) 각 엔티티를 DTO로 변환하며 번역된 제목 선택 - return result.map(ep -> { - String title; - switch (lang) { - case "en": - case "en-US": - case "en-GB": title = ep.getTitleEn(); break; - case "zh": title = ep.getTitleZh(); break; - case "ja": title = ep.getTitleJa(); break; - case "vi": title = ep.getTitleVi(); break; - case "id": title = ep.getTitleId(); break; - default: title = ep.getTitleNm(); - } - // appLink 값 결정 - String link = ep.getAppLink() != null && !ep.getAppLink().isBlank() - ? ep.getAppLink() - : "검색 결과가 없습니다."; - - String thumbnailUrl = getThumbnailUrl(ep.getThumbnailUrl()); - - return new EduProgramSimpleDto( - ep.getId(), - title, - ep.getAppQual(), - ep.getTuitEtc(), - ep.getAppEndDate(), - (ep.getTuitEtc() == null || ep.getTuitEtc().isBlank()), - link, - thumbnailUrl - ); - }); - } + log.debug("교육 프로그램 목록 조회: 무료={}, 정렬={}, 페이지={}, 크기={}, 언어={}", + isFree, sortBy, page, size, userLanguage); + Page programPage = findProgramsByFeeFilter(isFree, pageRequest); - public EduProgram findDetailById(Long id) { - return eduProgramRepository.findById(id) - .orElseThrow(() -> new IllegalArgumentException("해당 교육을 찾을 수 없습니다.")); + return programPage.map(program -> dtoMapper.toSimpleDto(program, userLanguage)); } - private EduProgram convertToEntity(JsonNode item) { - return EduProgram.builder() - .titleNm(item.path("TITL_NM").asText()) - .langGb(item.path("LANG_GB").asText()) - .cont(item.path("CONT").asText()) - .appStartDate(parseDate(item, "APP_ST_DT")) - .appStartTime(parseTime(item, "APP_ST_HOUR_DT", "APP_ST_MINU_DT")) - .appEndDate(parseDate(item, "APP_EN_DT")) - .appEndTime(parseTime(item, "APP_EN_HOUR_DT", "APP_EN_MINU_DT")) - .appEndYn("Y".equals(item.path("APP_END_YN").asText())) - .eduStartDate(parseDate(item, "EDU_ST_DT")) - .eduStartTime(parseTime(item, "EDU_ST_HOUR_DT", "EDU_ST_MINU_DT")) - .eduEndDate(parseDate(item, "EDU_EN_DT")) - .eduEndTime(parseTime(item, "EDU_EN_HOUR_DT", "EDU_EN_MINU_DT")) - .appQual(item.path("APP_QUAL").asText()) - .appWayEtc(item.path("APP_WAY_ETC").asText()) - .tuitEtc(item.path("TUIT_ETC").asText()) - .pers(item.path("PERS").asInt()) - .regDt(parseDateTime(item, "REG_DT")) - .updDt(parseDateTime(item, "UPD_DT")) - .thumbnailUrl(null) - .build(); + /** + * 교육 프로그램 상세 조회 + */ + @Transactional(readOnly = true) + public EduProgram findDetailById(Long id) { + return eduProgramRepository.findById(id) + .orElseThrow(() -> new IllegalArgumentException( + String.format("ID %d에 해당하는 교육 프로그램을 찾을 수 없습니다.", id) + )); } - private EduProgramSimpleDto toSimpleDto(EduProgram ep, String lang) { - String title; - switch (lang) { - case "en-US", "en-GB": title = ep.getTitleEn(); break; - case "zh": title = ep.getTitleZh(); break; - case "ja": title = ep.getTitleJa(); break; - case "vi": title = ep.getTitleVi(); break; - case "id": title = ep.getTitleId(); break; - default: title = ep.getTitleNm(); - } + // ===== Private Helper Methods ===== - String link = ep.getAppLink() != null && !ep.getAppLink().isBlank() - ? ep.getAppLink() - : "검색 결과가 없습니다."; - - String thumbnailUrl = getThumbnailUrl(ep.getThumbnailUrl()); - - return new EduProgramSimpleDto( - ep.getId(), - title, - ep.getAppQual(), - ep.getTuitEtc(), - ep.getAppEndDate(), - (ep.getTuitEtc() == null || ep.getTuitEtc().isBlank()), - link, - thumbnailUrl - ); + /** + * 한국어 프로그램인지 확인 + */ + private boolean isKoreanProgram(JsonNode item) { + return "KO".equals(item.path("LANG_GB").asText()); } + /** + * 개별 프로그램 처리 (신규 저장 또는 기존 업데이트) + */ + private void processProgram(JsonNode item) { + try { + EduProgram parsedProgram = dataParser.parseFromJson(item); - private LocalDate parseDate(JsonNode item, String key) { - String raw = item.path(key).asText(); + Optional existingProgram = eduProgramRepository + .findByTitleNm(parsedProgram.getTitleNm()); - // 하이픈이 있는 경우: "2025-04-22" - if (raw.contains("-")) { - return LocalDate.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE); - } + if (existingProgram.isPresent()) { + updateExistingProgram(existingProgram.get()); + } else { + saveNewProgram(parsedProgram); + } - // 하이픈 없는 경우: "20250422" - if (raw.length() >= 8) { - return LocalDate.parse(raw, DateTimeFormatter.ofPattern("yyyyMMdd")); + } catch (Exception e) { + log.warn("프로그램 처리 중 오류 발생: {}", + item.path("TITL_NM").asText(), e); } - - return null; } - private LocalTime parseTime(JsonNode item, String hourKey, String minKey) { - try { - int hour = Integer.parseInt(item.path(hourKey).asText()); - int minute = Integer.parseInt(item.path(minKey).asText()); - return LocalTime.of(hour, minute); - } catch (Exception e) { - return null; + /** + * 기존 프로그램의 누락된 번역 채우기 + */ + private void updateExistingProgram(EduProgram existingProgram) { + boolean hasUpdates = translationService.fillMissingTranslations(existingProgram); + + if (hasUpdates) { + eduProgramRepository.save(existingProgram); + log.debug("기존 프로그램 번역 업데이트: {}", existingProgram.getTitleNm()); } } - private LocalDateTime parseDateTime(JsonNode item, String key) { - String raw = item.path(key).asText(); - - try { - // 예: 20250422144321 → yyyyMMddHHmmss - if (raw.length() == 14) { - DateTimeFormatter formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss"); - return LocalDateTime.parse(raw, formatter); - } + /** + * 신규 프로그램 저장 (모든 번역 포함) + */ + private void saveNewProgram(EduProgram newProgram) { + EduProgram programWithTranslations = translationService + .createWithAllTranslations(newProgram); - // 예: 2025-04-22T14:43:21 → ISO_LOCAL_DATE_TIME - if (raw.contains("T")) { - return LocalDateTime.parse(raw, DateTimeFormatter.ISO_LOCAL_DATE_TIME); - } + eduProgramRepository.save(programWithTranslations); + log.debug("신규 프로그램 저장: {}", newProgram.getTitleNm()); + } - // 예: 20250422 → 그냥 날짜만 있는 경우 - if (raw.length() == 8) { - LocalDate date = LocalDate.parse(raw, DateTimeFormatter.ofPattern("yyyyMMdd")); - return date.atStartOfDay(); - } + /** + * 페이지 요청 객체 생성 + */ + private PageRequest createPageRequest(int page, int size, String sortBy) { + return PageRequest.of(page, size, Sort.by(Sort.Direction.DESC, sortBy)); + } - } catch (Exception e) { - System.err.println("⚠️ 날짜 파싱 실패: " + raw); + /** + * 수강료 필터에 따른 프로그램 조회 + */ + private Page findProgramsByFeeFilter(Boolean isFree, PageRequest pageRequest) { + if (isFree == null) { + return eduProgramRepository.findAll(pageRequest); } - return null; + return isFree + ? eduProgramRepository.findByTuitEtcIsNullOrTuitEtc("", pageRequest) + : eduProgramRepository.findByTuitEtcIsNotNullAndTuitEtcNot("", pageRequest); } - -} +} \ No newline at end of file diff --git a/src/main/java/com/_data/_data/eduinfo/service/EduProgramTranslationService.java b/src/main/java/com/_data/_data/eduinfo/service/EduProgramTranslationService.java new file mode 100644 index 0000000..5dce60d --- /dev/null +++ b/src/main/java/com/_data/_data/eduinfo/service/EduProgramTranslationService.java @@ -0,0 +1,109 @@ +package com._data._data.eduinfo.service; + +import com._data._data.aichat.service.TranslationService; +import com._data._data.eduinfo.entity.EduProgram; +import java.util.function.Supplier; +import java.util.function.Consumer; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Service; + +@Slf4j +@Service +public class EduProgramTranslationService { + private static final String SOURCE_LANGUAGE = "ko"; + private static final String[] TARGET_LANGUAGES = {"en-US", "zh", "ja", "vi", "id"}; + + private final TranslationService translationService; + + public EduProgramTranslationService(TranslationService translationService) { + this.translationService = translationService; + } + + /** + * 기존 프로그램에서 누락된 번역을 채워넣음 + * + * @param program 번역을 채울 프로그램 엔티티 + * @return 번역이 업데이트되었는지 여부 + */ + public boolean fillMissingTranslations(EduProgram program) { + String originalTitle = program.getTitleNm(); + if (originalTitle == null || originalTitle.isBlank()) { + log.warn("원본 제목이 없어 번역 패스. ID: {}", program.getId()); + return false; + } + + boolean hasUpdates = false; + + hasUpdates |= fillTranslationIfEmpty( + program::getTitleEn, program::setTitleEn, originalTitle, "en-US"); + hasUpdates |= fillTranslationIfEmpty( + program::getTitleZh, program::setTitleZh, originalTitle, "zh"); + hasUpdates |= fillTranslationIfEmpty( + program::getTitleJa, program::setTitleJa, originalTitle, "ja"); + hasUpdates |= fillTranslationIfEmpty( + program::getTitleVi, program::setTitleVi, originalTitle, "vi"); + hasUpdates |= fillTranslationIfEmpty( + program::getTitleId, program::setTitleId, originalTitle, "id"); + + if (hasUpdates) { + log.debug("프로그램 번역 업데이트 완료: {}", originalTitle); + } + + return hasUpdates; + } + + /** + * 신규 프로그램에 모든 언어의 번역을 생성 + * + * @param program 번역을 생성할 프로그램 엔티티 + * @return 번역이 설정된 프로그램 엔티티 + */ + public EduProgram createWithAllTranslations(EduProgram program) { + String originalTitle = program.getTitleNm(); + if (originalTitle == null || originalTitle.isBlank()) { + log.warn("원본 제목이 없어 번역을 건너뜁니다."); + return program; + } + + try { + program.setTitleEn(translateSafely(originalTitle, "en-US")); + program.setTitleZh(translateSafely(originalTitle, "zh")); + program.setTitleJa(translateSafely(originalTitle, "ja")); + program.setTitleVi(translateSafely(originalTitle, "vi")); + program.setTitleId(translateSafely(originalTitle, "id")); + + log.debug("신규 프로그램 번역 완료: {}", originalTitle); + } catch (Exception e) { + log.error("프로그램 번역 중 오류 발생: {}", originalTitle, e); + } + + return program; + } + + /** + * 번역이 비어있는 경우에만 번역을 수행 + */ + private boolean fillTranslationIfEmpty(Supplier getter, Consumer setter, + String originalText, String targetLanguage) { + String currentValue = getter.get(); + if (currentValue == null || currentValue.isBlank()) { + String translated = translateSafely(originalText, targetLanguage); + setter.accept(translated); + return true; + } + return false; + } + + /** + * 안전한 번역 수행 (예외 발생 시 원본 반환) + */ + private String translateSafely(String text, String targetLanguage) { + try { + String translated = translationService.translateText(text, SOURCE_LANGUAGE, targetLanguage); + return translated != null ? translated : text; + } catch (Exception e) { + log.warn("번역 실패 (원본 유지): {} -> {}", text, targetLanguage, e); + return text; + } + } +}