package com.openisle.service; import com.openisle.config.CachingConfig; 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.model.PollPost; import com.openisle.model.PollVote; import com.openisle.repository.PostRepository; import com.openisle.repository.LotteryPostRepository; import com.openisle.repository.PollPostRepository; 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.service.PostChangeLogService; import com.openisle.repository.CommentRepository; import com.openisle.repository.ReactionRepository; import com.openisle.repository.PostSubscriptionRepository; import com.openisle.repository.NotificationRepository; import com.openisle.repository.PollVoteRepository; import com.openisle.model.Role; import com.openisle.exception.RateLimitException; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; import org.springframework.context.ApplicationContext; import org.springframework.data.redis.core.RedisTemplate; import org.springframework.stereotype.Service; import org.springframework.scheduling.TaskScheduler; import com.openisle.service.EmailSender; import java.time.Duration; 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 final PollPostRepository pollPostRepository; private final PollVoteRepository pollVoteRepository; 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 PostChangeLogService postChangeLogService; private final ConcurrentMap> scheduledFinalizations = new ConcurrentHashMap<>(); @Value("${app.website-url:https://www.open-isle.com}") private String websiteUrl; private final RedisTemplate redisTemplate; @org.springframework.beans.factory.annotation.Autowired public PostService(PostRepository postRepository, UserRepository userRepository, CategoryRepository categoryRepository, TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, PollPostRepository pollPostRepository, PollVoteRepository pollVoteRepository, 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, PostChangeLogService postChangeLogService, @Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode, RedisTemplate redisTemplate) { this.postRepository = postRepository; this.userRepository = userRepository; this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; this.pollPostRepository = pollPostRepository; this.pollVoteRepository = pollVoteRepository; 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.postChangeLogService = postChangeLogService; this.publishMode = publishMode; this.redisTemplate = redisTemplate; } @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()); } for (PollPost pp : pollPostRepository.findByEndTimeAfterAndResultAnnouncedFalse(now)) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); scheduledFinalizations.put(pp.getId(), future); } for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { applicationContext.getBean(PostService.class).finalizePoll(pp.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, 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")); boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(true); Post saved = postRepository.save(post); postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, false); return saved; } public Post includeInRss(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")); boolean oldFeatured = !Boolean.TRUE.equals(post.getRssExcluded()); post.setRssExcluded(false); Post saved = postRepository.save(post); postChangeLogService.recordFeaturedChange(saved, user, oldFeatured, true); notificationService.createNotification(saved.getAuthor(), NotificationType.POST_FEATURED, saved, null, null, null, null, null); pointService.awardForFeatured(saved.getAuthor().getUsername(), saved.getId()); return saved; } 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, java.util.List options, Boolean multiple) { // 限制访问次数 boolean limitResult = postRateLimit(username); if (!limitResult) { 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 if (actualType == PostType.POLL) { if (options == null || options.size() < 2) { throw new IllegalArgumentException("At least two options required"); } PollPost pp = new PollPost(); pp.setOptions(options); pp.setEndTime(endTime); pp.setMultiple(multiple != null && multiple); post = pp; } 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 if (post instanceof PollPost) { post = pollPostRepository.save((PollPost) 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); } else if (post instanceof PollPost pp && pp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), java.util.Date.from(pp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); scheduledFinalizations.put(pp.getId(), future); } return post; } /** * 限制发帖频率 * @param username * @return */ private boolean postRateLimit(String username){ String key = CachingConfig.LIMIT_CACHE_NAME +":posts:"+username; String result = (String)redisTemplate.opsForValue().get(key); //最近没有创建过文章 if(StringUtils.isEmpty(result)){ // 限制频率为5分钟 redisTemplate.opsForValue().set(key,"1", Duration.ofMinutes(5)); return true; } return false; } 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); } } public PollPost getPoll(Long postId) { return pollPostRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); } @Transactional public PollPost votePoll(Long postId, String username, java.util.List optionIndices) { PollPost post = pollPostRepository.findById(postId) .orElseThrow(() -> new com.openisle.exception.NotFoundException("Post not found")); if (post.getEndTime() != null && post.getEndTime().isBefore(LocalDateTime.now())) { throw new IllegalStateException("Poll has ended"); } User user = userRepository.findByUsername(username) .orElseThrow(() -> new com.openisle.exception.NotFoundException("User not found")); if (post.getParticipants().contains(user)) { throw new IllegalArgumentException("User already voted"); } if (optionIndices == null || optionIndices.isEmpty()) { throw new IllegalArgumentException("No options selected"); } java.util.Set unique = new java.util.HashSet<>(optionIndices); for (int optionIndex : unique) { if (optionIndex < 0 || optionIndex >= post.getOptions().size()) { throw new IllegalArgumentException("Invalid option"); } } post.getParticipants().add(user); for (int optionIndex : unique) { post.getVotes().merge(optionIndex, 1, Integer::sum); PollVote vote = new PollVote(); vote.setPost(post); vote.setUser(user); vote.setOptionIndex(optionIndex); pollVoteRepository.save(vote); } PollPost saved = pollPostRepository.save(post); if (post.getAuthor() != null && !post.getAuthor().getId().equals(user.getId())) { notificationService.createNotification(post.getAuthor(), NotificationType.POLL_VOTE, post, null, null, user, null, null); } return saved; } @Transactional public void finalizePoll(Long postId) { scheduledFinalizations.remove(postId); pollPostRepository.findById(postId).ifPresent(pp -> { if (pp.isResultAnnounced()) { return; } pp.setResultAnnounced(true); pollPostRepository.save(pp); if (pp.getAuthor() != null) { notificationService.createNotification(pp.getAuthor(), NotificationType.POLL_RESULT_OWNER, pp, null, null, null, null, null); } for (User participant : pp.getParticipants()) { notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, pp, null, null, null, null, null); } postChangeLogService.recordVoteResult(pp); }); } @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 && !w.getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_WIN)) { 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 && !lp.getAuthor().getDisabledEmailNotificationTypes().contains(NotificationType.LOTTERY_DRAW)) { 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())); } postChangeLogService.recordLotteryResult(lp); }); } @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, 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")); java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(java.time.LocalDateTime.now()); Post saved = postRepository.save(post); postChangeLogService.recordPinnedChange(saved, user, oldPinned, saved.getPinnedAt()); return saved; } public Post unpinPost(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")); java.time.LocalDateTime oldPinned = post.getPinnedAt(); post.setPinnedAt(null); Post saved = postRepository.save(post); postChangeLogService.recordPinnedChange(saved, user, oldPinned, null); return saved; } 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"); } boolean oldClosed = post.isClosed(); post.setClosed(true); Post saved = postRepository.save(post); postChangeLogService.recordClosedChange(saved, user, oldClosed, true); return saved; } 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"); } boolean oldClosed = post.isClosed(); post.setClosed(false); Post saved = postRepository.save(post); postChangeLogService.recordClosedChange(saved, user, oldClosed, false); return saved; } @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"); } String oldTitle = post.getTitle(); String oldContent = post.getContent(); Category oldCategory = post.getCategory(); java.util.Set oldTags = new java.util.HashSet<>(post.getTags()); post.setTitle(title); 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); if (!java.util.Objects.equals(oldTitle, title)) { postChangeLogService.recordTitleChange(updated, user, oldTitle, title); } if (!java.util.Objects.equals(oldContent, content)) { postChangeLogService.recordContentChange(updated, user, oldContent, content); } if (!java.util.Objects.equals(oldCategory.getId(), category.getId())) { postChangeLogService.recordCategoryChange(updated, user, oldCategory.getName(), category.getName()); } java.util.Set newTags = new java.util.HashSet<>(tags); if (!oldTags.equals(newTags)) { postChangeLogService.recordTagChange(updated, user, oldTags, newTags); } 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); } }