Merge pull request #798 from nagisa77/feature/vote

feature for vote
This commit is contained in:
Tim
2025-09-01 10:28:44 +08:00
committed by GitHub
25 changed files with 1458 additions and 513 deletions

View File

@@ -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<PollDto> pollProgress(@PathVariable Long id) {
return ResponseEntity.ok(postMapper.toSummaryDto(postService.getPoll(id)).getPoll());
}
@PostMapping("/{id}/poll/vote")
public ResponseEntity<Void> vote(@PathVariable Long id, @RequestParam("option") List<Integer> option, Authentication auth) {
postService.votePoll(id, auth.getName(), option);
return ResponseEntity.ok().build();
}
@GetMapping
public List<PostSummaryDto> listPosts(@RequestParam(value = "categoryId", required = false) Long categoryId,
@RequestParam(value = "categoryIds", required = false) List<Long> categoryIds,

View File

@@ -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<String> options;
private Map<Integer, Integer> votes;
private LocalDateTime endTime;
private List<AuthorDto> participants;
private Map<Integer, List<AuthorDto>> optionParticipants;
private boolean multiple;
}

View File

@@ -26,5 +26,8 @@ public class PostRequest {
private Integer pointCost;
private LocalDateTime startTime;
private LocalDateTime endTime;
// fields for poll posts
private List<String> options;
private Boolean multiple;
}

View File

@@ -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;
}

View File

@@ -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<Integer, List<AuthorDto>> 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);
}
}
}

View File

@@ -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 */

View File

@@ -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<String> options = new ArrayList<>();
@ElementCollection
@CollectionTable(name = "poll_post_votes", joinColumns = @JoinColumn(name = "post_id"))
@MapKeyColumn(name = "option_index")
@Column(name = "vote_count")
private Map<Integer, Integer> votes = new HashMap<>();
@ManyToMany
@JoinTable(name = "poll_participants",
joinColumns = @JoinColumn(name = "post_id"),
inverseJoinColumns = @JoinColumn(name = "user_id"))
private Set<User> participants = new HashSet<>();
@Column
private Boolean multiple = false;
@Column
private LocalDateTime endTime;
@Column
private boolean resultAnnounced = false;
}

View File

@@ -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;
}

View File

@@ -2,5 +2,6 @@ package com.openisle.model;
public enum PostType {
NORMAL,
LOTTERY
LOTTERY,
POLL
}

View File

@@ -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<PollPost, Long> {
List<PollPost> findByEndTimeAfterAndResultAnnouncedFalse(LocalDateTime now);
List<PollPost> findByEndTimeBeforeAndResultAnnouncedFalse(LocalDateTime now);
}

View File

@@ -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<PollVote, Long> {
List<PollVote> findByPostId(Long postId);
}

View File

@@ -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<String> 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<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);