mirror of
https://github.com/nagisa77/OpenIsle.git
synced 2026-02-10 00:51:00 +08:00
821 lines
38 KiB
Java
821 lines
38 KiB
Java
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.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.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.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 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 ConcurrentMap<Long, ScheduledFuture<?>> 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,
|
||
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,
|
||
@Value("${app.post.publish-mode:DIRECT}") PublishMode publishMode) {
|
||
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.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());
|
||
}
|
||
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<Post> 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<Long> tagIds,
|
||
PostType type,
|
||
String prizeDescription,
|
||
String prizeIcon,
|
||
Integer prizeCount,
|
||
Integer pointCost,
|
||
LocalDateTime startTime,
|
||
LocalDateTime endTime,
|
||
java.util.List<String> options,
|
||
Boolean multiple) {
|
||
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<com.openisle.model.Tag> 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<User> 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;
|
||
}
|
||
|
||
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<Integer> 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<Integer> 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);
|
||
}
|
||
});
|
||
}
|
||
|
||
@Transactional
|
||
public void finalizeLottery(Long postId) {
|
||
log.info("start to finalizeLottery for {}", postId);
|
||
scheduledFinalizations.remove(postId);
|
||
lotteryPostRepository.findById(postId).ifPresent(lp -> {
|
||
List<User> participants = new ArrayList<>(lp.getParticipants());
|
||
if (participants.isEmpty()) {
|
||
return;
|
||
}
|
||
Collections.shuffle(participants);
|
||
int winnersCount = Math.min(lp.getPrizeCount(), participants.size());
|
||
java.util.Set<User> 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()));
|
||
}
|
||
});
|
||
}
|
||
|
||
@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<Post> listPosts() {
|
||
return listPostsByCategories(null, null, null);
|
||
}
|
||
|
||
public List<Post> listPostsByViews(Integer page, Integer pageSize) {
|
||
return listPostsByViews(null, null, page, pageSize);
|
||
}
|
||
|
||
public List<Post> listPostsByViews(java.util.List<Long> categoryIds,
|
||
java.util.List<Long> tagIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||
|
||
java.util.List<Post> posts;
|
||
|
||
if (!hasCategories && !hasTags) {
|
||
posts = postRepository.findByStatusOrderByViewsDesc(PostStatus.PUBLISHED);
|
||
} else if (hasCategories) {
|
||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||
if (categories.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
if (hasTags) {
|
||
java.util.List<com.openisle.model.Tag> 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<com.openisle.model.Tag> 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<Post> listPostsByLatestReply(Integer page, Integer pageSize) {
|
||
return listPostsByLatestReply(null, null, page, pageSize);
|
||
}
|
||
|
||
public List<Post> listPostsByLatestReply(java.util.List<Long> categoryIds,
|
||
java.util.List<Long> tagIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
boolean hasCategories = categoryIds != null && !categoryIds.isEmpty();
|
||
boolean hasTags = tagIds != null && !tagIds.isEmpty();
|
||
|
||
java.util.List<Post> posts;
|
||
|
||
if (!hasCategories && !hasTags) {
|
||
posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||
} else if (hasCategories) {
|
||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||
if (categories.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
if (hasTags) {
|
||
java.util.List<com.openisle.model.Tag> 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<com.openisle.model.Tag> 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<Post> listPostsByCategories(java.util.List<Long> categoryIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
if (categoryIds == null || categoryIds.isEmpty()) {
|
||
java.util.List<Post> posts = postRepository.findByStatusOrderByCreatedAtDesc(PostStatus.PUBLISHED);
|
||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||
}
|
||
|
||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||
java.util.List<Post> posts = postRepository.findByCategoryInAndStatusOrderByCreatedAtDesc(categories, PostStatus.PUBLISHED);
|
||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||
}
|
||
|
||
public List<Post> 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<Post> listPostsByTags(java.util.List<Long> tagIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
if (tagIds == null || tagIds.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
|
||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||
if (tags.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
|
||
java.util.List<Post> posts = postRepository.findByAllTagsOrderByCreatedAtDesc(tags, PostStatus.PUBLISHED, tags.size());
|
||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||
}
|
||
|
||
public List<Post> listPostsByCategoriesAndTags(java.util.List<Long> categoryIds,
|
||
java.util.List<Long> tagIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
if (categoryIds == null || categoryIds.isEmpty() || tagIds == null || tagIds.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
|
||
java.util.List<Category> categories = categoryRepository.findAllById(categoryIds);
|
||
java.util.List<com.openisle.model.Tag> tags = tagRepository.findAllById(tagIds);
|
||
if (categories.isEmpty() || tags.isEmpty()) {
|
||
return java.util.List.of();
|
||
}
|
||
|
||
java.util.List<Post> posts = postRepository.findByCategoriesAndAllTagsOrderByCreatedAtDesc(categories, tags, PostStatus.PUBLISHED, tags.size());
|
||
return paginate(sortByPinnedAndCreated(posts), page, pageSize);
|
||
}
|
||
|
||
public List<Post> listFeaturedPosts(List<Long> categoryIds,
|
||
List<Long> tagIds,
|
||
Integer page,
|
||
Integer pageSize) {
|
||
List<Post> 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<Post> 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<com.openisle.model.Tag> 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<Long> 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<com.openisle.model.Tag> 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<Post> getPostsByIds(java.util.List<Long> ids) {
|
||
return postRepository.findAllById(ids);
|
||
}
|
||
|
||
public long countPostsByCategory(Long categoryId) {
|
||
return postRepository.countByCategory_Id(categoryId);
|
||
}
|
||
|
||
public Map<Long, Long> countPostsByCategoryIds(List<Long> categoryIds) {
|
||
Map<Long, Long> 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<Long, Long> countPostsByTagIds(List<Long> tagIds) {
|
||
Map<Long, Long> 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<Post> sortByPinnedAndCreated(java.util.List<Post> 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<Post> sortByPinnedAndViews(java.util.List<Post> 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<Post> sortByPinnedAndLastReply(java.util.List<Post> 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<Post> paginate(java.util.List<Post> 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);
|
||
}
|
||
}
|