package com.openisle.service; import com.openisle.model.Post; import com.openisle.model.PostStatus; import com.openisle.model.PostType; import com.openisle.model.PublishMode; import com.openisle.model.User; import com.openisle.model.Category; import com.openisle.model.Comment; import com.openisle.model.NotificationType; import com.openisle.model.LotteryPost; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.UserRepository; import com.openisle.repository.CategoryRepository; import com.openisle.repository.TagRepository; import com.openisle.service.SubscriptionService; import com.openisle.service.CommentService; import com.openisle.repository.CommentRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.NotificationRepository; import com.openisle.model.Role; import com.openisle.exception.RateLimitException; import lombok.extern.slf4j.Slf4j; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.stereotype.Service; import org.springframework.scheduling.TaskScheduler; import com.openisle.service.EmailSender; import java.time.ZoneId; import java.time.ZoneOffset; import java.util.*; import org.springframework.data.domain.PageRequest; import org.springframework.data.domain.Pageable; import org.springframework.data.domain.Sort; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; import java.time.LocalDateTime; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.ConcurrentMap; import java.util.concurrent.ScheduledFuture; import org.springframework.boot.context.event.ApplicationReadyEvent; import org.springframework.context.event.EventListener; @Slf4j @Service public class PostService { private final PostRepository postRepository; private final UserRepository userRepository; private final CategoryRepository categoryRepository; private final TagRepository tagRepository; private final LotteryPostRepository lotteryPostRepository; private PublishMode publishMode; private final NotificationService notificationService; private final SubscriptionService subscriptionService; private final CommentService commentService; private final CommentRepository commentRepository; private final ReactionRepository reactionRepository; private final PostSubscriptionRepository postSubscriptionRepository; private final NotificationRepository notificationRepository; private final PostReadService postReadService; private final ImageUploader imageUploader; private final TaskScheduler taskScheduler; private final EmailSender emailSender; private final ApplicationContext applicationContext; private final PointService pointService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, UserRepository userRepository, CategoryRepository categoryRepository, TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, NotificationService notificationService, SubscriptionService subscriptionService, CommentService commentService, CommentRepository commentRepository, ReactionRepository reactionRepository, PostSubscriptionRepository postSubscriptionRepository, NotificationRepository notificationRepository, PostReadService postReadService, ImageUploader imageUploader, TaskScheduler taskScheduler, EmailSender emailSender, ApplicationContext applicationContext, PointService pointService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; this.commentService = commentService; this.commentRepository = commentRepository; this.reactionRepository = reactionRepository; this.postSubscriptionRepository = postSubscriptionRepository; this.notificationRepository = notificationRepository; this.postReadService = postReadService; this.imageUploader = imageUploader; this.taskScheduler = taskScheduler; this.emailSender = emailSender; this.applicationContext = applicationContext; this.pointService = pointService; this.publishMode = publishMode; } @EventListener(ApplicationReadyEvent.class) public void rescheduleLotteries() { LocalDateTime now = LocalDateTime.now(); for (LotteryPost lp : lotteryPostRepository.findByEndTimeAfterAndWinnersIsEmpty(now)) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); scheduledFinalizations.put(lp.getId(), future); } for (LotteryPost lp : lotteryPostRepository.findByEndTimeBeforeAndWinnersIsEmpty(now)) { applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()); } } public PublishMode getPublishMode() { return publishMode; } public void setPublishMode(PublishMode publishMode) { this.publishMode = publishMode; } public List listLatestRssPosts(int limit) { Pageable pageable = PageRequest.of(0, limit, Sort.by(Sort.Direction.DESC, "createdAt")); return postRepository.findByStatusAndRssExcludedFalseOrderByCreatedAtDesc(PostStatus.PUBLISHED, pageable); } public Post excludeFromRss(Long id) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); post.setRssExcluded(true); return postRepository.save(post); } public Post includeInRss(Long id) { Post post = postRepository.findById(id).orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); post.setRssExcluded(false); post = postRepository.save(post); notificationService.createNotification(post.getAuthor(), NotificationType.POST_FEATURED, post, null, null, null, null, null); pointService.awardForFeatured(post.getAuthor().getUsername(), post.getId()); return post; } public Post createPost(String username, Long categoryId, String title, String content, java.util.List tagIds, PostType type, String prizeDescription, String prizeIcon, Integer prizeCount, Integer pointCost, LocalDateTime startTime, LocalDateTime endTime) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { throw new RateLimitException("Too many posts"); } if (tagIds == null || tagIds.isEmpty()) { throw new IllegalArgumentException("At least one tag required"); } if (tagIds.size() > 2) { throw new IllegalArgumentException("At most two tags allowed"); } User author = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("Category not found")); java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } PostType actualType = type != null ? type : PostType.NORMAL; Post post; if (actualType == PostType.LOTTERY) { if (pointCost != null && (pointCost < 0 || pointCost > 100)) { throw new IllegalArgumentException("pointCost must be between 0 and 100"); } LotteryPost lp = new LotteryPost(); lp.setPrizeDescription(prizeDescription); lp.setPrizeIcon(prizeIcon); lp.setPrizeCount(prizeCount != null ? prizeCount : 0); lp.setPointCost(pointCost != null ? pointCost : 0); lp.setStartTime(startTime); lp.setEndTime(endTime); post = lp; } else { post = new Post(); } post.setType(actualType); post.setTitle(title); post.setContent(content); post.setAuthor(author); post.setCategory(category); post.setTags(new HashSet<>(tags)); post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); } else { post = postRepository.save(post); } imageUploader.addReferences(imageUploader.extractUrls(content)); if (post.getStatus() == PostStatus.PENDING) { java.util.List admins = userRepository.findByRole(com.openisle.model.Role.ADMIN); for (User admin : admins) { notificationService.createNotification(admin, NotificationType.POST_REVIEW_REQUEST, post, null, null, author, null, null); } notificationService.createNotification(author, NotificationType.POST_REVIEW_REQUEST, post, null, null, null, null, null); } // notify followers of author for (User u : subscriptionService.getSubscribers(author.getUsername())) { if (!u.getId().equals(author.getId())) { notificationService.createNotification( u, NotificationType.FOLLOWED_POST, post, null, null, author, null, null); } } notificationService.notifyMentions(content, author, post, null); if (post instanceof LotteryPost lp && lp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizeLottery(lp.getId()), java.util.Date.from(lp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); scheduledFinalizations.put(lp.getId(), future); } return post; } public void joinLottery(Long postId, String username) { LotteryPost post = lotteryPostRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (post.getParticipants().add(user)) { pointService.processLotteryJoin(user, post); lotteryPostRepository.save(post); } } @Transactional public void finalizeLottery(Long postId) { log.info("start to finalizeLottery for {}", postId); scheduledFinalizations.remove(postId); lotteryPostRepository.findById(postId).ifPresent(lp -> { List participants = new ArrayList<>(lp.getParticipants()); if (participants.isEmpty()) { return; } Collections.shuffle(participants); int winnersCount = Math.min(lp.getPrizeCount(), participants.size()); java.util.Set winners = new java.util.HashSet<>(participants.subList(0, winnersCount)); log.info("winner count {}", winnersCount); lp.setWinners(winners); lotteryPostRepository.save(lp); for (User w : winners) { if (w.getEmail() != null) { emailSender.sendEmail(w.getEmail(), "你中奖了", "恭喜你在抽奖贴 \"" + lp.getTitle() + "\" 中获奖"); } notificationService.createNotification(w, NotificationType.LOTTERY_WIN, lp, null, null, lp.getAuthor(), null, null); notificationService.sendCustomPush(w, "你中奖了", String.format("%s/posts/%d", websiteUrl, lp.getId())); } if (lp.getAuthor() != null) { if (lp.getAuthor().getEmail() != null) { emailSender.sendEmail(lp.getAuthor().getEmail(), "抽奖已开奖", "您的抽奖贴 \"" + lp.getTitle() + "\" 已开奖"); } notificationService.createNotification(lp.getAuthor(), NotificationType.LOTTERY_DRAW, lp, null, null, null, null, null); notificationService.sendCustomPush(lp.getAuthor(), "抽奖已开奖", String.format("%s/posts/%d", websiteUrl, lp.getId())); } }); } @Transactional public Post viewPost(Long id, String viewer) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); if (post.getStatus() != PostStatus.PUBLISHED) { if (viewer == null) { throw new com.openisle.exception.NotFoundException("Post not found"); } User viewerUser = userRepository.findByUsername(viewer) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (!viewerUser.getRole().equals(com.openisle.model.Role.ADMIN) && !viewerUser.getId().equals(post.getAuthor().getId())) { throw new com.openisle.exception.NotFoundException("Post not found"); } } post.setViews(post.getViews() + 1); post = postRepository.save(post); if (viewer != null) { postReadService.recordRead(viewer, id); } if (viewer != null && !viewer.equals(post.getAuthor().getUsername())) { User viewerUser = userRepository.findByUsername(viewer).orElse(null); if (viewerUser != null) { notificationRepository.deleteByTypeAndFromUserAndPost(NotificationType.POST_VIEWED, viewerUser, post); notificationService.createNotification(post.getAuthor(), NotificationType.POST_VIEWED, post, null, null, viewerUser, null, null); } } return post; } public List listPosts() { return listPostsByCategories(null, null, null); } public List listPostsByViews(Integer page, Integer pageSize) { return listPostsByViews(null, null, page, pageSize); } public List listPostsByViews(java.util.List categoryIds, java.util.List tagIds, Integer page, Integer pageSize) { boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); boolean hasTags = tagIds != null && !tagIds.isEmpty(); java.util.List posts; if (!hasCategories && !hasTags) { posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED); } else if (hasCategories) { java.util.List categories = categoryRepository.findAllById(categoryIds); if (categories.isEmpty()) { return java.util.List.of(); } if (hasTags) { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { return java.util.List.of(); } posts = postRepository.findByCategoriesAndAllTagsOrderByViewsDesc( categories, tags, PostStatus.PUBLISHED, tags.size()); } else { posts = postRepository.findByCategoryInAndStatusOrderByViewsDesc(categories, PostStatus.PUBLISHED); } } else { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { return java.util.List.of(); } posts = postRepository.findByAllTagsOrderByViewsDesc(tags, PostStatus.PUBLISHED, tags.size()); } return paginate(sortByPinnedAndViews(posts), page, pageSize); } public List listPostsByLatestReply(Integer page, Integer pageSize) { return listPostsByLatestReply(null, null, page, pageSize); } public List listPostsByLatestReply(java.util.List categoryIds, java.util.List tagIds, Integer page, Integer pageSize) { boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); boolean hasTags = tagIds != null && !tagIds.isEmpty(); java.util.List posts; if (!hasCategories && !hasTags) { posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED); } else if (hasCategories) { java.util.List categories = categoryRepository.findAllById(categoryIds); if (categories.isEmpty()) { return java.util.List.of(); } if (hasTags) { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { return java.util.List.of(); } posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc( categories, tags, PostStatus.PUBLISHED, tags.size()); } else { posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); } } else { java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { return java.util.List.of(); } posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); } return paginate(sortByPinnedAndLastReply(posts), page, pageSize); } public List listPostsByCategories(java.util.List categoryIds, Integer page, Integer pageSize) { if (categoryIds == null || categoryIds.isEmpty()) { java.util.List posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED); return paginate(sortByPinnedAndCreated(posts), page, pageSize); } java.util.List categories = categoryRepository.findAllById(categoryIds); java.util.List posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED); return paginate(sortByPinnedAndCreated(posts), page, pageSize); } public List getRecentPostsByUser(String username, int limit) { User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); Pageable pageable = PageRequest.of(0, limit); return postRepository.findByAuthorAndStatusOrderByCreatedAtDesc(user, PostStatus.PUBLISHED, pageable); } public java.time.LocalDateTime getLastPostTime(String username) { return postRepository.findLastPostTime(username); } public long getTotalViews(String username) { Long v = postRepository.sumViews(username); return v != null ? v : 0; } public List listPostsByTags(java.util.List tagIds, Integer page, Integer pageSize) { if (tagIds == null || tagIds.isEmpty()) { return java.util.List.of(); } java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { return java.util.List.of(); } java.util.List posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size()); return paginate(sortByPinnedAndCreated(posts), page, pageSize); } public List listPostsByCategoriesAndTags(java.util.List categoryIds, java.util.List tagIds, Integer page, Integer pageSize) { if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) { return java.util.List.of(); } java.util.List categories = categoryRepository.findAllById(categoryIds); java.util.List tags = tagRepository.findAllById(tagIds); if (categories.isEmpty() || tags.isEmpty()) { return java.util.List.of(); } java.util.List posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size()); return paginate(sortByPinnedAndCreated(posts), page, pageSize); } public List listFeaturedPosts(List categoryIds, List tagIds, Integer page, Integer pageSize) { List posts; boolean hasCategories = categoryIds != null && !categoryIds.isEmpty(); boolean hasTags = tagIds != null && !tagIds.isEmpty(); if (hasCategories && hasTags) { posts = listPostsByCategoriesAndTags(categoryIds, tagIds, null, null); } else if (hasCategories) { posts = listPostsByCategories(categoryIds, null, null); } else if (hasTags) { posts = listPostsByTags(tagIds, null, null); } else { posts = listPosts(); } // 仅保留 getRssExcluded 为 0 且不为空 // 若字段类型是 Boolean(包装类型),0 等价于 false: posts = posts.stream() .filter(p -> p.getRssExcluded() != null && !p.getRssExcluded()) .toList(); return paginate(sortByPinnedAndCreated(posts), page, pageSize); } public List listPendingPosts() { return postRepository.findByStatus(PostStatus.PENDING); } public Post approvePost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); // publish all pending tags along with the post for (com.openisle.model.Tag tag : post.getTags()) { if (!tag.isApproved()) { tag.setApproved(true); tagRepository.save(tag); } } post.setStatus(PostStatus.PUBLISHED); post = postRepository.save(post); notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, true, null, null, null); return post; } public Post rejectPost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); // remove user created tags that are only linked to this post java.util.Set tags = new java.util.HashSet<>(post.getTags()); for (com.openisle.model.Tag tag : tags) { if (!tag.isApproved()) { long count = postRepository.countDistinctByTags_Id(tag.getId()); if (count <= 1) { post.getTags().remove(tag); tagRepository.delete(tag); } } } post.setStatus(PostStatus.REJECTED); post = postRepository.save(post); notificationService.createNotification(post.getAuthor(), NotificationType.POST_REVIEWED, post, null, false, null, null, null); return post; } public Post pinPost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); post.setPinnedAt(java.time.LocalDateTime.now()); return postRepository.save(post); } public Post unpinPost(Long id) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); post.setPinnedAt(null); return postRepository.save(post); } public Post closePost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } post.setClosed(true); return postRepository.save(post); } public Post reopenPost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } post.setClosed(false); return postRepository.save(post); } @org.springframework.transaction.annotation.Transactional public Post updatePost(Long id, String username, Long categoryId, String title, String content, java.util.List tagIds) { if (tagIds == null || tagIds.isEmpty()) { throw new IllegalArgumentException("At least one tag required"); } if (tagIds.size() > 2) { throw new IllegalArgumentException("At most two tags allowed"); } Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (!user.getId().equals(post.getAuthor().getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } Category category = categoryRepository.findById(categoryId) .orElseThrow(() -> new IllegalArgumentException("Category not found")); java.util.List tags = tagRepository.findAllById(tagIds); if (tags.isEmpty()) { throw new IllegalArgumentException("Tag not found"); } post.setTitle(title); String oldContent = post.getContent(); post.setContent(content); post.setCategory(category); post.setTags(new java.util.HashSet<>(tags)); Post updated = postRepository.save(post); imageUploader.adjustReferences(oldContent, content); notificationService.notifyMentions(content, user, updated, null); return updated; } @org.springframework.transaction.annotation.Transactional public void deletePost(Long id, String username) { Post post = postRepository.findById(id) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); User author = post.getAuthor(); boolean adminDeleting = !user.getId().equals(author.getId()) && user.getRole() == Role.ADMIN; if (!user.getId().equals(author.getId()) && user.getRole() != Role.ADMIN) { throw new IllegalArgumentException("Unauthorized"); } for (Comment c : commentRepository.findByPostAndParentIsNullOrderByCreatedAtAsc(post)) { commentService.deleteCommentCascade(c); } reactionRepository.findByPost(post).forEach(reactionRepository::delete); postSubscriptionRepository.findByPost(post).forEach(postSubscriptionRepository::delete); notificationRepository.deleteAll(notificationRepository.findByPost(post)); postReadService.deleteByPost(post); imageUploader.removeReferences(imageUploader.extractUrls(post.getContent())); if (post instanceof LotteryPost lp) { ScheduledFuture future = scheduledFinalizations.remove(lp.getId()); if (future != null) { future.cancel(false); } } String title = post.getTitle(); postRepository.delete(post); if (adminDeleting) { notificationService.createNotification(author, NotificationType.POST_DELETED, null, null, null, user, null, title); } } public java.util.List getPostsByIds(java.util.List ids) { return postRepository.findAllById(ids); } public long countPostsByCategory(Long categoryId) { return postRepository.countByCategory_Id(categoryId); } public Map countPostsByCategoryIds(List categoryIds) { Map result = new HashMap<>(); var dbResult = postRepository.countPostsByCategoryIds(categoryIds); dbResult.forEach(r -> { result.put(((Long)r[0]), ((Long)r[1])); }); return result; } public long countPostsByTag(Long tagId) { return postRepository.countDistinctByTags_Id(tagId); } public Map countPostsByTagIds(List tagIds) { Map result = new HashMap<>(); if (CollectionUtils.isEmpty(tagIds)) { return result; } var dbResult = postRepository.countPostsByTagIds(tagIds); dbResult.forEach(r -> { result.put(((Long)r[0]), ((Long)r[1])); }); return result; } private java.util.List sortByPinnedAndCreated(java.util.List posts) { return posts.stream() .sorted(java.util.Comparator .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) .thenComparing(Post::getCreatedAt, java.util.Comparator.reverseOrder())) .toList(); } private java.util.List sortByPinnedAndViews(java.util.List posts) { return posts.stream() .sorted(java.util.Comparator .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) .thenComparing(Post::getViews, java.util.Comparator.reverseOrder())) .toList(); } private java.util.List sortByPinnedAndLastReply(java.util.List posts) { return posts.stream() .sorted(java.util.Comparator .comparing(Post::getPinnedAt, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder())) .thenComparing(p -> { java.time.LocalDateTime t = commentRepository.findLastCommentTime(p); return t != null ? t : p.getCreatedAt(); }, java.util.Comparator.nullsLast(java.util.Comparator.reverseOrder()))) .toList(); } private java.util.List paginate(java.util.List posts, Integer page, Integer pageSize) { if (page == null || pageSize == null) { return posts; } int from = page * pageSize; if (from >= posts.size()) { return java.util.List.of(); } int to = Math.min(from + pageSize, posts.size()); return posts.subList(from, to); } }