diff --git a/backend/src/main/java/com/openisle/controller/PostController.java b/backend/src/main/java/com/openisle/controller/PostController.java index aa1707ff1..598bc7ed2 100644 --- a/backend/src/main/java/com/openisle/controller/PostController.java +++ b/backend/src/main/java/com/openisle/controller/PostController.java @@ -3,6 +3,7 @@ package com.openisle.controller; import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostRequest; import com.openisle.dto.PostSummaryDto; +import com.openisle.dto.PollDto; import com.openisle.mapper.PostMapper; import com.openisle.model.Post; import com.openisle.service.*; @@ -42,7 +43,8 @@ public class PostController { req.getTitle(), req.getContent(), req.getTagIds(), req.getType(), req.getPrizeDescription(), req.getPrizeIcon(), req.getPrizeCount(), req.getPointCost(), - req.getStartTime(), req.getEndTime()); + req.getStartTime(), req.getEndTime(), + req.getOptions(), req.getMultiple()); draftService.deleteDraft(auth.getName()); PostDetailDto dto = postMapper.toDetailDto(post, auth.getName()); dto.setReward(levelService.awardForPost(auth.getName())); @@ -86,6 +88,17 @@ public class PostController { return ResponseEntity.ok().build(); } + @GetMapping("/{id}/poll/progress") + public ResponseEntity pollProgress(@PathVariable Long id) { + return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll()); + } + + @PostMapping("/{id}/poll/vote") + public ResponseEntity vote(@PathVariable Long id, @RequestParam("option") List option, Authentication auth) { + postService.votePoll(id, auth.getName(), option); + return ResponseEntity.ok().build(); + } + @GetMapping public List listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId, @RequestParam(value = "categoryIds", required = false) List categoryIds, diff --git a/backend/src/main/java/com/openisle/dto/PollDto.java b/backend/src/main/java/com/openisle/dto/PollDto.java new file mode 100644 index 000000000..af8da1de0 --- /dev/null +++ b/backend/src/main/java/com/openisle/dto/PollDto.java @@ -0,0 +1,17 @@ +package com.openisle.dto; + +import lombok.Data; + +import java.time.LocalDateTime; +import java.util.List; +import java.util.Map; + +@Data +public class PollDto { + private List options; + private Map votes; + private LocalDateTime endTime; + private List participants; + private Map> optionParticipants; + private boolean multiple; +} diff --git a/backend/src/main/java/com/openisle/dto/PostRequest.java b/backend/src/main/java/com/openisle/dto/PostRequest.java index 1fe02669a..bdebadb6c 100644 --- a/backend/src/main/java/com/openisle/dto/PostRequest.java +++ b/backend/src/main/java/com/openisle/dto/PostRequest.java @@ -26,5 +26,8 @@ public class PostRequest { private Integer pointCost; private LocalDateTime startTime; private LocalDateTime endTime; + // fields for poll posts + private List options; + private Boolean multiple; } diff --git a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java index 665c590f7..48205bc0e 100644 --- a/backend/src/main/java/com/openisle/dto/PostSummaryDto.java +++ b/backend/src/main/java/com/openisle/dto/PostSummaryDto.java @@ -31,6 +31,7 @@ public class PostSummaryDto { private int pointReward; private PostType type; private LotteryDto lottery; + private PollDto poll; private boolean rssExcluded; private boolean closed; } diff --git a/backend/src/main/java/com/openisle/mapper/PostMapper.java b/backend/src/main/java/com/openisle/mapper/PostMapper.java index ad1a826da..bd4a04826 100644 --- a/backend/src/main/java/com/openisle/mapper/PostMapper.java +++ b/backend/src/main/java/com/openisle/mapper/PostMapper.java @@ -5,18 +5,24 @@ import com.openisle.dto.PostDetailDto; import com.openisle.dto.PostSummaryDto; import com.openisle.dto.ReactionDto; import com.openisle.dto.LotteryDto; +import com.openisle.dto.PollDto; +import com.openisle.dto.AuthorDto; import com.openisle.model.CommentSort; import com.openisle.model.Post; import com.openisle.model.LotteryPost; +import com.openisle.model.PollPost; import com.openisle.model.User; +import com.openisle.model.PollVote; import com.openisle.service.CommentService; import com.openisle.service.ReactionService; import com.openisle.service.SubscriptionService; +import com.openisle.repository.PollVoteRepository; import lombok.RequiredArgsConstructor; import org.springframework.stereotype.Component; import java.time.LocalDateTime; import java.util.List; +import java.util.Map; import java.util.stream.Collectors; /** Mapper responsible for converting posts into DTOs. */ @@ -32,6 +38,7 @@ public class PostMapper { private final UserMapper userMapper; private final TagMapper tagMapper; private final CategoryMapper categoryMapper; + private final PollVoteRepository pollVoteRepository; public PostSummaryDto toSummaryDto(Post post) { PostSummaryDto dto = new PostSummaryDto(); @@ -93,5 +100,19 @@ public class PostMapper { l.setWinners(lp.getWinners().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); dto.setLottery(l); } + + if (post instanceof PollPost pp) { + PollDto p = new PollDto(); + p.setOptions(pp.getOptions()); + p.setVotes(pp.getVotes()); + p.setEndTime(pp.getEndTime()); + p.setParticipants(pp.getParticipants().stream().map(userMapper::toAuthorDto).collect(Collectors.toList())); + Map> optionParticipants = pollVoteRepository.findByPostId(pp.getId()).stream() + .collect(Collectors.groupingBy(PollVote::getOptionIndex, + Collectors.mapping(v -> userMapper.toAuthorDto(v.getUser()), Collectors.toList()))); + p.setOptionParticipants(optionParticipants); + p.setMultiple(Boolean.TRUE.equals(pp.getMultiple())); + dto.setPoll(p); + } } } diff --git a/backend/src/main/java/com/openisle/model/NotificationType.java b/backend/src/main/java/com/openisle/model/NotificationType.java index c4b4e0e25..ccfd57eb7 100644 --- a/backend/src/main/java/com/openisle/model/NotificationType.java +++ b/backend/src/main/java/com/openisle/model/NotificationType.java @@ -40,6 +40,12 @@ public enum NotificationType { LOTTERY_WIN, /** Your lottery post was drawn */ LOTTERY_DRAW, + /** Someone participated in your poll */ + POLL_VOTE, + /** Your poll post has concluded */ + POLL_RESULT_OWNER, + /** A poll you participated in has concluded */ + POLL_RESULT_PARTICIPANT, /** Your post was featured */ POST_FEATURED, /** You were mentioned in a post or comment */ diff --git a/backend/src/main/java/com/openisle/model/PollPost.java b/backend/src/main/java/com/openisle/model/PollPost.java new file mode 100644 index 000000000..494796f5d --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PollPost.java @@ -0,0 +1,43 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +import java.time.LocalDateTime; +import java.util.*; + +@Entity +@Table(name = "poll_posts") +@Getter +@Setter +@NoArgsConstructor +@PrimaryKeyJoinColumn(name = "post_id") +public class PollPost extends Post { + @ElementCollection + @CollectionTable(name = "poll_post_options", joinColumns = @JoinColumn(name = "post_id")) + @Column(name = "option_text") + private List options = new ArrayList<>(); + + @ElementCollection + @CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id")) + @MapKeyColumn(name = "option_index") + @Column(name = "vote_count") + private Map votes = new HashMap<>(); + + @ManyToMany + @JoinTable(name = "poll_participants", + joinColumns = @JoinColumn(name = "post_id"), + inverseJoinColumns = @JoinColumn(name = "user_id")) + private Set participants = new HashSet<>(); + + @Column + private Boolean multiple = false; + + @Column + private LocalDateTime endTime; + + @Column + private boolean resultAnnounced = false; +} diff --git a/backend/src/main/java/com/openisle/model/PollVote.java b/backend/src/main/java/com/openisle/model/PollVote.java new file mode 100644 index 000000000..41994dc90 --- /dev/null +++ b/backend/src/main/java/com/openisle/model/PollVote.java @@ -0,0 +1,28 @@ +package com.openisle.model; + +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; + +@Entity +@Table(name = "poll_votes", uniqueConstraints = @UniqueConstraint(columnNames = {"post_id", "user_id", "option_index"})) +@Getter +@Setter +@NoArgsConstructor +public class PollVote { + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "post_id") + private PollPost post; + + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @JoinColumn(name = "user_id") + private User user; + + @Column(name = "option_index", nullable = false) + private int optionIndex; +} diff --git a/backend/src/main/java/com/openisle/model/PostType.java b/backend/src/main/java/com/openisle/model/PostType.java index d14a28701..b8becaca0 100644 --- a/backend/src/main/java/com/openisle/model/PostType.java +++ b/backend/src/main/java/com/openisle/model/PostType.java @@ -2,5 +2,6 @@ package com.openisle.model; public enum PostType { NORMAL, - LOTTERY + LOTTERY, + POLL } diff --git a/backend/src/main/java/com/openisle/repository/PollPostRepository.java b/backend/src/main/java/com/openisle/repository/PollPostRepository.java new file mode 100644 index 000000000..853009946 --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PollPostRepository.java @@ -0,0 +1,13 @@ +package com.openisle.repository; + +import com.openisle.model.PollPost; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.time.LocalDateTime; +import java.util.List; + +public interface PollPostRepository extends JpaRepository { + List findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now); + + List findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now); +} diff --git a/backend/src/main/java/com/openisle/repository/PollVoteRepository.java b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java new file mode 100644 index 000000000..b2e823e4e --- /dev/null +++ b/backend/src/main/java/com/openisle/repository/PollVoteRepository.java @@ -0,0 +1,10 @@ +package com.openisle.repository; + +import com.openisle.model.PollVote; +import org.springframework.data.jpa.repository.JpaRepository; + +import java.util.List; + +public interface PollVoteRepository extends JpaRepository { + List findByPostId(Long postId); +} diff --git a/backend/src/main/java/com/openisle/service/PostService.java b/backend/src/main/java/com/openisle/service/PostService.java index 993c19c20..3b088817c 100644 --- a/backend/src/main/java/com/openisle/service/PostService.java +++ b/backend/src/main/java/com/openisle/service/PostService.java @@ -9,8 +9,11 @@ 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; @@ -20,6 +23,7 @@ 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; @@ -54,6 +58,8 @@ public class PostService { 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; @@ -78,6 +84,8 @@ public class PostService { CategoryRepository categoryRepository, TagRepository tagRepository, LotteryPostRepository lotteryPostRepository, + PollPostRepository pollPostRepository, + PollVoteRepository pollVoteRepository, NotificationService notificationService, SubscriptionService subscriptionService, CommentService commentService, @@ -97,6 +105,8 @@ public class PostService { this.categoryRepository = categoryRepository; this.tagRepository = tagRepository; this.lotteryPostRepository = lotteryPostRepository; + this.pollPostRepository = pollPostRepository; + this.pollVoteRepository = pollVoteRepository; this.notificationService = notificationService; this.subscriptionService = subscriptionService; this.commentService = commentService; @@ -125,6 +135,15 @@ public class PostService { 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() { @@ -166,7 +185,9 @@ public class PostService { Integer prizeCount, Integer pointCost, LocalDateTime startTime, - LocalDateTime endTime) { + LocalDateTime endTime, + java.util.List options, + Boolean multiple) { long recent = postRepository.countByAuthorAfter(username, java.time.LocalDateTime.now().minusMinutes(5)); if (recent >= 1) { @@ -200,6 +221,15 @@ public class PostService { 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(); } @@ -212,6 +242,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 PollPost) { + post = pollPostRepository.save((PollPost) post); } else { post = postRepository.save(post); } @@ -246,6 +278,11 @@ public class PostService { () -> 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; } @@ -261,6 +298,66 @@ public class PostService { } } + 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); + } + }); + } + @Transactional public void finalizeLottery(Long postId) { log.info("start to finalizeLottery for {}", postId); diff --git a/backend/src/test/java/com/openisle/controller/PostControllerTest.java b/backend/src/test/java/com/openisle/controller/PostControllerTest.java index 90779b57e..5b667e926 100644 --- a/backend/src/test/java/com/openisle/controller/PostControllerTest.java +++ b/backend/src/test/java/com/openisle/controller/PostControllerTest.java @@ -76,7 +76,7 @@ class PostControllerTest { post.setTags(Set.of(tag)); when(postService.createPost(eq("alice"), eq(1L), eq("t"), eq("c"), eq(List.of(1L)), - isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post); + isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull(), isNull())).thenReturn(post); when(postService.viewPost(eq(1L), any())).thenReturn(post); when(commentService.getCommentsForPost(eq(1L), any())).thenReturn(List.of()); when(commentService.getParticipants(anyLong(), anyInt())).thenReturn(List.of()); @@ -187,7 +187,7 @@ class PostControllerTest { .andExpect(status().isBadRequest()); verify(postService, never()).createPost(any(), any(), any(), any(), any(), - any(), any(), any(), any(), any(), any(), any()); + any(), any(), any(), any(), any(), any(), any(), any(), any()); } @Test diff --git a/backend/src/test/java/com/openisle/service/PostServiceTest.java b/backend/src/test/java/com/openisle/service/PostServiceTest.java index a7bf71caa..de3f58f43 100644 --- a/backend/src/test/java/com/openisle/service/PostServiceTest.java +++ b/backend/src/test/java/com/openisle/service/PostServiceTest.java @@ -146,7 +146,7 @@ class PostServiceTest { assertThrows(RateLimitException.class, () -> service.createPost("alice", 1L, "t", "c", List.of(1L), - null, null, null, null, null, null, null)); + null, null, null, null, null, null, null, null, null)); } @Test diff --git a/frontend_nuxt/assets/global.css b/frontend_nuxt/assets/global.css index 294f21e43..0435f54e3 100644 --- a/frontend_nuxt/assets/global.css +++ b/frontend_nuxt/assets/global.css @@ -34,6 +34,7 @@ --page-max-width-mobile: 900px; --article-info-background-color: #f0f0f0; --activity-card-background-color: #fafafa; + --poll-option-button-background-color: rgb(218, 218, 218); } [data-theme='dark'] { @@ -61,6 +62,7 @@ --blockquote-text-color: #999; --article-info-background-color: #747373; --activity-card-background-color: #585858; + --poll-option-button-background-color: #3a3a3a; } :root[data-frosted='off'] { @@ -91,7 +93,7 @@ body { .vditor-toolbar--pin { top: calc(var(--header-height) + 1px) !important; - z-index: 2000; + z-index: 20; } .vditor-panel { diff --git a/frontend_nuxt/components/LotteryForm.vue b/frontend_nuxt/components/LotteryForm.vue new file mode 100644 index 000000000..30bcdcd8b --- /dev/null +++ b/frontend_nuxt/components/LotteryForm.vue @@ -0,0 +1,189 @@ + + + + + diff --git a/frontend_nuxt/components/PollForm.vue b/frontend_nuxt/components/PollForm.vue new file mode 100644 index 000000000..320d7693f --- /dev/null +++ b/frontend_nuxt/components/PollForm.vue @@ -0,0 +1,100 @@ + + + + + diff --git a/frontend_nuxt/components/PostLottery.vue b/frontend_nuxt/components/PostLottery.vue new file mode 100644 index 000000000..29d487685 --- /dev/null +++ b/frontend_nuxt/components/PostLottery.vue @@ -0,0 +1,310 @@ + + + + + diff --git a/frontend_nuxt/components/PostPoll.vue b/frontend_nuxt/components/PostPoll.vue new file mode 100644 index 000000000..fdfc7cc07 --- /dev/null +++ b/frontend_nuxt/components/PostPoll.vue @@ -0,0 +1,459 @@ + + + + + diff --git a/frontend_nuxt/components/PostTypeSelect.vue b/frontend_nuxt/components/PostTypeSelect.vue index 3d01eacd7..fd713628c 100644 --- a/frontend_nuxt/components/PostTypeSelect.vue +++ b/frontend_nuxt/components/PostTypeSelect.vue @@ -33,6 +33,7 @@ export default { return [ { id: 'NORMAL', name: '普通帖子', icon: 'fa-regular fa-file' }, { id: 'LOTTERY', name: '抽奖帖子', icon: 'fa-solid fa-gift' }, + { id: 'POLL', name: '投票帖子', icon: 'fa-solid fa-square-poll-vertical' }, ] } diff --git a/frontend_nuxt/pages/index.vue b/frontend_nuxt/pages/index.vue index dfd51e349..e2be72a60 100644 --- a/frontend_nuxt/pages/index.vue +++ b/frontend_nuxt/pages/index.vue @@ -70,6 +70,10 @@ + {{ article.title }} @@ -542,7 +546,8 @@ const sanitizeDescription = (text) => stripMarkdown(text) } .pinned-icon, -.lottery-icon { +.lottery-icon, +.poll-icon { margin-right: 4px; color: var(--primary-color); } diff --git a/frontend_nuxt/pages/message.vue b/frontend_nuxt/pages/message.vue index 6da9ce0ef..bf252e616 100644 --- a/frontend_nuxt/pages/message.vue +++ b/frontend_nuxt/pages/message.vue @@ -195,6 +195,44 @@ 已开奖 + + +