diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 0af3758f6..9c4be50f1 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -1,10 +1,17 @@ package com.openisle.service; import com.openisle.config.CachingConfig; -import com.openisle.exception.NotFoundException; import com.openisle.exception.RateLimitException; import com.openisle.model.*; import com.openisle.repository.*; +import java.time.Duration; +import java.time.LocalDateTime; +import java.time.ZoneId; +import java.util.*; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.ConcurrentMap; +import java.util.concurrent.ScheduledFuture; +import java.util.stream.Collectors; import lombok.extern.slf4j.Slf4j; import org.apache.commons.lang3.StringUtils; import org.springframework.beans.factory.annotation.Value; @@ -21,36 +28,6 @@ import org.springframework.stereotype.Service; import org.springframework.transaction.annotation.Transactional; import org.springframework.util.CollectionUtils; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.util.*; -import com.openisle.exception.RateLimitException; -import com.openisle.mapper.PostMapper; -import com.openisle.model.*; -import com.openisle.repository.CategoryRepository; -import com.openisle.repository.CommentRepository; -import com.openisle.repository.LotteryPostRepository; -import com.openisle.repository.NotificationRepository; -import com.openisle.repository.PointHistoryRepository; -import com.openisle.repository.PollPostRepository; -import com.openisle.repository.PollVoteRepository; -import com.openisle.repository.PostRepository; -import com.openisle.repository.PostSubscriptionRepository; -import com.openisle.repository.ReactionRepository; -import com.openisle.repository.TagRepository; -import com.openisle.repository.UserRepository; -import com.openisle.service.EmailSender; -import java.time.Duration; -import java.time.LocalDateTime; -import java.time.ZoneId; -import java.time.ZoneOffset; -import java.util.*; -import java.util.concurrent.ConcurrentHashMap; -import java.util.concurrent.ConcurrentMap; -import java.util.concurrent.ScheduledFuture; -import java.util.stream.Collectors; - @Slf4j @Service public class PostService { @@ -121,6 +98,7 @@ public class PostService { this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; this.pollPostRepository = pollPostRepository; + this.categoryProposalPostRepository = categoryProposalPostRepository; this.pollVoteRepository = pollVoteRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; @@ -165,18 +143,23 @@ public class PostService { for (PollPost pp : pollPostRepository.findByEndTimeBeforeAndResultAnnouncedFalse(now)) { applicationContext.getBean(PostService.class).finalizePoll(pp.getId()); } - for (CategoryProposalPost cp : categoryProposalPostRepository - .findByEndTimeAfterAndProposalStatus(now, CategoryProposalStatus.PENDING)) { - if (cp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), - java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(cp.getId(), future); - } + for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeAfterAndProposalStatus( + now, + CategoryProposalStatus.PENDING + )) { + if (cp.getEndTime() != null) { + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(cp.getId(), future); + } } - for (CategoryProposalPost cp : categoryProposalPostRepository - .findByEndTimeBeforeAndProposalStatus(now, CategoryProposalStatus.PENDING)) { - applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); + for (CategoryProposalPost cp : categoryProposalPostRepository.findByEndTimeBeforeAndProposalStatus( + now, + CategoryProposalStatus.PENDING + )) { + applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()); } } @@ -302,41 +285,41 @@ public class PostService { pp.setMultiple(multiple != null && multiple); post = pp; } else if (actualType == PostType.PROPOSAL) { - CategoryProposalPost cp = new CategoryProposalPost(); - if (proposedName == null || proposedName.isBlank()) { - throw new IllegalArgumentException("Proposed name required"); + CategoryProposalPost cp = new CategoryProposalPost(); + if (proposedName == null || proposedName.isBlank()) { + throw new IllegalArgumentException("Proposed name required"); + } + if (proposedSlug == null || proposedSlug.isBlank()) { + throw new IllegalArgumentException("Proposed slug required"); + } + if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { + throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); + } + cp.setProposedName(proposedName); + cp.setProposedSlug(proposedSlug); + cp.setDescription(proposalDescription); + if (approveThreshold != null) { + if (approveThreshold < 0 || approveThreshold > 100) { + throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); } - if (proposedSlug == null || proposedSlug.isBlank()) { - throw new IllegalArgumentException("Proposed slug required"); + cp.setApproveThreshold(approveThreshold); + } + if (quorum != null) { + if (quorum < 0) { + throw new IllegalArgumentException("quorum must be >= 0"); } - if (categoryProposalPostRepository.existsByProposedSlug(proposedSlug)) { - throw new IllegalArgumentException("Proposed slug already exists: " + proposedSlug); - } - cp.setProposedName(proposedName); - cp.setProposedSlug(proposedSlug); - cp.setDescription(proposalDescription); - if (approveThreshold != null) { - if (approveThreshold < 0 || approveThreshold > 100) { - throw new IllegalArgumentException("approveThreshold must be between 0 and 100"); - } - cp.setApproveThreshold(approveThreshold); - } - if (quorum != null) { - if (quorum < 0) { - throw new IllegalArgumentException("quorum must be >= 0"); - } - cp.setQuorum(quorum); - } - cp.setStartAt(startTime); - cp.setEndTime(endTime); - // default yes/no options if not provided - if (options == null || options.size() < 2) { - cp.setOptions(List.of("同意", "反对")); - } else { - cp.setOptions(options); - } - cp.setMultiple(false); - post = cp; + cp.setQuorum(quorum); + } + cp.setStartAt(startTime); + cp.setEndTime(endTime); + // default yes/no options if not provided + if (options == null || options.size() < 2) { + cp.setOptions(List.of("同意", "反对")); + } else { + cp.setOptions(options); + } + cp.setMultiple(false); + post = cp; } else { post = new Post(); } @@ -349,8 +332,8 @@ public class PostService { post.setStatus(publishMode == PublishMode.REVIEW ? PostStatus.PENDING : PostStatus.PUBLISHED); if (post instanceof LotteryPost) { post = lotteryPostRepository.save((LotteryPost) post); - }else if (post instanceof CategoryProposalPost categoryProposalPost) { - post = categoryProposalPostRepository.save(categoryProposalPost); + } else if (post instanceof CategoryProposalPost categoryProposalPost) { + post = categoryProposalPostRepository.save(categoryProposalPost); } else if (post instanceof PollPost) { post = pollPostRepository.save((PollPost) post); } else { @@ -406,10 +389,11 @@ public class PostService { ); scheduledFinalizations.put(lp.getId(), future); } else if (post instanceof CategoryProposalPost cp && cp.getEndTime() != null) { - ScheduledFuture future = taskScheduler.schedule( - () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), - java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant())); - scheduledFinalizations.put(cp.getId(), future); + ScheduledFuture future = taskScheduler.schedule( + () -> applicationContext.getBean(PostService.class).finalizeProposal(cp.getId()), + java.util.Date.from(cp.getEndTime().atZone(ZoneId.systemDefault()).toInstant()) + ); + scheduledFinalizations.put(cp.getId(), future); } else if (post instanceof PollPost pp && pp.getEndTime() != null) { ScheduledFuture future = taskScheduler.schedule( () -> applicationContext.getBean(PostService.class).finalizePoll(pp.getId()), @@ -420,47 +404,72 @@ public class PostService { return post; } - @CacheEvict( - value = CachingConfig.POST_CACHE_NAME, allEntries = true - ) + @CacheEvict(value = CachingConfig.POST_CACHE_NAME, allEntries = true) @Transactional public void finalizeProposal(Long postId) { scheduledFinalizations.remove(postId); - categoryProposalPostRepository.findById(postId).ifPresent(cp -> { - if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { - return; - } - int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; - int approveVotes = 0; - if (cp.getVotes() != null) { - approveVotes = cp.getVotes().getOrDefault(0, 0); - } - boolean quorumMet = totalParticipants >= cp.getQuorum(); - int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; - boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); - if (quorumMet && thresholdMet) { - cp.setProposalStatus(CategoryProposalStatus.APPROVED); - } else { - cp.setProposalStatus(CategoryProposalStatus.REJECTED); - String reason; - if (!quorumMet && !thresholdMet) { - reason = "未达到法定人数且赞成率不足"; - } else if (!quorumMet) { - reason = "未达到法定人数"; - } else { - reason = "赞成率不足"; - } - cp.setRejectReason(reason); - } - cp.setResultSnapshot("approveVotes=" + approveVotes + ", totalParticipants=" + totalParticipants + ", approvePercent=" + approvePercent); - categoryProposalPostRepository.save(cp); - if (cp.getAuthor() != null) { - notificationService.createNotification(cp.getAuthor(), NotificationType.POLL_RESULT_OWNER, cp, null, null, null, null, null); - } - for (User participant : cp.getParticipants()) { - notificationService.createNotification(participant, NotificationType.POLL_RESULT_PARTICIPANT, cp, null, null, null, null, null); - } - postChangeLogService.recordVoteResult(cp); + categoryProposalPostRepository + .findById(postId) + .ifPresent(cp -> { + if (cp.getProposalStatus() != CategoryProposalStatus.PENDING) { + return; + } + int totalParticipants = cp.getParticipants() != null ? cp.getParticipants().size() : 0; + int approveVotes = 0; + if (cp.getVotes() != null) { + approveVotes = cp.getVotes().getOrDefault(0, 0); + } + boolean quorumMet = totalParticipants >= cp.getQuorum(); + int approvePercent = totalParticipants > 0 ? (approveVotes * 100) / totalParticipants : 0; + boolean thresholdMet = approvePercent >= cp.getApproveThreshold(); + if (quorumMet && thresholdMet) { + cp.setProposalStatus(CategoryProposalStatus.APPROVED); + } else { + cp.setProposalStatus(CategoryProposalStatus.REJECTED); + String reason; + if (!quorumMet && !thresholdMet) { + reason = "未达到法定人数且赞成率不足"; + } else if (!quorumMet) { + reason = "未达到法定人数"; + } else { + reason = "赞成率不足"; + } + cp.setRejectReason(reason); + } + cp.setResultSnapshot( + "approveVotes=" + + approveVotes + + ", totalParticipants=" + + totalParticipants + + ", approvePercent=" + + approvePercent + ); + categoryProposalPostRepository.save(cp); + if (cp.getAuthor() != null) { + notificationService.createNotification( + cp.getAuthor(), + NotificationType.POLL_RESULT_OWNER, + cp, + null, + null, + null, + null, + null + ); + } + for (User participant : cp.getParticipants()) { + notificationService.createNotification( + participant, + NotificationType.POLL_RESULT_PARTICIPANT, + cp, + null, + null, + null, + null, + null + ); + } + postChangeLogService.recordVoteResult(cp); }); } diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 12180eecc..6a5cdfdc5 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -76,6 +76,15 @@ class PostControllerTest { @MockBean private MedalService medalService; + @MockBean + private CategoryService categoryService; + + @MockBean + private TagService tagService; + + @MockBean + private PointService pointService; + @MockBean private com.openisle.repository.PollVoteRepository pollVoteRepository; @@ -275,6 +284,7 @@ class PostControllerTest { any(), any(), any(), + any(), any() ); } diff --git a/backend/src/test/resources/application.properties b/backend/src/test/resources/application.properties index b65fb13f2..a7bbe101e 100644 --- a/backend/src/test/resources/application.properties +++ b/backend/src/test/resources/application.properties @@ -46,3 +46,4 @@ app.avatar.base-url=${AVATAR_BASE_URL:https://api.dicebear.com/6.x} # Web push configuration app.webpush.public-key=${WEBPUSH_PUBLIC_KEY:} app.webpush.private-key=${WEBPUSH_PRIVATE_KEY:} +app.snippet-length=${SNIPPET_LENGTH:200}